@ontrails/commander 1.0.0-beta.41 → 1.0.0-beta.43

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,21 @@
1
1
  # @ontrails/commander
2
2
 
3
+ ## 1.0.0-beta.43
4
+
5
+ ### Patch Changes
6
+
7
+ - [`88a6a62`](https://github.com/outfitter-dev/trails/commit/88a6a62a9e9e230ca6d368fa78dc3ece6c816204): Complete the v1 classification-first cutover from projection/project vocabulary
8
+ to derive/derived for contract-owned fact production and render/rendered for
9
+ surface presentation. Public type, helper, rule, relation, and report names move
10
+ without compatibility aliases; ordinary repository/project nouns remain
11
+ explicit preserves or structured review inventory.
12
+
13
+ ## 1.0.0-beta.42
14
+
15
+ ### Patch Changes
16
+
17
+ - [`454e935`](https://github.com/outfitter-dev/trails/commit/454e935088782a181df89a205c0ff6f2eb936434): Define bounded multiselect argv normalization in the framework CLI model and apply it automatically in the Commander adapter, accepting both contiguous and repeated forms while preserving child routes after the first explicit value.
18
+
3
19
  ## 1.0.0-beta.41
4
20
 
5
21
  ## 1.0.0-beta.40
package/README.md CHANGED
@@ -35,6 +35,17 @@ if (commands.isErr()) {
35
35
  const program = toCommander(commands.value, { name: 'myapp' });
36
36
  ```
37
37
 
38
+ ## Multiselect flags
39
+
40
+ Schema fields such as `z.array(z.enum(['cli', 'mcp', 'http']))` derive a bounded multiselect flag. The shared CLI argv normalizer lets adapters accept both contiguous and repeated forms; Commander applies it automatically:
41
+
42
+ ```bash
43
+ myapp create --surfaces cli mcp http
44
+ myapp create --surfaces cli --surfaces mcp --surfaces http
45
+ ```
46
+
47
+ The first matching token after the flag is its explicit value. After that first value, additional collection stops before known child routes or values outside the declared choices. Adopters do not need custom parsing or surface configuration for either form.
48
+
38
49
  ## Installation
39
50
 
40
51
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/commander",
3
- "version": "1.0.0-beta.41",
3
+ "version": "1.0.0-beta.43",
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.41",
26
- "@ontrails/core": "^1.0.0-beta.41",
25
+ "@ontrails/cli": "^1.0.0-beta.43",
26
+ "@ontrails/core": "^1.0.0-beta.43",
27
27
  "commander": "^14.0.3"
28
28
  },
29
29
  "peerDependencies": {
@@ -0,0 +1,182 @@
1
+ import { Command } from 'commander';
2
+ import type { ParseOptions, Option } from 'commander';
3
+ import { normalizeCliArgv } from '@ontrails/cli';
4
+ import type { CliCommand } from '@ontrails/cli';
5
+
6
+ type EffectiveParseOptions = Omit<ParseOptions, 'from'> & {
7
+ readonly from?: ParseOptions['from'] | 'eval' | undefined;
8
+ };
9
+
10
+ export const visibleOptionsFor = (command: Command): readonly Option[] => {
11
+ const commands: Command[] = [];
12
+ let current: Command | null = command;
13
+ while (current !== null) {
14
+ commands.push(current);
15
+ current = current.parent;
16
+ }
17
+ return commands.toReversed().flatMap((owner) => owner.options);
18
+ };
19
+
20
+ export interface InvocationOptionMatch {
21
+ readonly inlineValue: boolean;
22
+ readonly option: Option;
23
+ }
24
+
25
+ const findShortOption = (
26
+ options: readonly Option[],
27
+ short: string
28
+ ): Option | undefined => options.find((candidate) => candidate.short === short);
29
+
30
+ export const invocationOptionMatches = (
31
+ options: readonly Option[],
32
+ token: string
33
+ ): readonly InvocationOptionMatch[] => {
34
+ const exact = options.filter(
35
+ (option) => token === option.long || token === option.short
36
+ );
37
+ if (exact.length > 0) {
38
+ return exact.map((option) => ({ inlineValue: false, option }));
39
+ }
40
+
41
+ const longWithValue = options.filter(
42
+ (option) =>
43
+ option.long !== undefined &&
44
+ (option.required || option.optional) &&
45
+ token.startsWith(`${option.long}=`)
46
+ );
47
+ if (longWithValue.length > 0) {
48
+ return longWithValue.map((option) => ({ inlineValue: true, option }));
49
+ }
50
+
51
+ if (token.length <= 2 || token[0] !== '-' || token[1] === '-') {
52
+ return [];
53
+ }
54
+
55
+ const matches: InvocationOptionMatch[] = [];
56
+ let group = token.slice(1);
57
+ while (group.length > 0) {
58
+ const option = findShortOption(options, `-${group[0]}`);
59
+ if (option === undefined) {
60
+ break;
61
+ }
62
+ const inlineValue =
63
+ (option.required || option.optional) && group.length > 1;
64
+ matches.push({ inlineValue, option });
65
+ if (option.required || option.optional) {
66
+ break;
67
+ }
68
+ group = group.slice(1);
69
+ }
70
+ return matches;
71
+ };
72
+
73
+ export const isNegativeNumberArg = (
74
+ command: Command,
75
+ token: string
76
+ ): boolean => {
77
+ if (!/^-(\d+|\d*\.\d+)(e[+-]?\d+)?$/.test(token)) {
78
+ return false;
79
+ }
80
+
81
+ for (
82
+ let current: Command | null = command;
83
+ current !== null;
84
+ current = current.parent
85
+ ) {
86
+ if (current.options.some((option) => /^-\d$/.test(option.short ?? ''))) {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ return true;
92
+ };
93
+
94
+ export const optionConsumesFollowingValue = (
95
+ command: Command,
96
+ match: InvocationOptionMatch,
97
+ nextToken: string | undefined
98
+ ): boolean =>
99
+ !match.inlineValue &&
100
+ (match.option.required ||
101
+ (match.option.optional &&
102
+ nextToken !== undefined &&
103
+ (!nextToken.startsWith('-') || isNegativeNumberArg(command, nextToken))));
104
+
105
+ const argvUserStart = (
106
+ parseOptions?: EffectiveParseOptions | undefined
107
+ ): number => {
108
+ if (parseOptions?.from === 'user') {
109
+ return 0;
110
+ }
111
+ if (parseOptions?.from === 'eval') {
112
+ return 1;
113
+ }
114
+ if (parseOptions?.from === 'electron') {
115
+ const electronProcess = process as NodeJS.Process & {
116
+ readonly defaultApp?: boolean | undefined;
117
+ };
118
+ return electronProcess.defaultApp ? 2 : 1;
119
+ }
120
+ return 2;
121
+ };
122
+
123
+ const effectiveParseOptions = (
124
+ argv: readonly string[] | undefined,
125
+ parseOptions: ParseOptions | undefined
126
+ ): EffectiveParseOptions | undefined => {
127
+ if (
128
+ argv === undefined &&
129
+ parseOptions?.from === undefined &&
130
+ process.execArgv.some((arg) =>
131
+ ['-e', '--eval', '-p', '--print'].includes(arg)
132
+ )
133
+ ) {
134
+ // Commander supports this origin internally but does not publish it in
135
+ // ParseOptions. Preserve its one-token offset while normalizing argv.
136
+ return { from: 'eval' };
137
+ }
138
+ if (
139
+ argv === undefined &&
140
+ parseOptions?.from === undefined &&
141
+ process.versions['electron'] !== undefined
142
+ ) {
143
+ return { from: 'electron' };
144
+ }
145
+ return parseOptions;
146
+ };
147
+
148
+ export class TrailsCommanderProgram extends Command {
149
+ readonly #commands: readonly CliCommand[];
150
+
151
+ constructor(commands: readonly CliCommand[]) {
152
+ super();
153
+ this.#commands = commands;
154
+ }
155
+
156
+ #normalizeArgv(
157
+ argv: readonly string[] | undefined,
158
+ parseOptions: EffectiveParseOptions | undefined
159
+ ): readonly string[] {
160
+ const input = argv ?? process.argv;
161
+ const start = argvUserStart(parseOptions);
162
+ return [
163
+ ...input.slice(0, start),
164
+ ...normalizeCliArgv(this.#commands, input.slice(start)),
165
+ ];
166
+ }
167
+
168
+ override parse(argv?: readonly string[], parseOptions?: ParseOptions): this {
169
+ const options = effectiveParseOptions(argv, parseOptions);
170
+ const normalized = this.#normalizeArgv(argv, options);
171
+ return super.parse(normalized, options as ParseOptions | undefined);
172
+ }
173
+
174
+ override parseAsync(
175
+ argv?: readonly string[],
176
+ parseOptions?: ParseOptions
177
+ ): Promise<this> {
178
+ const options = effectiveParseOptions(argv, parseOptions);
179
+ const normalized = this.#normalizeArgv(argv, options);
180
+ return super.parseAsync(normalized, options as ParseOptions | undefined);
181
+ }
182
+ }
package/src/surface.ts CHANGED
@@ -38,7 +38,7 @@ export interface CreateProgramOptions extends BaseSurfaceOptions {
38
38
  /**
39
39
  * App-authored overlay envelopes (conventionally the app module's
40
40
  * `trailsOverlays` export); the `surfaces` envelope's `cli` bindings
41
- * project synonym and command-group routes onto the program.
41
+ * render synonym and command-group routes onto the program.
42
42
  */
43
43
  readonly overlays?: readonly OverlayEnvelopeLike[] | undefined;
44
44
  readonly presets?: CliFlag[][] | undefined;
@@ -4,12 +4,12 @@
4
4
 
5
5
  import {
6
6
  isTrailsError,
7
- projectPublicSurfaceError,
7
+ renderPublicSurfaceError,
8
8
  redactErrorContext,
9
9
  redactErrorString,
10
10
  ValidationError,
11
11
  } from '@ontrails/core';
12
- import type { SurfaceErrorProjection } from '@ontrails/core';
12
+ import type { SurfaceErrorRendering } from '@ontrails/core';
13
13
  import type { CliCommand, CliFlag } from '@ontrails/cli';
14
14
  import {
15
15
  applyCliFlagValueAliases,
@@ -18,6 +18,14 @@ import {
18
18
  } from '@ontrails/cli';
19
19
  import { Command, InvalidArgumentError, Option } from 'commander';
20
20
 
21
+ import {
22
+ invocationOptionMatches,
23
+ isNegativeNumberArg,
24
+ optionConsumesFollowingValue,
25
+ TrailsCommanderProgram,
26
+ visibleOptionsFor,
27
+ } from './multiselect-argv.js';
28
+
21
29
  // ---------------------------------------------------------------------------
22
30
  // Options
23
31
  // ---------------------------------------------------------------------------
@@ -224,87 +232,6 @@ const rootCommandFor = (command: Command): Command => {
224
232
  const invocationArgsFor = (command: Command): readonly string[] =>
225
233
  rootCommandFor(command).args;
226
234
 
227
- const visibleOptionsFor = (command: Command): readonly Option[] => {
228
- const commands: Command[] = [];
229
- let current: Command | null = command;
230
- while (current !== null) {
231
- commands.push(current);
232
- current = current.parent;
233
- }
234
- return commands.toReversed().flatMap((owner) => owner.options);
235
- };
236
-
237
- interface InvocationOptionMatch {
238
- readonly inlineValue: boolean;
239
- readonly option: Option;
240
- }
241
-
242
- const findShortOption = (
243
- options: readonly Option[],
244
- short: string
245
- ): Option | undefined => options.find((candidate) => candidate.short === short);
246
-
247
- const invocationOptionMatches = (
248
- options: readonly Option[],
249
- token: string
250
- ): readonly InvocationOptionMatch[] => {
251
- const exact = options.filter(
252
- (option) => token === option.long || token === option.short
253
- );
254
- if (exact.length > 0) {
255
- return exact.map((option) => ({ inlineValue: false, option }));
256
- }
257
-
258
- const longWithValue = options.filter(
259
- (option) =>
260
- option.long !== undefined &&
261
- (option.required || option.optional) &&
262
- token.startsWith(`${option.long}=`)
263
- );
264
- if (longWithValue.length > 0) {
265
- return longWithValue.map((option) => ({ inlineValue: true, option }));
266
- }
267
-
268
- if (token.length <= 2 || token[0] !== '-' || token[1] === '-') {
269
- return [];
270
- }
271
-
272
- const matches: InvocationOptionMatch[] = [];
273
- let group = token.slice(1);
274
- while (group.length > 0) {
275
- const option = findShortOption(options, `-${group[0]}`);
276
- if (option === undefined) {
277
- break;
278
- }
279
- const inlineValue =
280
- (option.required || option.optional) && group.length > 1;
281
- matches.push({ inlineValue, option });
282
- if (option.required || option.optional) {
283
- break;
284
- }
285
- group = group.slice(1);
286
- }
287
- return matches;
288
- };
289
-
290
- const isNegativeNumberArg = (command: Command, token: string): boolean => {
291
- if (!/^-(\d+|\d*\.\d+)(e[+-]?\d+)?$/.test(token)) {
292
- return false;
293
- }
294
-
295
- for (
296
- let current: Command | null = command;
297
- current !== null;
298
- current = current.parent
299
- ) {
300
- if (current.options.some((option) => /^-\d$/.test(option.short ?? ''))) {
301
- return false;
302
- }
303
- }
304
-
305
- return true;
306
- };
307
-
308
235
  const isVariadicOptionValue = (
309
236
  command: Command,
310
237
  option: Option | undefined,
@@ -313,17 +240,6 @@ const isVariadicOptionValue = (
313
240
  option !== undefined &&
314
241
  (!token.startsWith('-') || isNegativeNumberArg(command, token));
315
242
 
316
- const optionConsumesFollowingValue = (
317
- command: Command,
318
- match: InvocationOptionMatch,
319
- nextToken: string | undefined
320
- ): boolean =>
321
- !match.inlineValue &&
322
- (match.option.required ||
323
- (match.option.optional &&
324
- nextToken !== undefined &&
325
- (!nextToken.startsWith('-') || isNegativeNumberArg(command, nextToken))));
326
-
327
243
  interface InvocationScan {
328
244
  readonly pathEnd: number | undefined;
329
245
  readonly structuredInputIndexes: ReadonlySet<number>;
@@ -506,8 +422,13 @@ const getUserSuppliedFlagKeys = (
506
422
  ): ReadonlySet<string> => {
507
423
  const userSupplied = new Set<string>();
508
424
  for (const key of getFlagOptionKeys(flags)) {
509
- if (isUserSuppliedOption(command, key)) {
510
- userSupplied.add(key);
425
+ let owner: Command | null = command;
426
+ while (owner !== null) {
427
+ if (isUserSuppliedOption(owner, key)) {
428
+ userSupplied.add(key);
429
+ break;
430
+ }
431
+ owner = owner.parent;
511
432
  }
512
433
  }
513
434
  return userSupplied;
@@ -590,7 +511,7 @@ const collectPermitScopeLines = (
590
511
  /**
591
512
  * Collect operator-facing detail lines for an execution error.
592
513
  *
593
- * The public surface projection intentionally drops structured context, but
514
+ * The public surface rendering intentionally drops structured context, but
594
515
  * the CLI is an operator surface: validation issues and permit scope
595
516
  * requirements are what the operator needs to act, so they are re-rendered
596
517
  * here (through the shared redactor) for non-internal Trails errors.
@@ -621,7 +542,7 @@ const collectErrorContext = (
621
542
  interface CliErrorEnvelope {
622
543
  readonly ok: false;
623
544
  readonly context?: Record<string, unknown> | undefined;
624
- readonly error: SurfaceErrorProjection;
545
+ readonly error: SurfaceErrorRendering;
625
546
  readonly details?: readonly string[] | undefined;
626
547
  }
627
548
 
@@ -659,12 +580,12 @@ const handleError = (
659
580
  userSuppliedFlagKeys: ReadonlySet<string>
660
581
  ): void => {
661
582
  const err = error instanceof Error ? error : new Error(String(error));
662
- const projection = projectPublicSurfaceError('cli', err);
583
+ const rendering = renderPublicSurfaceError('cli', err);
663
584
  const context = collectErrorContext(err);
664
585
  const details = collectErrorDetailLines(err);
665
586
  const mode = structuredErrorMode(flags, topoName, userSuppliedFlagKeys);
666
587
  if (mode === undefined) {
667
- process.stderr.write(`Error: ${projection.message}\n`);
588
+ process.stderr.write(`Error: ${rendering.message}\n`);
668
589
  for (const line of details) {
669
590
  process.stderr.write(` ${line}\n`);
670
591
  }
@@ -672,14 +593,14 @@ const handleError = (
672
593
  writeStructuredError(
673
594
  {
674
595
  ...(context === undefined ? {} : { context }),
675
- error: projection,
596
+ error: rendering,
676
597
  ...(details.length === 0 ? {} : { details }),
677
598
  ok: false,
678
599
  },
679
600
  mode
680
601
  );
681
602
  }
682
- process.exit(projection.code);
603
+ process.exit(rendering.code);
683
604
  };
684
605
 
685
606
  const collectDisallowedAncestorOptions = (
@@ -999,7 +920,7 @@ export const toCommander = (
999
920
  options?: ToCommanderOptions
1000
921
  ): Command => {
1001
922
  validateCliCommands(commands);
1002
- const program = new Command();
923
+ const program = new TrailsCommanderProgram(commands);
1003
924
  applyOptions(program, options);
1004
925
  const topoName = options?.topoName ?? options?.name ?? program.name();
1005
926
  const nodes = new Map<string, CommandNodeState>();