@tikoci/centrs 0.1.1 → 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.
package/src/btest.ts CHANGED
@@ -138,6 +138,8 @@ export interface BtestClientData {
138
138
  stopReason: BtestStopReason;
139
139
  serverUdpPort?: number;
140
140
  clientUdpPort?: number;
141
+ /** TCP data connections actually driven (1 + joined secondaries). */
142
+ activeConnections: number;
141
143
  }
142
144
 
143
145
  export interface BtestClientOperationMeta {
@@ -215,9 +217,37 @@ export async function btestClient(
215
217
  controlPort,
216
218
  };
217
219
 
220
+ const warnings: {
221
+ code: string;
222
+ message: string;
223
+ context?: Record<string, unknown>;
224
+ }[] = [];
225
+
218
226
  try {
219
227
  validateClientOptions(request);
220
228
 
229
+ // The btest wire command carries a single tx-size field; --remote-udp-tx-size
230
+ // and --local-udp-tx-size map to it separately only for receive (remote wins)
231
+ // and transmit (local wins). For both, localUdpTxSize wins and
232
+ // remoteUdpTxSize is silently discarded — warn when they differ so the user
233
+ // knows which size the server will actually use.
234
+ if (
235
+ protocol === "udp" &&
236
+ direction === "both" &&
237
+ request.remoteUdpTxSize !== undefined &&
238
+ request.localUdpTxSize !== undefined &&
239
+ request.remoteUdpTxSize !== request.localUdpTxSize
240
+ ) {
241
+ warnings.push({
242
+ code: "routeros/btest-udp-tx-size-ignored",
243
+ message: `--remote-udp-tx-size (${request.remoteUdpTxSize}) is ignored for --direction both; the btest wire protocol carries a single tx-size and --local-udp-tx-size (${request.localUdpTxSize}) is used for both directions.`,
244
+ context: {
245
+ ignored: request.remoteUdpTxSize,
246
+ used: request.localUdpTxSize,
247
+ },
248
+ });
249
+ }
250
+
221
251
  const cdbResolution = await resolveCdb(
222
252
  {
223
253
  targetInput: request.targetInput,
@@ -273,6 +303,9 @@ export async function btestClient(
273
303
  ? { statusIntervalMs: request.intervalMs }
274
304
  : {}),
275
305
  ...(txSize !== undefined ? { txSize } : {}),
306
+ ...(request.connectionCount !== undefined
307
+ ? { tcpConnectionCount: request.connectionCount }
308
+ : {}),
276
309
  ...(request.localTxSpeed !== undefined
277
310
  ? { localTxSpeed: request.localTxSpeed }
278
311
  : {}),
@@ -301,6 +334,7 @@ export async function btestClient(
301
334
  txTotalAvgBps: secs > 0 ? (summary.totalTxBytes * 8) / secs : 0,
302
335
  rxTotalAvgBps: secs > 0 ? (summary.totalRxBytes * 8) / secs : 0,
303
336
  stopReason: summary.stopReason,
337
+ activeConnections: summary.activeConnections,
304
338
  };
305
339
  if (summary.username !== undefined) data.user = summary.username;
306
340
  if (summary.serverUdpPort !== undefined)
@@ -308,10 +342,36 @@ export async function btestClient(
308
342
  if (summary.clientUdpPort !== undefined)
309
343
  data.clientUdpPort = summary.clientUdpPort;
310
344
 
345
+ // TCP multi-connection: centrs now opens the negotiated extra data
346
+ // connections (fan-out). It still degrades to a single stream when the server
347
+ // negotiates no session token — notably authenticated (EC-SRP5) sessions,
348
+ // whose post-auth token centrs does not yet capture. Warn only when the
349
+ // realized fan-out fell short of the request, with the actual count.
350
+ if (
351
+ protocol === "tcp" &&
352
+ request.connectionCount !== undefined &&
353
+ request.connectionCount > 1 &&
354
+ summary.activeConnections < request.connectionCount
355
+ ) {
356
+ const single = summary.activeConnections === 1;
357
+ warnings.push({
358
+ code: "routeros/btest-connection-count-single-stream",
359
+ message: `--connection-count (${request.connectionCount}) requested, but ${summary.activeConnections} TCP data connection(s) are active${
360
+ single && summary.authKind !== "none"
361
+ ? " — authenticated multi-connection is not yet supported"
362
+ : ""
363
+ }; throughput will not scale to the full count.`,
364
+ context: {
365
+ requested: request.connectionCount,
366
+ active: summary.activeConnections,
367
+ },
368
+ });
369
+ }
370
+
311
371
  return {
312
372
  ok: true,
313
373
  data,
314
- warnings: [],
374
+ warnings: warnings.map((w) => ({ ...w })),
315
375
  tips: [],
316
376
  meta: clientMeta(target.host, controlPort, operation, auth.username),
317
377
  };
package/src/cli/api.ts ADDED
@@ -0,0 +1,536 @@
1
+ import {
2
+ type ApiOutputFormat,
3
+ type ApiRequest,
4
+ apiEnvelope,
5
+ apiListen,
6
+ apiOutputFormats,
7
+ buildApiErrorEnvelope,
8
+ normalizeApiEndpoint,
9
+ renderApiEnvelope,
10
+ renderApiStreamLine,
11
+ } from "../api.ts";
12
+ import {
13
+ asCentrsError,
14
+ CentrsError,
15
+ formatCentrsErrorText,
16
+ } from "../errors.ts";
17
+ import { describeCentrs } from "../index.ts";
18
+ import {
19
+ type CliCommandMetadata,
20
+ expectValue,
21
+ renderCommandHelp,
22
+ unknownFlagError,
23
+ } from "./common.ts";
24
+ import {
25
+ buildTargetSelectionTips,
26
+ cdbFileFromArgs,
27
+ formatTipsText,
28
+ isMissingTargetError,
29
+ missingTargetError,
30
+ withTips,
31
+ } from "./missing-target.ts";
32
+
33
+ export const apiCommand: CliCommandMetadata = {
34
+ name: "api",
35
+ usage: "centrs api <router> <endpoint> [flags]",
36
+ summary:
37
+ "Structured RouterOS API passthrough (gh-api style) over REST or native API.",
38
+ options: [
39
+ {
40
+ flag: "-X / --method",
41
+ valueName: "<verb>",
42
+ description:
43
+ "HTTP method, default GET (case-insensitive). GET→print, PUT→add, PATCH→set, DELETE→remove, POST→run.",
44
+ },
45
+ {
46
+ flag: "-f",
47
+ valueName: "<key=value>",
48
+ description:
49
+ "Body field, repeatable; assembled into the JSON body (verbatim string values).",
50
+ },
51
+ {
52
+ flag: "-d / --data",
53
+ valueName: "<json>",
54
+ description: "Raw JSON request body. Conflicts with `-f` / `--input`.",
55
+ },
56
+ {
57
+ flag: "--input",
58
+ valueName: "<file|->",
59
+ description: "Read the raw JSON body from a file or stdin (`-`).",
60
+ },
61
+ {
62
+ flag: "--query / --filter",
63
+ valueName: "<expr>",
64
+ description:
65
+ "RouterOS-side row filter, AND-combined, repeatable: name=value, name!=value, name>value, name<value, name.",
66
+ },
67
+ {
68
+ flag: "--raw-query",
69
+ valueName: "<word>",
70
+ description:
71
+ "Verbatim RouterOS query word (repeatable) for OR / absence / stack expressions.",
72
+ },
73
+ {
74
+ flag: "--attribute / --proplist",
75
+ valueName: "<a,b>",
76
+ description: "Property projection → `.proplist`.",
77
+ },
78
+ {
79
+ flag: "--raw",
80
+ description:
81
+ "Strip the envelope; emit bare RouterOS JSON. Implies `--validate=false`; does not imply `--yes`.",
82
+ },
83
+ {
84
+ flag: "--yes",
85
+ description:
86
+ "Confirm a mutating (non-read) request in non-interactive runs.",
87
+ },
88
+ {
89
+ flag: "--stream / --listen",
90
+ description:
91
+ "Follow changes as an NDJSON envelope stream (native-api only; the `/listen` endpoint infers it). Ends with a summary envelope.",
92
+ },
93
+ {
94
+ flag: "--count",
95
+ valueName: "<n>",
96
+ description: "Stop a `--stream` after N change frames.",
97
+ },
98
+ {
99
+ flag: "--duration",
100
+ valueName: "<dur>",
101
+ description:
102
+ "Stop a `--stream` after this wall-clock window (e.g. `5s`).",
103
+ },
104
+ {
105
+ flag: "--via",
106
+ valueName: "<rest-api|native-api>",
107
+ description: "Pin the transport; no silent downgrade. Default rest-api.",
108
+ },
109
+ {
110
+ flag: "--host",
111
+ valueName: "<host|url>",
112
+ description: "Override the resolved host or base URL for the target.",
113
+ },
114
+ {
115
+ flag: "--port",
116
+ valueName: "<port>",
117
+ description: "Override the resolved management port.",
118
+ },
119
+ {
120
+ flag: "--username / --user / -u",
121
+ valueName: "<name>",
122
+ description: "RouterOS username. Falls back to `CENTRS_USERNAME`.",
123
+ },
124
+ {
125
+ flag: "--password",
126
+ valueName: "<secret>",
127
+ description: "RouterOS password. Falls back to `CENTRS_PASSWORD`.",
128
+ },
129
+ {
130
+ flag: "--insecure",
131
+ description:
132
+ "Accept a self-signed `api-ssl`/REST TLS cert. Default verifies.",
133
+ },
134
+ {
135
+ flag: "--cdb-file",
136
+ valueName: "<path>",
137
+ description: "Read target credentials from a WinBox CDB file.",
138
+ },
139
+ {
140
+ flag: "--cdb-password",
141
+ valueName: "<password>",
142
+ description: "Decrypt an encrypted WinBox CDB file.",
143
+ },
144
+ {
145
+ flag: "--resolve",
146
+ valueName: "<none|arp>",
147
+ description:
148
+ "Resolve a MAC-address target to an IP via the host ARP cache (default none).",
149
+ },
150
+ {
151
+ flag: "--timeout",
152
+ valueName: "<duration>",
153
+ description: "Per-request timeout (for REST, max 60s).",
154
+ },
155
+ {
156
+ flag: "--validate[=false]",
157
+ description:
158
+ "Run `/console/inspect` validation before the request (default true).",
159
+ },
160
+ {
161
+ flag: "--format",
162
+ valueName: "<json|yaml|text>",
163
+ description:
164
+ "Output format. Defaults to json for api; `CENTRS_FORMAT` overrides.",
165
+ },
166
+ {
167
+ flag: "--json",
168
+ description: "Shortcut for `--format json`.",
169
+ },
170
+ {
171
+ flag: "--verbose",
172
+ description: "Include additional context in text output.",
173
+ },
174
+ ],
175
+ };
176
+
177
+ interface ApiCliArgs extends ApiRequest {
178
+ help?: boolean;
179
+ format?: ApiOutputFormat;
180
+ /** Unresolved `--input` value (`-` or a path); read into `inputBody` in the runner. */
181
+ inputPath?: string;
182
+ }
183
+
184
+ export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
185
+ const parsed: ApiCliArgs = { endpoint: "" };
186
+ const positional: string[] = [];
187
+
188
+ for (let index = 0; index < args.length; index += 1) {
189
+ const arg = args[index];
190
+ if (arg === undefined) {
191
+ continue;
192
+ }
193
+ if (arg.startsWith("--validate=")) {
194
+ parsed.validate = parseBooleanFlag(arg.slice("--validate=".length), arg);
195
+ continue;
196
+ }
197
+ switch (arg) {
198
+ case "--help":
199
+ case "-h":
200
+ parsed.help = true;
201
+ break;
202
+ case "--json":
203
+ parsed.format = "json";
204
+ break;
205
+ case "--verbose":
206
+ parsed.verbose = true;
207
+ break;
208
+ case "--yes":
209
+ parsed.yes = true;
210
+ break;
211
+ case "--raw":
212
+ parsed.raw = true;
213
+ break;
214
+ case "--stream":
215
+ case "--listen":
216
+ parsed.listen = true;
217
+ break;
218
+ case "--validate":
219
+ parsed.validate = true;
220
+ break;
221
+ case "--no-validate":
222
+ parsed.validate = false;
223
+ break;
224
+ case "-X":
225
+ case "--method":
226
+ parsed.method = expectValue(args, ++index, arg);
227
+ break;
228
+ case "-f":
229
+ case "--field": {
230
+ const kv = expectValue(args, ++index, arg);
231
+ const eq = kv.indexOf("=");
232
+ if (eq <= 0) {
233
+ throw new Error(`${arg} expects key=value; got ${kv}`);
234
+ }
235
+ parsed.fields = {
236
+ ...(parsed.fields ?? {}),
237
+ [kv.slice(0, eq)]: kv.slice(eq + 1),
238
+ };
239
+ break;
240
+ }
241
+ case "-d":
242
+ case "--data":
243
+ parsed.data = expectValue(args, ++index, arg);
244
+ break;
245
+ case "--input":
246
+ parsed.inputPath = expectValue(args, ++index, arg);
247
+ break;
248
+ case "--query":
249
+ case "--filter":
250
+ parsed.query = [
251
+ ...(parsed.query ?? []),
252
+ expectValue(args, ++index, arg),
253
+ ];
254
+ break;
255
+ case "--raw-query":
256
+ parsed.rawQuery = [
257
+ ...(parsed.rawQuery ?? []),
258
+ expectValue(args, ++index, arg),
259
+ ];
260
+ break;
261
+ case "--attribute":
262
+ case "--proplist":
263
+ parsed.proplist = [
264
+ ...(parsed.proplist ?? []),
265
+ expectValue(args, ++index, arg),
266
+ ];
267
+ break;
268
+ case "--duration":
269
+ parsed.duration = expectValue(args, ++index, arg);
270
+ break;
271
+ case "--count":
272
+ parsed.count = parseIntegerFlag(expectValue(args, ++index, arg), arg);
273
+ break;
274
+ case "--via":
275
+ parsed.via = expectValue(args, ++index, arg);
276
+ break;
277
+ case "--host":
278
+ parsed.host = expectValue(args, ++index, arg);
279
+ break;
280
+ case "--port":
281
+ parsed.port = parseIntegerFlag(expectValue(args, ++index, arg), arg);
282
+ break;
283
+ case "--user":
284
+ case "-u":
285
+ case "--username":
286
+ parsed.username = expectValue(args, ++index, arg);
287
+ break;
288
+ case "--password":
289
+ parsed.password = expectValue(args, ++index, arg);
290
+ break;
291
+ case "--insecure":
292
+ parsed.insecure = true;
293
+ break;
294
+ case "--cdb-file":
295
+ parsed.cdbFile = expectValue(args, ++index, arg);
296
+ break;
297
+ case "--cdb-password":
298
+ parsed.cdbPassword = expectValue(args, ++index, arg);
299
+ break;
300
+ case "--resolve":
301
+ parsed.resolve = expectValue(args, ++index, arg);
302
+ break;
303
+ case "--timeout":
304
+ parsed.timeout = expectValue(args, ++index, arg);
305
+ break;
306
+ case "--format": {
307
+ const value = expectValue(args, ++index, arg);
308
+ if (!apiOutputFormats.includes(value as ApiOutputFormat)) {
309
+ throw new Error(
310
+ `--format must be one of ${apiOutputFormats.join(", ")}; got ${value}.`,
311
+ );
312
+ }
313
+ parsed.format = value as ApiOutputFormat;
314
+ break;
315
+ }
316
+ default:
317
+ if (arg.startsWith("-")) {
318
+ throw unknownFlagError("api", arg, apiCommand.options);
319
+ }
320
+ positional.push(arg);
321
+ break;
322
+ }
323
+ }
324
+
325
+ parsed.targetInput = positional[0];
326
+ parsed.endpoint = positional[1] ?? "";
327
+ parsed.stdinIsTty = process.stdin.isTTY;
328
+ return parsed;
329
+ }
330
+
331
+ export async function runApiCli(args: readonly string[]): Promise<number> {
332
+ let parsed: ApiCliArgs | undefined;
333
+ try {
334
+ parsed = parseApiCliArgs(args);
335
+ if (parsed.help) {
336
+ console.log(renderCommandHelp(describeCentrs(), apiCommand));
337
+ return 0;
338
+ }
339
+ if (!parsed.targetInput) {
340
+ throw missingTargetError({
341
+ command: "api",
342
+ summary: "`centrs api` requires a <router> and an <endpoint>.",
343
+ remediation:
344
+ "Pass the router host/identity then the endpoint, e.g. `centrs api 192.0.2.10 ip/address`.",
345
+ });
346
+ }
347
+ // Resolve `--input` (file / stdin) before handing the body to the orchestrator.
348
+ if (parsed.inputPath !== undefined) {
349
+ const inputPath = parsed.inputPath;
350
+ try {
351
+ parsed.inputBody =
352
+ inputPath === "-"
353
+ ? await Bun.stdin.text()
354
+ : await Bun.file(inputPath).text();
355
+ } catch (error) {
356
+ throw new CentrsError({
357
+ code: "input/local-file-not-found",
358
+ summary: `Cannot read the --input body from ${inputPath === "-" ? "stdin" : inputPath}.`,
359
+ remediation:
360
+ "Check the path and read permissions, or pipe the JSON body into `--input -`.",
361
+ context: { input: inputPath },
362
+ cause: error,
363
+ });
364
+ }
365
+ }
366
+
367
+ // Open-ended follow (`--stream`/`--listen`, or a `/listen` endpoint) consumes
368
+ // the NDJSON envelope stream instead of a single one-shot envelope.
369
+ const streaming =
370
+ parsed.listen === true ||
371
+ (parsed.endpoint.length > 0 &&
372
+ normalizeApiEndpoint(parsed.endpoint).listen);
373
+ if (streaming) {
374
+ return await runApiListenCli(parsed, args);
375
+ }
376
+
377
+ const envelope = await apiEnvelope(parsed);
378
+ const resolvedFormat =
379
+ (
380
+ envelope.meta.operation as {
381
+ request?: { format?: ApiOutputFormat };
382
+ }
383
+ )?.request?.format ??
384
+ parsed.format ??
385
+ "json";
386
+ const rendered = renderApiEnvelope(envelope, resolvedFormat, {
387
+ raw: parsed.raw,
388
+ verbose: parsed.verbose,
389
+ });
390
+ if (envelope.ok) {
391
+ console.log(rendered);
392
+ return 0;
393
+ }
394
+ console.error(rendered);
395
+ return 1;
396
+ } catch (error) {
397
+ const format = inferApiFormat(args, parsed);
398
+ const tips = isMissingTargetError(error)
399
+ ? await buildTargetSelectionTips({
400
+ cdbFile: parsed?.cdbFile ?? cdbFileFromArgs(args),
401
+ env: Bun.env,
402
+ })
403
+ : [];
404
+ if (parsed?.raw) {
405
+ // `--raw` defines a compact JSON error contract (code/message → stderr);
406
+ // keep that machine shape on the pre-envelope failure path too (e.g. a
407
+ // missing `--input` file) instead of falling back to plain text.
408
+ const envelope = withTips(buildApiErrorEnvelope(parsed, error), tips);
409
+ console.error(
410
+ renderApiEnvelope(envelope, format, {
411
+ raw: true,
412
+ verbose: parsed.verbose ?? false,
413
+ }),
414
+ );
415
+ } else if (format === "json" || format === "yaml") {
416
+ const envelope = withTips(
417
+ buildApiErrorEnvelope(parsed ?? { endpoint: "" }, error),
418
+ tips,
419
+ );
420
+ console.error(
421
+ renderApiEnvelope(envelope, format, {
422
+ verbose: parsed?.verbose ?? false,
423
+ }),
424
+ );
425
+ } else {
426
+ console.error(
427
+ formatCentrsErrorText(
428
+ asCentrsError(error, {
429
+ code: "input/invalid-command",
430
+ summary: error instanceof Error ? error.message : String(error),
431
+ remediation:
432
+ "Use `centrs api --help` to inspect the supported endpoint shape and flags.",
433
+ }),
434
+ { verbose: parsed?.verbose ?? args.includes("--verbose") },
435
+ ) + formatTipsText(tips),
436
+ );
437
+ }
438
+ return 1;
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Consume the open-ended `--stream` follow: print one line per change frame
444
+ * (NDJSON under `json`, human rows under `text`), then the terminating summary.
445
+ * Ctrl-C (SIGINT) cancels the listen and still emits the summary. The exit code
446
+ * reflects whether the stream *started* cleanly — a failure before the first
447
+ * frame is `1`; a mid-stream error frame leaves a started stream at `0`.
448
+ */
449
+ async function runApiListenCli(
450
+ parsed: ApiCliArgs,
451
+ args: readonly string[],
452
+ ): Promise<number> {
453
+ const format = inferApiFormat(args, parsed);
454
+ const controller = new AbortController();
455
+ const onSigint = (): void => controller.abort();
456
+ process.on("SIGINT", onSigint);
457
+ let exitCode = 0;
458
+ let first = true;
459
+ try {
460
+ for await (const envelope of apiListen(
461
+ parsed,
462
+ Bun.env,
463
+ controller.signal,
464
+ )) {
465
+ const line = renderApiStreamLine(envelope, format, {
466
+ raw: parsed.raw,
467
+ verbose: parsed.verbose,
468
+ });
469
+ if (envelope.ok) {
470
+ console.log(line);
471
+ } else {
472
+ console.error(line);
473
+ if (first) {
474
+ exitCode = 1;
475
+ }
476
+ }
477
+ first = false;
478
+ }
479
+ } finally {
480
+ process.off("SIGINT", onSigint);
481
+ }
482
+ return exitCode;
483
+ }
484
+
485
+ function inferApiFormat(
486
+ args: readonly string[],
487
+ parsed?: ApiCliArgs,
488
+ ): ApiOutputFormat {
489
+ if (parsed?.format) {
490
+ return parsed.format;
491
+ }
492
+ if (args.includes("--json")) {
493
+ return "json";
494
+ }
495
+ const index = args.indexOf("--format");
496
+ if (index >= 0) {
497
+ const value = args[index + 1];
498
+ if (
499
+ value !== undefined &&
500
+ apiOutputFormats.includes(value as ApiOutputFormat)
501
+ ) {
502
+ return value as ApiOutputFormat;
503
+ }
504
+ }
505
+ // Honor CENTRS_FORMAT on the parse-time error path so the catch render matches
506
+ // buildApiErrorEnvelope's env-aware `meta.operation.request.format`. CLI flags
507
+ // above still win.
508
+ const envFormat = Bun.env["CENTRS_FORMAT"];
509
+ if (
510
+ envFormat !== undefined &&
511
+ apiOutputFormats.includes(envFormat as ApiOutputFormat)
512
+ ) {
513
+ return envFormat as ApiOutputFormat;
514
+ }
515
+ // api is machine-first: default to the structured json envelope, not text.
516
+ return "json";
517
+ }
518
+
519
+ function parseIntegerFlag(value: string, flag: string): number {
520
+ // Validate the whole token: `Number.parseInt` would silently accept prefixes
521
+ // like `8728ms` / `10abc` and send a request to the wrong port/count.
522
+ if (!/^-?\d+$/.test(value.trim())) {
523
+ throw new Error(`${flag} must be an integer; got ${value}.`);
524
+ }
525
+ return Number.parseInt(value, 10);
526
+ }
527
+
528
+ function parseBooleanFlag(value: string, flag: string): boolean {
529
+ if (["true", "1", "yes", "on"].includes(value.toLowerCase())) {
530
+ return true;
531
+ }
532
+ if (["false", "0", "no", "off"].includes(value.toLowerCase())) {
533
+ return false;
534
+ }
535
+ throw new Error(`${flag} must be true or false; got ${value}.`);
536
+ }
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,