@ontrails/commander 1.0.0-beta.23 → 1.0.0-beta.29

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/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # @ontrails/commander
2
2
 
3
+ ## 1.0.0-beta.29
4
+
5
+ ### Patch Changes
6
+
7
+ - @ontrails/cli@1.0.0-beta.29
8
+ - @ontrails/core@1.0.0-beta.29
9
+
10
+ ## 1.0.0-beta.28
11
+
12
+ ### Patch Changes
13
+
14
+ - @ontrails/cli@1.0.0-beta.28
15
+ - @ontrails/core@1.0.0-beta.28
16
+
17
+ ## 1.0.0-beta.27
18
+
19
+ ### Patch Changes
20
+
21
+ - @ontrails/cli@1.0.0-beta.27
22
+ - @ontrails/core@1.0.0-beta.27
23
+
24
+ ## 1.0.0-beta.26
25
+
26
+ ### Patch Changes
27
+
28
+ - Updated dependencies [1307568]
29
+ - Updated dependencies [371d19e]
30
+ - @ontrails/core@1.0.0-beta.26
31
+ - @ontrails/cli@1.0.0-beta.26
32
+
33
+ ## 1.0.0-beta.25
34
+
35
+ ### Patch Changes
36
+
37
+ - 60caabf: Render operator-actionable detail lines after CLI execution errors: validation failures list their topo issues (message plus trail id) and permission failures name the required permit scopes with a copyable `--permit` form. Non-internal Trails error context only, passed through the shared redactor; internal errors keep the redacted generic message.
38
+ - dbf4ff4: Emit structured CLI error envelopes for JSON/JSONL command failures and map compile-time Trails DB lock contention to a retryable timeout instead of a generic internal error.
39
+ - f1e6efa: Prevent executable parent command defaults from leaking into nested child commands.
40
+ - a8e4dc3: Clean up the Wayfinder navigation grammar before RC, including explicit pattern/query/file selectors, target-bound dependency and impact flags, drift-first provenance fields, stricter fires declaration diagnostics, and updated operator dogfood coverage.
41
+ - 1d3ae74: Materialize resolved CLI command aliases through the Commander surface while
42
+ preserving the same trail contract and execution path.
43
+ - Updated dependencies [c36aca9]
44
+ - Updated dependencies [3befcf1]
45
+ - Updated dependencies [f1e6efa]
46
+ - Updated dependencies [a4f9cf6]
47
+ - Updated dependencies [9bcf34e]
48
+ - Updated dependencies [f7d97fc]
49
+ - @ontrails/core@1.0.0-beta.25
50
+ - @ontrails/cli@1.0.0-beta.25
51
+
52
+ ## 1.0.0-beta.24
53
+
54
+ ### Patch Changes
55
+
56
+ - @ontrails/cli@1.0.0-beta.24
57
+ - @ontrails/core@1.0.0-beta.24
58
+
3
59
  ## 1.0.0-beta.23
4
60
 
5
61
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/commander",
3
- "version": "1.0.0-beta.23",
3
+ "version": "1.0.0-beta.29",
4
4
  "files": [
5
5
  "src/**/*.ts",
6
6
  "!src/**/__tests__/**",
@@ -22,8 +22,8 @@
22
22
  "clean": "rm -rf dist *.tsbuildinfo"
23
23
  },
24
24
  "dependencies": {
25
- "@ontrails/cli": "^1.0.0-beta.23",
26
- "@ontrails/core": "^1.0.0-beta.23",
25
+ "@ontrails/cli": "^1.0.0-beta.29",
26
+ "@ontrails/core": "^1.0.0-beta.29",
27
27
  "commander": "^14.0.3"
28
28
  },
29
29
  "peerDependencies": {
package/src/surface.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type {
6
6
  BaseSurfaceOptions,
7
+ CliCommandAliasInput,
7
8
  Layer,
8
9
  ResourceOverrideMap,
9
10
  Topo,
@@ -27,6 +28,9 @@ import { toCommander } from './to-commander.js';
27
28
  * Options for creating Commander CLI surfaces from a Trails topo.
28
29
  */
29
30
  export interface CreateProgramOptions extends BaseSurfaceOptions {
31
+ readonly aliases?:
32
+ | Readonly<Record<string, readonly CliCommandAliasInput[]>>
33
+ | undefined;
30
34
  readonly createContext?:
31
35
  | (() => TrailContextInit | Promise<TrailContextInit>)
32
36
  | undefined;
@@ -58,6 +62,7 @@ const deriveCommanderOptions = (
58
62
  ): ToCommanderOptions => {
59
63
  const commanderOpts: ToCommanderOptions = {
60
64
  name: options.name ?? graph.name,
65
+ topoName: graph.name,
61
66
  };
62
67
  if (options.version !== undefined || graph.version !== undefined) {
63
68
  commanderOpts.version = options.version ?? graph.version;
@@ -88,6 +93,7 @@ export const createProgram = (
88
93
  options: CreateProgramOptions = {}
89
94
  ) => {
90
95
  const commandsResult = deriveCliCommands(graph, {
96
+ aliases: options.aliases,
91
97
  configValues: options.configValues,
92
98
  createContext: options.createContext,
93
99
  exclude: options.exclude,
@@ -2,9 +2,20 @@
2
2
  * Adapt framework-agnostic CliCommand[] to a Commander program.
3
3
  */
4
4
 
5
- import { projectPublicSurfaceError } from '@ontrails/core';
5
+ import {
6
+ isTrailsError,
7
+ projectPublicSurfaceError,
8
+ redactErrorContext,
9
+ redactErrorString,
10
+ ValidationError,
11
+ } from '@ontrails/core';
12
+ import type { SurfaceErrorProjection } from '@ontrails/core';
6
13
  import type { CliCommand, CliFlag } from '@ontrails/cli';
7
- import { applyCliFlagValueAliases, validateCliCommands } from '@ontrails/cli';
14
+ import {
15
+ applyCliFlagValueAliases,
16
+ deriveOutputMode,
17
+ validateCliCommands,
18
+ } from '@ontrails/cli';
8
19
  import { Command, InvalidArgumentError, Option } from 'commander';
9
20
 
10
21
  // ---------------------------------------------------------------------------
@@ -17,6 +28,7 @@ import { Command, InvalidArgumentError, Option } from 'commander';
17
28
  export interface ToCommanderOptions {
18
29
  description?: string | undefined;
19
30
  name?: string | undefined;
31
+ topoName?: string | undefined;
20
32
  version?: string | undefined;
21
33
  }
22
34
 
@@ -53,8 +65,34 @@ const strictParseNumber = (value: string): number => {
53
65
  return n;
54
66
  };
55
67
 
68
+ const validateChoiceValue = (flag: CliFlag, value: string): void => {
69
+ if (flag.choices && !flag.choices.includes(value)) {
70
+ throw new InvalidArgumentError(
71
+ `Allowed choices are ${flag.choices.join(', ')}.`
72
+ );
73
+ }
74
+ };
75
+
76
+ const isRepeatableArrayFlag = (flag: CliFlag): boolean =>
77
+ !flag.variadic && (flag.type === 'number[]' || flag.type === 'string[]');
78
+
79
+ const buildRepeatableArrayParser =
80
+ (flag: CliFlag) =>
81
+ (value: string, previous: unknown): readonly (number | string)[] => {
82
+ const parsed = flag.type === 'number[]' ? strictParseNumber(value) : value;
83
+ validateChoiceValue(flag, String(parsed));
84
+ return [...(Array.isArray(previous) ? previous : []), parsed];
85
+ };
86
+
56
87
  /** Apply common modifiers (choices, default, arg parser) to a Commander Option. */
57
88
  const applyOptionModifiers = (opt: Option, flag: CliFlag): void => {
89
+ if (isRepeatableArrayFlag(flag)) {
90
+ opt.argParser(buildRepeatableArrayParser(flag));
91
+ if (flag.default !== undefined) {
92
+ opt.default(flag.default);
93
+ }
94
+ return;
95
+ }
58
96
  if (flag.choices) {
59
97
  opt.choices(flag.choices);
60
98
  }
@@ -135,6 +173,32 @@ const isUserSuppliedOption = (command: Command, name: string): boolean => {
135
173
  const getCommandOptionNames = (command: Command): Set<string> =>
136
174
  new Set(command.options.map((option) => option.attributeName()));
137
175
 
176
+ const renderCommandPath = (command: Command): string => {
177
+ const segments: string[] = [];
178
+ let current: Command | null = command;
179
+ while (current !== null && current.parent !== null) {
180
+ segments.push(current.name());
181
+ current = current.parent;
182
+ }
183
+ return segments.toReversed().join(' ');
184
+ };
185
+
186
+ const renderOptionName = (option: Option): string =>
187
+ option.long ?? `--${option.attributeName()}`;
188
+
189
+ const INHERITED_SURFACE_OPTION_KEYS = new Set([
190
+ 'cwd',
191
+ 'devPermit',
192
+ 'json',
193
+ 'jsonl',
194
+ 'output',
195
+ 'permit',
196
+ 'quiet',
197
+ 'token',
198
+ 'trace',
199
+ 'watch',
200
+ ]);
201
+
138
202
  const hasUserSuppliedOptionOutside = (
139
203
  sourceCommand: Command,
140
204
  allowedCommand: Command
@@ -158,23 +222,64 @@ const getActionTarget = (fallbackTarget: Command, actionArgs: unknown[]) => {
158
222
  return candidate instanceof Command ? candidate : fallbackTarget;
159
223
  };
160
224
 
161
- const getParsedFlags = (command: Command): Record<string, unknown> =>
162
- command.optsWithGlobals() as Record<string, unknown>;
225
+ const getParsedFlags = (command: Command): Record<string, unknown> => {
226
+ const flags = command.optsWithGlobals() as Record<string, unknown>;
227
+ const commandOptionNames = getCommandOptionNames(command);
228
+ let { parent } = command;
229
+ while (parent !== null) {
230
+ for (const option of parent.options) {
231
+ const name = option.attributeName();
232
+ if (commandOptionNames.has(name)) {
233
+ continue;
234
+ }
235
+ if (
236
+ INHERITED_SURFACE_OPTION_KEYS.has(name) &&
237
+ isUserSuppliedOption(parent, name)
238
+ ) {
239
+ flags[name] = parent.getOptionValue(name);
240
+ } else {
241
+ Reflect.deleteProperty(flags, name);
242
+ }
243
+ }
244
+ ({ parent } = parent);
245
+ }
246
+ for (const option of command.options) {
247
+ const name = option.attributeName();
248
+ if (command.getOptionValueSource(name) !== undefined) {
249
+ flags[name] = command.getOptionValue(name);
250
+ }
251
+ }
252
+ return flags;
253
+ };
254
+
255
+ const toOptionKey = (name: string): string =>
256
+ name.replaceAll(/-([a-zA-Z0-9])/g, (_, ch: string) => ch.toUpperCase());
163
257
 
164
258
  const getFlagOptionKeys = (flags: readonly CliCommand['flags'][number][]) =>
165
259
  new Set(
166
260
  flags.flatMap((flag) => [
167
- flag.name.replaceAll(/-([a-zA-Z0-9])/g, (_, ch: string) =>
168
- ch.toUpperCase()
169
- ),
170
- ...(flag.valueAliases ?? []).map((alias) =>
171
- alias.name.replaceAll(/-([a-zA-Z0-9])/g, (_, ch: string) =>
172
- ch.toUpperCase()
173
- )
174
- ),
261
+ toOptionKey(flag.name),
262
+ ...(flag.valueAliases ?? []).map((alias) => toOptionKey(alias.name)),
175
263
  ])
176
264
  );
177
265
 
266
+ const getCanonicalUserSuppliedFlagKeys = (
267
+ flags: readonly CliCommand['flags'][number][],
268
+ userSuppliedFlagKeys: ReadonlySet<string>
269
+ ): ReadonlySet<string> => {
270
+ const canonicalKeys = new Set(userSuppliedFlagKeys);
271
+ for (const flag of flags) {
272
+ const flagKey = toOptionKey(flag.name);
273
+ const aliasSelected = (flag.valueAliases ?? []).some((alias) =>
274
+ userSuppliedFlagKeys.has(toOptionKey(alias.name))
275
+ );
276
+ if (aliasSelected) {
277
+ canonicalKeys.add(flagKey);
278
+ }
279
+ }
280
+ return canonicalKeys;
281
+ };
282
+
178
283
  const getUserSuppliedFlagKeys = (
179
284
  command: Command,
180
285
  flags: readonly CliCommand['flags'][number][]
@@ -220,19 +325,178 @@ const getFallbackUserSuppliedFlagKeys = (
220
325
  return userSupplied;
221
326
  };
222
327
 
328
+ const collectValidationIssueLines = (
329
+ context: Readonly<Record<string, unknown>>
330
+ ): readonly string[] => {
331
+ const { issues } = context;
332
+ if (!Array.isArray(issues)) {
333
+ return [];
334
+ }
335
+ return issues.flatMap((issue) => {
336
+ if (typeof issue !== 'object' || issue === null) {
337
+ return [];
338
+ }
339
+ const { message, trailId } = issue as {
340
+ message?: unknown;
341
+ trailId?: unknown;
342
+ };
343
+ if (typeof message !== 'string') {
344
+ return [];
345
+ }
346
+ const suffix = typeof trailId === 'string' ? ` (${trailId})` : '';
347
+ return [`- ${redactErrorString(message)}${suffix}`];
348
+ });
349
+ };
350
+
351
+ const collectPermitScopeLines = (
352
+ context: Readonly<Record<string, unknown>>
353
+ ): readonly string[] => {
354
+ const candidate = [context['required'], context['missing']].find((value) =>
355
+ Array.isArray(value)
356
+ );
357
+ const scopes = (Array.isArray(candidate) ? candidate : [])
358
+ .filter((scope): scope is string => typeof scope === 'string')
359
+ .map((scope) => redactErrorString(scope));
360
+ if (scopes.length === 0) {
361
+ return [];
362
+ }
363
+ const scopeJson = scopes.map((scope) => JSON.stringify(scope)).join(',');
364
+ return [
365
+ `Required scopes: ${scopes.join(', ')}`,
366
+ `Grant with: --permit '{"id":"<caller-id>","scopes":[${scopeJson}]}'`,
367
+ ];
368
+ };
369
+
370
+ /**
371
+ * Collect operator-facing detail lines for an execution error.
372
+ *
373
+ * The public surface projection intentionally drops structured context, but
374
+ * the CLI is an operator surface: validation issues and permit scope
375
+ * requirements are what the operator needs to act, so they are re-rendered
376
+ * here (through the shared redactor) for non-internal Trails errors.
377
+ */
378
+ const collectErrorDetailLines = (error: Error): readonly string[] => {
379
+ if (!isTrailsError(error) || error.category === 'internal') {
380
+ return [];
381
+ }
382
+ const context = error.context ?? {};
383
+ if (error.category === 'validation') {
384
+ return collectValidationIssueLines(context);
385
+ }
386
+ if (error.category === 'permission') {
387
+ return collectPermitScopeLines(context);
388
+ }
389
+ return [];
390
+ };
391
+
392
+ const collectErrorContext = (
393
+ error: Error
394
+ ): Record<string, unknown> | undefined => {
395
+ if (!isTrailsError(error) || error.category === 'internal') {
396
+ return undefined;
397
+ }
398
+ return redactErrorContext(error.context);
399
+ };
400
+
401
+ interface CliErrorEnvelope {
402
+ readonly ok: false;
403
+ readonly context?: Record<string, unknown> | undefined;
404
+ readonly error: SurfaceErrorProjection;
405
+ readonly details?: readonly string[] | undefined;
406
+ }
407
+
408
+ type StructuredErrorMode = 'json' | 'jsonl';
409
+
410
+ const structuredErrorMode = (
411
+ flags: Readonly<Record<string, unknown>>,
412
+ topoName: string,
413
+ userSuppliedFlagKeys: ReadonlySet<string>
414
+ ): StructuredErrorMode | undefined => {
415
+ const modeFlags = { ...flags };
416
+ if (!userSuppliedFlagKeys.has('output')) {
417
+ delete modeFlags['output'];
418
+ }
419
+ const { mode } = deriveOutputMode(modeFlags, topoName);
420
+ return mode === 'text' ? undefined : mode;
421
+ };
422
+
423
+ const writeStructuredError = (
424
+ envelope: CliErrorEnvelope,
425
+ mode: StructuredErrorMode
426
+ ): void => {
427
+ process.stderr.write(
428
+ mode === 'json'
429
+ ? `${JSON.stringify(envelope, null, 2)}\n`
430
+ : `${JSON.stringify(envelope)}\n`
431
+ );
432
+ };
433
+
223
434
  /** Handle execution errors with appropriate exit codes. */
224
- const handleError = (error: unknown): void => {
435
+ const handleError = (
436
+ error: unknown,
437
+ flags: Readonly<Record<string, unknown>>,
438
+ topoName: string,
439
+ userSuppliedFlagKeys: ReadonlySet<string>
440
+ ): void => {
225
441
  const err = error instanceof Error ? error : new Error(String(error));
226
442
  const projection = projectPublicSurfaceError('cli', err);
227
- process.stderr.write(`Error: ${projection.message}\n`);
443
+ const context = collectErrorContext(err);
444
+ const details = collectErrorDetailLines(err);
445
+ const mode = structuredErrorMode(flags, topoName, userSuppliedFlagKeys);
446
+ if (mode === undefined) {
447
+ process.stderr.write(`Error: ${projection.message}\n`);
448
+ for (const line of details) {
449
+ process.stderr.write(` ${line}\n`);
450
+ }
451
+ } else {
452
+ writeStructuredError(
453
+ {
454
+ ...(context === undefined ? {} : { context }),
455
+ error: projection,
456
+ ...(details.length === 0 ? {} : { details }),
457
+ ok: false,
458
+ },
459
+ mode
460
+ );
461
+ }
228
462
  process.exit(projection.code);
229
463
  };
230
464
 
465
+ const collectDisallowedAncestorOptions = (
466
+ target: Command,
467
+ allowedFlags: readonly CliCommand['flags'][number][]
468
+ ): readonly string[] => {
469
+ const allowedKeys = getFlagOptionKeys(allowedFlags);
470
+ const childPath = renderCommandPath(target);
471
+ const disallowed: string[] = [];
472
+ let { parent } = target;
473
+
474
+ while (parent !== null) {
475
+ const parentPath = renderCommandPath(parent);
476
+ for (const option of parent.options) {
477
+ const name = option.attributeName();
478
+ if (
479
+ isUserSuppliedOption(parent, name) &&
480
+ !allowedKeys.has(name) &&
481
+ !INHERITED_SURFACE_OPTION_KEYS.has(name)
482
+ ) {
483
+ disallowed.push(
484
+ `${renderOptionName(option)} belongs to "${parentPath}" and is not supported by "${childPath}".`
485
+ );
486
+ }
487
+ }
488
+ ({ parent } = parent);
489
+ }
490
+
491
+ return disallowed;
492
+ };
493
+
231
494
  interface BareChildFallback {
232
495
  readonly argName: string;
233
496
  readonly argValue: string;
234
497
  readonly parentCommand: CliCommand;
235
498
  readonly parentTarget: Command;
499
+ readonly requiresParentSignal: boolean;
236
500
  }
237
501
 
238
502
  const maybeUseBareChildFallback = (
@@ -246,10 +510,14 @@ const maybeUseBareChildFallback = (
246
510
  readonly parsedFlags: Record<string, unknown>;
247
511
  readonly userSuppliedFlagKeys: ReadonlySet<string>;
248
512
  } => {
513
+ const hasParentOnlySignal = fallback
514
+ ? hasUserSuppliedOptionOutside(fallback.parentTarget, target)
515
+ : false;
249
516
  if (
250
517
  !fallback ||
251
518
  hasAnyPositionalValue(cmd, parsedArgs) ||
252
- hasUserSuppliedOptionOutside(target, fallback.parentTarget)
519
+ hasUserSuppliedOptionOutside(target, fallback.parentTarget) ||
520
+ (fallback.requiresParentSignal && !hasParentOnlySignal)
253
521
  ) {
254
522
  return {
255
523
  command: cmd,
@@ -275,6 +543,7 @@ const maybeUseBareChildFallback = (
275
543
  const wireAction = (
276
544
  target: Command,
277
545
  cmd: CliCommand,
546
+ topoName: string,
278
547
  fallback?: BareChildFallback | undefined
279
548
  ): void => {
280
549
  target.action(async (...actionArgs: unknown[]) => {
@@ -291,17 +560,38 @@ const wireAction = (
291
560
  parentTarget: actionTarget.parent ?? fallback.parentTarget,
292
561
  }
293
562
  );
563
+ let { parsedFlags } = action;
294
564
  try {
295
- await action.command.execute(
296
- action.parsedArgs,
297
- applyCliFlagValueAliases(
565
+ const disallowedAncestorOptions = collectDisallowedAncestorOptions(
566
+ actionTarget,
567
+ action.command.flags
568
+ );
569
+ if (disallowedAncestorOptions.length > 0) {
570
+ throw new ValidationError('Unsupported option for this CLI command.', {
571
+ context: {
572
+ issues: disallowedAncestorOptions.map((message) => ({
573
+ message,
574
+ trailId: action.command.trail.id,
575
+ })),
576
+ },
577
+ });
578
+ }
579
+ parsedFlags = applyCliFlagValueAliases(
580
+ action.command.flags,
581
+ action.parsedFlags,
582
+ action.userSuppliedFlagKeys
583
+ );
584
+ await action.command.execute(action.parsedArgs, parsedFlags);
585
+ } catch (error: unknown) {
586
+ handleError(
587
+ error,
588
+ parsedFlags,
589
+ topoName,
590
+ getCanonicalUserSuppliedFlagKeys(
298
591
  action.command.flags,
299
- action.parsedFlags,
300
592
  action.userSuppliedFlagKeys
301
593
  )
302
594
  );
303
- } catch (error: unknown) {
304
- handleError(error);
305
595
  }
306
596
  });
307
597
  };
@@ -387,22 +677,22 @@ const ensureCommandNode = (
387
677
 
388
678
  const createBareChildFallback = (
389
679
  cmd: CliCommand,
680
+ path: readonly string[],
390
681
  parentState?: CommandNodeState | undefined
391
682
  ): BareChildFallback | undefined => {
392
- if (!parentState?.cliCommand || cmd.path.length < 2) {
683
+ if (!parentState?.cliCommand || path.length < 2) {
393
684
  return undefined;
394
685
  }
395
686
 
396
687
  const [parentArg] = parentState.cliCommand.args;
397
688
  const [childArg] = cmd.args;
398
- const childSegment = cmd.path.at(-1);
689
+ const childSegment = path.at(-1);
399
690
  if (
400
691
  parentArg === undefined ||
401
- childArg === undefined ||
402
692
  parentArg.required ||
403
693
  parentArg.variadic ||
404
- childArg.variadic ||
405
- childArg.name !== parentArg.name ||
694
+ (childArg !== undefined &&
695
+ (childArg.variadic || childArg.name !== parentArg.name)) ||
406
696
  childSegment === undefined
407
697
  ) {
408
698
  return undefined;
@@ -413,16 +703,19 @@ const createBareChildFallback = (
413
703
  argValue: childSegment,
414
704
  parentCommand: parentState.cliCommand,
415
705
  parentTarget: parentState.command,
706
+ requiresParentSignal: childArg === undefined,
416
707
  };
417
708
  };
418
709
 
419
710
  const applyCliCommand = (
420
711
  state: CommandNodeState,
421
712
  cmd: CliCommand,
713
+ path: readonly string[],
714
+ topoName: string,
422
715
  fallback?: BareChildFallback | undefined
423
716
  ): void => {
424
717
  if (state.executable) {
425
- throw new Error(`Duplicate CLI path: ${cmd.path.join(' ')}`);
718
+ throw new Error(`Duplicate CLI path: ${path.join(' ')}`);
426
719
  }
427
720
 
428
721
  if (cmd.description) {
@@ -436,11 +729,26 @@ const applyCliCommand = (
436
729
  addArgs(state.command, cmd, {
437
730
  forceOptionalFirstArg: fallback !== undefined,
438
731
  });
439
- wireAction(state.command, cmd, fallback);
732
+ wireAction(state.command, cmd, topoName, fallback);
440
733
  state.cliCommand = cmd;
441
734
  state.executable = true;
442
735
  };
443
736
 
737
+ const commandRoutes = (cmd: CliCommand) =>
738
+ cmd.routes ?? [
739
+ {
740
+ kind: 'canonical' as const,
741
+ path: cmd.path,
742
+ source: 'derived' as const,
743
+ target: cmd.trail.id,
744
+ },
745
+ ];
746
+
747
+ const commandRouteEntries = (commands: readonly CliCommand[]) =>
748
+ commands.flatMap((cmd) =>
749
+ commandRoutes(cmd).map((route) => ({ cmd, path: route.path }))
750
+ );
751
+
444
752
  /**
445
753
  * Convert framework-agnostic CLI commands into a Commander program.
446
754
  *
@@ -452,7 +760,10 @@ const applyCliCommand = (
452
760
  * const commands = deriveCliCommands(graph);
453
761
  * if (commands.isErr()) throw commands.error;
454
762
  *
455
- * const program = toCommander(commands.value, { name: 'demo' });
763
+ * const program = toCommander(commands.value, {
764
+ * name: 'demo',
765
+ * topoName: 'demo',
766
+ * });
456
767
  * ```
457
768
  */
458
769
  export const toCommander = (
@@ -462,17 +773,18 @@ export const toCommander = (
462
773
  validateCliCommands(commands);
463
774
  const program = new Command();
464
775
  applyOptions(program, options);
776
+ const topoName = options?.topoName ?? options?.name ?? program.name();
465
777
  const nodes = new Map<string, CommandNodeState>();
466
778
 
467
- for (const cmd of commands.toSorted((a, b) =>
779
+ for (const { cmd, path } of commandRouteEntries(commands).toSorted((a, b) =>
468
780
  a.path.length === b.path.length
469
781
  ? a.path.join('.').localeCompare(b.path.join('.'))
470
782
  : a.path.length - b.path.length
471
783
  )) {
472
- const state = ensureCommandNode(cmd.path, program, nodes);
473
- const parentKey = pathKey(cmd.path.slice(0, -1));
474
- const fallback = createBareChildFallback(cmd, nodes.get(parentKey));
475
- applyCliCommand(state, cmd, fallback);
784
+ const state = ensureCommandNode(path, program, nodes);
785
+ const parentKey = pathKey(path.slice(0, -1));
786
+ const fallback = createBareChildFallback(cmd, path, nodes.get(parentKey));
787
+ applyCliCommand(state, cmd, path, topoName, fallback);
476
788
  }
477
789
 
478
790
  return program;