@tikoci/centrs 0.1.0 → 0.1.2

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 (45) hide show
  1. package/CHANGELOG.md +73 -2
  2. package/GLOSSARY.txt +16 -0
  3. package/README.md +1 -1
  4. package/commands/AGENTS.md +3 -2
  5. package/commands/api/AGENTS.md +212 -0
  6. package/commands/api/README.md +159 -0
  7. package/commands/api/examples.md +325 -0
  8. package/commands/btest/README.md +56 -31
  9. package/commands/btest/examples.md +35 -10
  10. package/commands/devices/README.md +26 -0
  11. package/commands/discover/README.md +17 -8
  12. package/commands/discover/examples.md +10 -5
  13. package/commands/execute/README.md +9 -3
  14. package/commands/retrieve/README.md +1 -1
  15. package/docs/CONSTITUTION.md +6 -6
  16. package/docs/MATRIX.md +46 -15
  17. package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
  18. package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
  19. package/docs/errors/usage/invalid-method.md +23 -0
  20. package/package.json +5 -5
  21. package/src/api.ts +1555 -0
  22. package/src/btest.ts +61 -1
  23. package/src/cli/api.ts +536 -0
  24. package/src/cli/btest.ts +45 -1
  25. package/src/cli/common.ts +64 -0
  26. package/src/cli/discover.ts +26 -0
  27. package/src/cli/execute.ts +30 -4
  28. package/src/cli/missing-target.ts +171 -0
  29. package/src/cli/retrieve.ts +32 -14
  30. package/src/cli/terminal.ts +18 -4
  31. package/src/cli/transfer.ts +37 -9
  32. package/src/cli.ts +22 -0
  33. package/src/core/error-catalog.ts +14 -0
  34. package/src/core/fanout.ts +160 -0
  35. package/src/core/inspect.ts +170 -0
  36. package/src/discover.ts +96 -23
  37. package/src/execute.ts +14 -47
  38. package/src/index.ts +61 -8
  39. package/src/protocols/adapter.ts +328 -0
  40. package/src/protocols/btest-session.ts +279 -46
  41. package/src/protocols/btest.ts +6 -2
  42. package/src/protocols/native-api.ts +223 -30
  43. package/src/retrieve-fanout.ts +13 -141
  44. package/src/retrieve.ts +30 -87
  45. package/commands/stream/README.md +0 -70
package/src/cli/common.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  * together.
7
7
  */
8
8
 
9
+ import { CentrsError } from "../errors.ts";
10
+
9
11
  export interface CliCommandOption {
10
12
  flag: string;
11
13
  valueName?: string;
@@ -31,6 +33,68 @@ export function expectValue(
31
33
  return value;
32
34
  }
33
35
 
36
+ /** Flatten an option's `flag` field ("-X / --method") into individual `-`/`--` tokens. */
37
+ export function knownFlags(options: readonly CliCommandOption[]): string[] {
38
+ const flags: string[] = [];
39
+ for (const option of options) {
40
+ for (const token of option.flag.split("/")) {
41
+ const trimmed = token.trim();
42
+ if (trimmed.startsWith("-")) {
43
+ flags.push(trimmed);
44
+ }
45
+ }
46
+ }
47
+ return flags;
48
+ }
49
+
50
+ function editDistance(a: string, b: string): number {
51
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
52
+ for (let i = 1; i <= a.length; i += 1) {
53
+ const curr = [i];
54
+ for (let j = 1; j <= b.length; j += 1) {
55
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
56
+ curr[j] = Math.min(
57
+ (curr[j - 1] ?? 0) + 1,
58
+ (prev[j] ?? 0) + 1,
59
+ (prev[j - 1] ?? 0) + cost,
60
+ );
61
+ }
62
+ prev = curr;
63
+ }
64
+ return prev[b.length] ?? 0;
65
+ }
66
+
67
+ /** Closest known flags to `arg` (Levenshtein ≤ 2 — tight enough to avoid noise), nearest first, capped at 3. */
68
+ export function closestFlags(arg: string, flags: readonly string[]): string[] {
69
+ return [...new Set(flags)]
70
+ .map((flag) => ({ flag, distance: editDistance(arg, flag) }))
71
+ .filter(({ distance }) => distance <= 2)
72
+ .sort((left, right) => left.distance - right.distance)
73
+ .slice(0, 3)
74
+ .map(({ flag }) => flag);
75
+ }
76
+
77
+ /**
78
+ * Actionable "unknown flag" error: lists the closest canonical matches (and
79
+ * aliases) so humans and agents can recover (commands/AGENTS.md → "Did you
80
+ * mean?"). Shared seam — parsers pass their own `command` + option metadata.
81
+ */
82
+ export function unknownFlagError(
83
+ command: string,
84
+ arg: string,
85
+ options: readonly CliCommandOption[],
86
+ ): CentrsError {
87
+ const suggestions = closestFlags(arg, knownFlags(options));
88
+ const hint =
89
+ suggestions.length > 0 ? ` Did you mean ${suggestions.join(", ")}?` : "";
90
+ return new CentrsError({
91
+ code: "input/invalid-command",
92
+ summary: `Unknown ${command} flag: ${arg}.${hint}`,
93
+ remediation: `Run \`centrs ${command} --help\` to see the supported flags${suggestions.length > 0 ? " and their aliases" : ""}.`,
94
+ context: { flag: arg, suggestions },
95
+ });
96
+ }
97
+
34
98
  export function renderCommandHelp(
35
99
  describe: string,
36
100
  command: CliCommandMetadata,
@@ -6,10 +6,12 @@
6
6
  */
7
7
 
8
8
  import {
9
+ DISCOVER_DEFAULT_TIMEOUT_MS,
9
10
  type DiscoverOptions,
10
11
  type DiscoverOutputFormat,
11
12
  discover,
12
13
  discoverOutputFormats,
14
+ MNDP_PORT,
13
15
  renderDiscoverEnvelope,
14
16
  } from "../discover.ts";
15
17
  import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
@@ -142,6 +144,29 @@ function parseDiscoverCliArgs(args: readonly string[]): DiscoverCliArgs {
142
144
  return parsed;
143
145
  }
144
146
 
147
+ /**
148
+ * A 15s passive listen looks like a hang on an interactive terminal, so print a
149
+ * one-line progress note to **stderr** for the human-facing text format on a
150
+ * TTY. Skipped when piped/redirected (not a TTY) and for the `--format
151
+ * json|yaml` structured modes, so machine consumers never see it. stderr is the
152
+ * sanctioned channel for discover scan progress (`docs/CONSTITUTION.md` → Result
153
+ * envelope).
154
+ */
155
+ function emitDiscoverProgress(
156
+ options: DiscoverOptions,
157
+ format: DiscoverOutputFormat | undefined,
158
+ ): void {
159
+ if (format === "json" || format === "yaml" || !process.stderr.isTTY) {
160
+ return;
161
+ }
162
+ const windowMs = options.timeoutMs ?? DISCOVER_DEFAULT_TIMEOUT_MS;
163
+ const port = options.port ?? MNDP_PORT;
164
+ const hint = options.save ? "" : " (add --save to populate the CDB)";
165
+ process.stderr.write(
166
+ `Listening for MNDP neighbors for ${Math.round(windowMs / 1000)}s on UDP ${port}…${hint}\n`,
167
+ );
168
+ }
169
+
145
170
  export async function runDiscoverCli(args: readonly string[]): Promise<number> {
146
171
  let parsed: DiscoverCliArgs | undefined;
147
172
  try {
@@ -160,6 +185,7 @@ export async function runDiscoverCli(args: readonly string[]): Promise<number> {
160
185
  cdbPassword: parsed.cdbPassword,
161
186
  env: Bun.env,
162
187
  };
188
+ emitDiscoverProgress(options, parsed.format);
163
189
  const envelope = await discover(options);
164
190
  const format = parsed.format ?? "text";
165
191
  const rendered = renderDiscoverEnvelope(envelope, format);
@@ -13,6 +13,14 @@ import {
13
13
  expectValue,
14
14
  renderCommandHelp,
15
15
  } from "./common.ts";
16
+ import {
17
+ buildTargetSelectionTips,
18
+ cdbFileFromArgs,
19
+ formatTipsText,
20
+ isMissingTargetError,
21
+ missingTargetError,
22
+ withTips,
23
+ } from "./missing-target.ts";
16
24
 
17
25
  export const executeCommand: CliCommandMetadata = {
18
26
  name: "execute",
@@ -241,6 +249,18 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
241
249
  console.log(renderCommandHelp(describeCentrs(), executeCommand));
242
250
  return 0;
243
251
  }
252
+ // No positional at all: lead with the missing-target guidance (CDB picker /
253
+ // discover) rather than the missing-command error — there is nothing to run
254
+ // without a device. A lone positional is treated as the target, so the
255
+ // "requires a command" path still fires for `execute <target>`.
256
+ if (!parsed.targetInput) {
257
+ throw missingTargetError({
258
+ command: "execute",
259
+ summary: "`centrs execute` requires a <target> and a RouterOS command.",
260
+ remediation:
261
+ 'Pass the router host/identity then the command, e.g. `centrs execute 192.0.2.10 "/system/resource/print"`.',
262
+ });
263
+ }
244
264
 
245
265
  const envelope = await executeEnvelope(parsed);
246
266
  const resolvedFormat =
@@ -264,10 +284,16 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
264
284
  // Parse/usage errors land here too: surface them through the same typed
265
285
  // envelope as every other runner (no raw message, no exit code 2).
266
286
  const format = inferExecuteFormat(args, parsed);
287
+ const tips = isMissingTargetError(error)
288
+ ? await buildTargetSelectionTips({
289
+ cdbFile: parsed?.cdbFile ?? cdbFileFromArgs(args),
290
+ env: Bun.env,
291
+ })
292
+ : [];
267
293
  if (format === "json" || format === "yaml") {
268
- const envelope = buildExecuteErrorEnvelope(
269
- parsed ?? { command: "" },
270
- error,
294
+ const envelope = withTips(
295
+ buildExecuteErrorEnvelope(parsed ?? { command: "" }, error),
296
+ tips,
271
297
  );
272
298
  console.error(
273
299
  renderExecuteEnvelope(envelope, format, {
@@ -284,7 +310,7 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
284
310
  "Use `centrs execute --help` to inspect the supported command shape and flags.",
285
311
  }),
286
312
  { verbose: parsed?.verbose ?? args.includes("--verbose") },
287
- ),
313
+ ) + formatTipsText(tips),
288
314
  );
289
315
  }
290
316
  return 1;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Shared "no `<router>` target" guidance for the CLI surfaces.
3
+ *
4
+ * Every command that takes a `<router>` (terminal, execute, retrieve, transfer,
5
+ * btest client) raises the same `input/invalid-command` error when the target is
6
+ * missing, tagged with `context.missingTarget`. Each runner then calls
7
+ * {@link buildTargetSelectionTips} to turn that bare usage error into actionable
8
+ * advice: list a few saved CDB devices to pass as `<router>`, or — when the
9
+ * registry is empty — point at `centrs discover --save`. Tips ride the envelope's
10
+ * `tips` channel (`docs/CONSTITUTION.md` → Result envelope); this module also
11
+ * renders them for the **text** error path, which `formatCentrsErrorText` does
12
+ * not surface on its own.
13
+ */
14
+
15
+ import { buildTip, type Tip } from "../core/envelope.ts";
16
+ import { defaultCdbPath, loadCdb } from "../devices.ts";
17
+ import { CentrsError } from "../errors.ts";
18
+ import { DEFAULT_RECORD_TARGET, identityFromComment } from "../resolver/cdb.ts";
19
+
20
+ /** Cap on how many saved devices a single tip names before "(+N more)". */
21
+ const MAX_LISTED_DEVICES = 8;
22
+
23
+ export interface MissingTargetErrorInit {
24
+ command: string;
25
+ summary: string;
26
+ remediation: string;
27
+ }
28
+
29
+ /**
30
+ * The canonical "missing `<router>`" usage error. The `context.missingTarget`
31
+ * flag is the marker {@link isMissingTargetError} keys on so a runner knows to
32
+ * attach {@link buildTargetSelectionTips} output.
33
+ */
34
+ export function missingTargetError(init: MissingTargetErrorInit): CentrsError {
35
+ return new CentrsError({
36
+ code: "input/invalid-command",
37
+ summary: init.summary,
38
+ remediation: init.remediation,
39
+ context: { missingTarget: true, command: init.command },
40
+ });
41
+ }
42
+
43
+ /** True for an error raised by {@link missingTargetError} (or one tagged like it). */
44
+ export function isMissingTargetError(error: unknown): boolean {
45
+ return (
46
+ error instanceof CentrsError && error.context?.["missingTarget"] === true
47
+ );
48
+ }
49
+
50
+ export interface CdbInputs {
51
+ cdbFile?: string;
52
+ env?: Record<string, string | undefined>;
53
+ }
54
+
55
+ /**
56
+ * Pull the `--cdb-file` path out of raw args. Used by catch paths where the
57
+ * request never finished parsing (the missing-target throw happens mid-parse),
58
+ * so the request object is unavailable but the flag may still be on the command
59
+ * line. The CDB *password* is deliberately not extracted: a credential must
60
+ * never flow into the (logged) tip path, and the tips read only an unencrypted
61
+ * registry — an encrypted CDB falls back to generic guidance.
62
+ */
63
+ export function cdbFileFromArgs(args: readonly string[]): string | undefined {
64
+ for (let index = 0; index < args.length; index += 1) {
65
+ if (args[index] === "--cdb-file") {
66
+ if (index + 1 >= args.length) return undefined;
67
+ const value = args[index + 1] as string; // bounds-checked above
68
+ if (value.startsWith("--")) return undefined;
69
+ return value;
70
+ }
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ /**
76
+ * Best-effort: inspect the CDB and turn "no `<router>`" into actionable tips —
77
+ * a list of saved targets when the registry has any, otherwise a pointer to
78
+ * `discover --save`. Never throws: an unreadable/encrypted CDB falls back to
79
+ * generic guidance, and a missing default CDB is reported as an empty registry
80
+ * (no file is created as a side effect of a usage error). The CDB password is
81
+ * intentionally never read here, so no credential can reach the rendered tip.
82
+ */
83
+ export async function buildTargetSelectionTips(
84
+ inputs: CdbInputs,
85
+ ): Promise<Tip[]> {
86
+ const env = inputs.env ?? {};
87
+ const explicitCdb =
88
+ inputs.cdbFile !== undefined || env["CENTRS_CDB_FILE"] !== undefined;
89
+ if (!explicitCdb && !(await Bun.file(defaultCdbPath(env)).exists())) {
90
+ return [emptyRegistryTip()];
91
+ }
92
+
93
+ let entries: readonly { target: string; comment: string }[];
94
+ try {
95
+ const cdb = await loadCdb({
96
+ cdbFile: inputs.cdbFile,
97
+ env,
98
+ });
99
+ entries = cdb.entries.filter(
100
+ (entry) =>
101
+ entry.target.length > 0 && entry.target !== DEFAULT_RECORD_TARGET,
102
+ );
103
+ } catch {
104
+ return [unreadableRegistryTip()];
105
+ }
106
+
107
+ if (entries.length === 0) {
108
+ return [emptyRegistryTip()];
109
+ }
110
+
111
+ const handles = entries
112
+ .slice(0, MAX_LISTED_DEVICES)
113
+ .map((entry) => identityFromComment(entry.comment, entry.target));
114
+ const extra =
115
+ entries.length > MAX_LISTED_DEVICES
116
+ ? ` (+${entries.length - MAX_LISTED_DEVICES} more)`
117
+ : "";
118
+ return [
119
+ buildTip(
120
+ "tip/select-target",
121
+ `Pass one of your ${entries.length} saved device(s) as <router>: ${handles.join(", ")}${extra}.`,
122
+ "Run `centrs devices list` for every saved target; <router> also resolves by identity=, mac=, or ip=.",
123
+ ),
124
+ ];
125
+ }
126
+
127
+ function emptyRegistryTip(): Tip {
128
+ return buildTip(
129
+ "tip/no-devices",
130
+ "No devices are saved in the CDB yet.",
131
+ "Run `centrs discover --save` to find RouterOS neighbors on the LAN and add them, then pass one as <router> (or `centrs devices add <target> --user … --password …`).",
132
+ );
133
+ }
134
+
135
+ function unreadableRegistryTip(): Tip {
136
+ return buildTip(
137
+ "tip/select-target",
138
+ "Could not read the CDB to list saved devices (it may be encrypted — pass --cdb-password).",
139
+ "Run `centrs devices list` to see saved targets, or `centrs discover --save` to find and add neighbors on the LAN.",
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Render tips for the **text** error path (mirrors the `devices` "Tips:"
145
+ * footer). Returns "" for an empty list so callers can append unconditionally.
146
+ */
147
+ export function formatTipsText(tips: readonly Tip[]): string {
148
+ if (tips.length === 0) {
149
+ return "";
150
+ }
151
+ const lines = ["", "Tips:"];
152
+ for (const tip of tips) {
153
+ lines.push(` - [${tip.code}] ${tip.message}`);
154
+ if (tip.fix) {
155
+ lines.push(` fix: ${tip.fix}`);
156
+ }
157
+ }
158
+ return lines.join("\n");
159
+ }
160
+
161
+ /**
162
+ * Return a copy of an envelope with `tips` attached (no-op for an empty list).
163
+ * The `as E` keeps the call site's envelope type when an error envelope declares
164
+ * `tips: []` (an empty tuple) — we only ever widen it to a populated list.
165
+ */
166
+ export function withTips<E extends { tips: readonly Tip[] }>(
167
+ envelope: E,
168
+ tips: readonly Tip[],
169
+ ): E {
170
+ return tips.length > 0 ? ({ ...envelope, tips } as E) : envelope;
171
+ }
@@ -22,6 +22,14 @@ import {
22
22
  expectValue,
23
23
  renderCommandHelp,
24
24
  } from "./common.ts";
25
+ import {
26
+ buildTargetSelectionTips,
27
+ cdbFileFromArgs,
28
+ formatTipsText,
29
+ isMissingTargetError,
30
+ missingTargetError,
31
+ withTips,
32
+ } from "./missing-target.ts";
25
33
 
26
34
  export const retrieveCommand: CliCommandMetadata = {
27
35
  name: "retrieve",
@@ -171,6 +179,12 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
171
179
  return 0;
172
180
  } catch (error) {
173
181
  const format = inferRequestedFormat(args, request);
182
+ const tips = isMissingTargetError(error)
183
+ ? await buildTargetSelectionTips({
184
+ cdbFile: request?.cdbFile ?? cdbFileFromArgs(args),
185
+ env: Bun.env,
186
+ })
187
+ : [];
174
188
  if (format === "json" || format === "yaml") {
175
189
  if (request?.group) {
176
190
  const envelope = buildRetrieveFanoutErrorEnvelope(request, error);
@@ -180,9 +194,14 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
180
194
  }),
181
195
  );
182
196
  } else {
183
- const envelope = buildRetrieveErrorEnvelope(
184
- request ?? fallbackRequestFromArgs(args),
185
- error,
197
+ // When parsing failed before a request existed, build the error
198
+ // envelope from an empty request rather than reconstructing positionals
199
+ // from raw args — a credential value (e.g. the token after
200
+ // `--password` / `--cdb-password`) would otherwise be echoed as
201
+ // `meta.target.input`.
202
+ const envelope = withTips(
203
+ buildRetrieveErrorEnvelope(request ?? { path: "" }, error),
204
+ tips,
186
205
  );
187
206
  console.error(
188
207
  renderRetrieveEnvelope(envelope, format, {
@@ -202,7 +221,7 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
202
221
  {
203
222
  verbose: request?.verbose ?? args.includes("--verbose"),
204
223
  },
205
- ),
224
+ ) + formatTipsText(tips),
206
225
  );
207
226
  }
208
227
  return 1;
@@ -341,9 +360,17 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveRequest & {
341
360
  return request;
342
361
  }
343
362
 
363
+ if (positional.length === 0) {
364
+ throw missingTargetError({
365
+ command: "retrieve",
366
+ summary: "`centrs retrieve` requires a <target> and a <routeros-path>.",
367
+ remediation:
368
+ "Pass the router host/identity then the RouterOS path, e.g. `centrs retrieve 192.0.2.10 /system/resource`.",
369
+ });
370
+ }
344
371
  if (positional.length < 2) {
345
372
  throw new Error(
346
- "`centrs retrieve` requires both <target> and <routeros-path>.",
373
+ "`centrs retrieve` requires a <routeros-path> after the <target>.",
347
374
  );
348
375
  }
349
376
 
@@ -372,12 +399,3 @@ function inferRequestedFormat(
372
399
  }
373
400
  return "text";
374
401
  }
375
-
376
- function fallbackRequestFromArgs(args: readonly string[]): RetrieveRequest {
377
- const positionals = args.filter((arg) => !arg.startsWith("-"));
378
- return {
379
- targetInput: positionals[0],
380
- path: positionals[1] ?? "",
381
- format: inferRequestedFormat(args),
382
- };
383
- }
@@ -20,6 +20,14 @@ import {
20
20
  expectValue,
21
21
  renderCommandHelp,
22
22
  } from "./common.ts";
23
+ import {
24
+ buildTargetSelectionTips,
25
+ cdbFileFromArgs,
26
+ formatTipsText,
27
+ isMissingTargetError,
28
+ missingTargetError,
29
+ withTips,
30
+ } from "./missing-target.ts";
23
31
 
24
32
  export const terminalCommand: CliCommandMetadata = {
25
33
  name: "terminal",
@@ -164,8 +172,14 @@ export async function runTerminalCli(args: readonly string[]): Promise<number> {
164
172
  // error nor these renderers, so the alert is dismissed as a false positive
165
173
  // repo-wide — see the matching dismissals on src/cli/{transfer,execute}.ts.
166
174
  const format = inferRequestedFormat(args);
175
+ const tips = isMissingTargetError(error)
176
+ ? await buildTargetSelectionTips({
177
+ cdbFile: request?.cdbFile ?? cdbFileFromArgs(args),
178
+ env: Bun.env,
179
+ })
180
+ : [];
167
181
  if (format === "json" || format === "yaml") {
168
- const envelope = buildTerminalErrorEnvelope(error);
182
+ const envelope = withTips(buildTerminalErrorEnvelope(error), tips);
169
183
  console.error(
170
184
  format === "yaml" ? toYaml(envelope) : JSON.stringify(envelope),
171
185
  );
@@ -179,7 +193,7 @@ export async function runTerminalCli(args: readonly string[]): Promise<number> {
179
193
  "Use `centrs terminal --help` for the supported command shape and flags.",
180
194
  }),
181
195
  { verbose: args.includes("--verbose") },
182
- ),
196
+ ) + formatTipsText(tips),
183
197
  );
184
198
  }
185
199
  return 1;
@@ -272,8 +286,8 @@ function parseTerminalCliArgs(args: readonly string[]): ParsedTerminal {
272
286
  }
273
287
  const targetInput = positional[0];
274
288
  if (!targetInput) {
275
- throw asCentrsError(new Error("terminal requires a <router> target."), {
276
- code: "input/invalid-command",
289
+ throw missingTargetError({
290
+ command: "terminal",
277
291
  summary: "`centrs terminal` requires a <router> target.",
278
292
  remediation:
279
293
  "Pass the device MAC (or a CDB identity/ip) as the first argument; run `centrs terminal --help` for the shape.",
@@ -24,6 +24,14 @@ import {
24
24
  expectValue,
25
25
  renderCommandHelp,
26
26
  } from "./common.ts";
27
+ import {
28
+ buildTargetSelectionTips,
29
+ cdbFileFromArgs,
30
+ formatTipsText,
31
+ isMissingTargetError,
32
+ missingTargetError,
33
+ withTips,
34
+ } from "./missing-target.ts";
27
35
 
28
36
  /** Sub-verb aliases, resolved to canonical verbs silently (commands/AGENTS.md). */
29
37
  const VERB_ALIASES: Record<string, TransferVerb> = {
@@ -173,10 +181,19 @@ export async function runTransferCli(
173
181
  // (CodeQL js/clear-text-logging).
174
182
  const safeRequest = redactTransferRequest(request);
175
183
  const format = inferRequestedFormat(args, safeRequest);
184
+ const tips = isMissingTargetError(error)
185
+ ? await buildTargetSelectionTips({
186
+ cdbFile: safeRequest?.cdbFile ?? cdbFileFromArgs(args),
187
+ env: Bun.env,
188
+ })
189
+ : [];
176
190
  if (format === "json" || format === "yaml") {
177
- const envelope = buildTransferErrorEnvelope(
178
- safeRequest ?? { verb: options.fixedVerb ?? "list" },
179
- error,
191
+ const envelope = withTips(
192
+ buildTransferErrorEnvelope(
193
+ safeRequest ?? { verb: options.fixedVerb ?? "list" },
194
+ error,
195
+ ),
196
+ tips,
180
197
  );
181
198
  console.error(
182
199
  renderTransferEnvelope(envelope, format, {
@@ -193,7 +210,7 @@ export async function runTransferCli(
193
210
  "Use `centrs transfer --help` to inspect the supported command shape and flags.",
194
211
  }),
195
212
  { verbose: safeRequest?.verbose ?? args.includes("--verbose") },
196
- ),
213
+ ) + formatTipsText(tips),
197
214
  );
198
215
  }
199
216
  return 1;
@@ -367,13 +384,24 @@ function assemblePositionals(
367
384
  } else {
368
385
  targetInput = positional[0];
369
386
  const rawVerb = positional[1];
370
- if (!targetInput || !rawVerb) {
387
+ // A missing target gets the tagged missing-target error (CDB picker tip);
388
+ // a present target with no verb is a distinct "which verb?" usage error.
389
+ if (!targetInput) {
390
+ throw missingTargetError({
391
+ command: "transfer",
392
+ summary:
393
+ "`centrs transfer` requires a <router> target and a verb (upload|download|list|remove|mkdir|copy).",
394
+ remediation:
395
+ "Pass the target then a verb, e.g. `centrs transfer <router> list`; run `--help` for the full shape.",
396
+ });
397
+ }
398
+ if (!rawVerb) {
371
399
  throw new CentrsError({
372
400
  code: "input/invalid-command",
373
401
  summary:
374
- "`centrs transfer` requires <router> and a verb (upload|download|list|remove|mkdir|copy).",
402
+ "`centrs transfer` requires a verb after the <router> (upload|download|list|remove|mkdir|copy).",
375
403
  remediation:
376
- "Pass the target then a verb, e.g. `centrs transfer <router> list`; run `--help` for the full shape.",
404
+ "Pass a verb, e.g. `centrs transfer <router> list`; run `--help` for the full shape.",
377
405
  context: { targetInput, verb: rawVerb },
378
406
  });
379
407
  }
@@ -382,8 +410,8 @@ function assemblePositionals(
382
410
  }
383
411
 
384
412
  if (!targetInput) {
385
- throw new CentrsError({
386
- code: "input/invalid-command",
413
+ throw missingTargetError({
414
+ command: "transfer",
387
415
  summary: "`centrs transfer` requires a <router> target.",
388
416
  remediation:
389
417
  "Pass the router host/identity as the first argument; run `centrs transfer --help` for the command shape.",
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import { apiCommand, runApiCli } from "./cli/api.ts";
3
4
  import { btestCommand, runBtestCli } from "./cli/btest.ts";
4
5
  import { devicesCommand, runDevicesCli } from "./cli/devices.ts";
5
6
  import { discoverCommand, runDiscoverCli } from "./cli/discover.ts";
@@ -14,6 +15,7 @@ import { describeCentrs, plannedProtocols, plannedSurfaces } from "./index.ts";
14
15
  const commandSummaries: ReadonlyArray<{ name: string; summary: string }> = [
15
16
  retrieveCommand,
16
17
  executeCommand,
18
+ apiCommand,
17
19
  transferCommand,
18
20
  terminalCommand,
19
21
  devicesCommand,
@@ -54,6 +56,9 @@ export async function runCli(
54
56
  if (command === "execute") {
55
57
  return await runExecuteCli(rest);
56
58
  }
59
+ if (command === "api") {
60
+ return await runApiCli(rest);
61
+ }
57
62
  if (command === "transfer") {
58
63
  return await runTransferCli(rest);
59
64
  }
@@ -80,6 +85,23 @@ export async function runCli(
80
85
  if (command === "mcp") {
81
86
  return await runMcpCli(rest);
82
87
  }
88
+ // `stream` (and the `tail` shorthand) folded into `api --stream`.
89
+ if (command === "stream" || command === "tail") {
90
+ console.error(
91
+ formatCentrsErrorText(
92
+ asCentrsError(
93
+ new Error(`\`centrs ${command}\` has been folded into \`api\`.`),
94
+ {
95
+ code: "input/invalid-command",
96
+ summary: `\`centrs ${command}\` is no longer a command — open-ended follow is now \`api … --stream\`.`,
97
+ remediation:
98
+ "Use `centrs api <router> <endpoint> --stream` (native-api only), e.g. `centrs api edge1 ip/address --stream`.",
99
+ },
100
+ ),
101
+ ),
102
+ );
103
+ return 1;
104
+ }
83
105
 
84
106
  console.error(
85
107
  formatCentrsErrorText(
@@ -218,6 +218,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
218
218
  summary:
219
219
  "The RouterOS native API returned a !trap with no more specific mapping.",
220
220
  },
221
+ {
222
+ code: "routeros/btest-connection-count-single-stream",
223
+ summary:
224
+ "Fewer TCP data connections were opened than --connection-count requested (e.g. authenticated multi-connection is not yet supported), so throughput will not scale to the full count.",
225
+ },
221
226
  {
222
227
  code: "routeros/btest-protocol",
223
228
  summary: "A bandwidth-test (btest) packet could not be decoded.",
@@ -227,6 +232,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
227
232
  summary:
228
233
  "The btest server refused a client because max-sessions is reached.",
229
234
  },
235
+ {
236
+ code: "routeros/btest-udp-tx-size-ignored",
237
+ summary:
238
+ "For UDP --direction both, the btest wire protocol carries a single tx-size; --remote-udp-tx-size is ignored and --local-udp-tx-size is used for both directions.",
239
+ },
230
240
  {
231
241
  code: "routeros/command-failed",
232
242
  summary: "RouterOS reported a command failure.",
@@ -415,6 +425,10 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
415
425
  code: "usage/invalid-concurrency",
416
426
  summary: "The --concurrency value must be an integer >= 1.",
417
427
  },
428
+ {
429
+ code: "usage/invalid-method",
430
+ summary: "The -X/--method value is not a supported HTTP method.",
431
+ },
418
432
  {
419
433
  code: "usage/missing-group",
420
434
  summary: "The operation requires a non-empty --group value.",