@ontrails/commander 1.0.0-beta.39 → 1.0.0-beta.42

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,24 @@
1
1
  # @ontrails/commander
2
2
 
3
+ ## 1.0.0-beta.42
4
+
5
+ ### Patch Changes
6
+
7
+ - [`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.
8
+
9
+ ## 1.0.0-beta.41
10
+
11
+ ## 1.0.0-beta.40
12
+
13
+ ### Patch Changes
14
+
15
+ - [`4030698`](https://github.com/outfitter-dev/trails/commit/40306984467625844564f0f84156530d7118a79c): Keep structured input on nested child commands from being reinterpreted as a
16
+ bare child-name positional fallback, while preserving schema-authored
17
+ `inputJson` flags as ordinary trail input, including through the public Trails
18
+ CLI. Optional numeric flags now consume negative values with Commander's own
19
+ parsing semantics, and variadic flags consume every following value, before
20
+ nested command routing is resolved.
21
+
3
22
  ## 1.0.0-beta.39
4
23
 
5
24
  ### Patch Changes
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.39",
3
+ "version": "1.0.0-beta.42",
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.39",
26
- "@ontrails/core": "^1.0.0-beta.39",
25
+ "@ontrails/cli": "^1.0.0-beta.42",
26
+ "@ontrails/core": "^1.0.0-beta.42",
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
+ }
@@ -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
  // ---------------------------------------------------------------------------
@@ -84,6 +92,8 @@ const buildRepeatableArrayParser =
84
92
  return [...(Array.isArray(previous) ? previous : []), parsed];
85
93
  };
86
94
 
95
+ const structuredInputOptions = new WeakSet<Option>();
96
+
87
97
  /** Apply common modifiers (choices, default, arg parser) to a Commander Option. */
88
98
  const applyOptionModifiers = (opt: Option, flag: CliFlag): void => {
89
99
  if (isRepeatableArrayFlag(flag)) {
@@ -107,6 +117,9 @@ const applyOptionModifiers = (opt: Option, flag: CliFlag): void => {
107
117
  /** Build Commander Option(s) from a CliFlag. Returns one or two options. */
108
118
  const buildOptions = (flag: CliFlag): Option[] => {
109
119
  const opt = new Option(buildFlagString(flag), flag.description);
120
+ if (flag.role === 'structured-input') {
121
+ structuredInputOptions.add(opt);
122
+ }
110
123
  applyOptionModifiers(opt, flag);
111
124
  const valueAliasOptions = (flag.valueAliases ?? []).map(
112
125
  (alias) =>
@@ -198,6 +211,107 @@ const INHERITED_SURFACE_OPTION_KEYS = new Set([
198
211
  'trace',
199
212
  'watch',
200
213
  ]);
214
+ const commandPathCommands = (command: Command): readonly Command[] => {
215
+ const commands: Command[] = [];
216
+ let current: Command | null = command;
217
+ while (current !== null && current.parent !== null) {
218
+ commands.push(current);
219
+ current = current.parent;
220
+ }
221
+ return commands.toReversed();
222
+ };
223
+
224
+ const rootCommandFor = (command: Command): Command => {
225
+ let root = command;
226
+ while (root.parent !== null) {
227
+ root = root.parent;
228
+ }
229
+ return root;
230
+ };
231
+
232
+ const invocationArgsFor = (command: Command): readonly string[] =>
233
+ rootCommandFor(command).args;
234
+
235
+ const isVariadicOptionValue = (
236
+ command: Command,
237
+ option: Option | undefined,
238
+ token: string
239
+ ): boolean =>
240
+ option !== undefined &&
241
+ (!token.startsWith('-') || isNegativeNumberArg(command, token));
242
+
243
+ interface InvocationScan {
244
+ readonly pathEnd: number | undefined;
245
+ readonly structuredInputIndexes: ReadonlySet<number>;
246
+ }
247
+
248
+ const scanInvocation = (command: Command): InvocationScan => {
249
+ const rawArgs = invocationArgsFor(command);
250
+ const pathCommands = commandPathCommands(command);
251
+ const structuredInputIndexes = new Set<number>();
252
+ let activeCommand = rootCommandFor(command);
253
+ let activeVariadicOption: Option | undefined;
254
+ let optionsEnded = false;
255
+ let pathEnd: number | undefined;
256
+ let pathOffset = 0;
257
+ for (let index = 0; index < rawArgs.length; index += 1) {
258
+ const token = rawArgs[index];
259
+ if (token === undefined) {
260
+ continue;
261
+ }
262
+ if (!optionsEnded && token === '--') {
263
+ optionsEnded = true;
264
+ activeVariadicOption = undefined;
265
+ continue;
266
+ }
267
+ if (isVariadicOptionValue(command, activeVariadicOption, token)) {
268
+ continue;
269
+ }
270
+ activeVariadicOption = undefined;
271
+ const optionMatches = optionsEnded
272
+ ? []
273
+ : invocationOptionMatches(visibleOptionsFor(activeCommand), token);
274
+ const finalOptionMatch = optionMatches.at(-1);
275
+ if (finalOptionMatch !== undefined) {
276
+ if (
277
+ optionMatches.some((match) => structuredInputOptions.has(match.option))
278
+ ) {
279
+ structuredInputIndexes.add(index);
280
+ }
281
+ activeVariadicOption =
282
+ finalOptionMatch.option.variadic && !finalOptionMatch.inlineValue
283
+ ? finalOptionMatch.option
284
+ : undefined;
285
+ if (
286
+ optionConsumesFollowingValue(
287
+ command,
288
+ finalOptionMatch,
289
+ rawArgs[index + 1]
290
+ )
291
+ ) {
292
+ index += 1;
293
+ }
294
+ continue;
295
+ }
296
+ const nextPathCommand = pathCommands[pathOffset];
297
+ if (nextPathCommand?.name() === token) {
298
+ activeCommand = nextPathCommand;
299
+ pathOffset += 1;
300
+ if (pathOffset === pathCommands.length) {
301
+ pathEnd = index;
302
+ }
303
+ }
304
+ }
305
+ return { pathEnd, structuredInputIndexes };
306
+ };
307
+
308
+ const hasStructuredInputAfterCommandPath = (command: Command): boolean => {
309
+ const { pathEnd, structuredInputIndexes } = scanInvocation(command);
310
+ if (pathEnd === undefined) {
311
+ return false;
312
+ }
313
+ return [...structuredInputIndexes].some((index) => index > pathEnd);
314
+ };
201
315
 
202
316
  const hasUserSuppliedOptionOutside = (
203
317
  sourceCommand: Command,
@@ -217,6 +331,28 @@ const hasAnyPositionalValue = (
217
331
  parsedArgs: Readonly<Record<string, unknown>>
218
332
  ): boolean => cmd.args.some((arg) => parsedArgs[arg.name] !== undefined);
219
333
 
334
+ const hasUserSuppliedStructuredInput = (
335
+ command: Command,
336
+ fallback?: BareChildFallback | undefined,
337
+ ownOptionsOnly = false
338
+ ): boolean => {
339
+ for (const option of command.options) {
340
+ if (!structuredInputOptions.has(option)) {
341
+ continue;
342
+ }
343
+ const name = option.attributeName();
344
+ const source = ownOptionsOnly
345
+ ? command.getOptionValueSource(name)
346
+ : command.getOptionValueSourceWithGlobals(name);
347
+ if (source !== undefined && source !== 'default' && source !== 'implied') {
348
+ return (
349
+ fallback === undefined || hasStructuredInputAfterCommandPath(command)
350
+ );
351
+ }
352
+ }
353
+ return false;
354
+ };
355
+
220
356
  const getActionTarget = (fallbackTarget: Command, actionArgs: unknown[]) => {
221
357
  const candidate = actionArgs.at(-1);
222
358
  return candidate instanceof Command ? candidate : fallbackTarget;
@@ -286,8 +422,13 @@ const getUserSuppliedFlagKeys = (
286
422
  ): ReadonlySet<string> => {
287
423
  const userSupplied = new Set<string>();
288
424
  for (const key of getFlagOptionKeys(flags)) {
289
- if (isUserSuppliedOption(command, key)) {
290
- 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;
291
432
  }
292
433
  }
293
434
  return userSupplied;
@@ -510,12 +651,15 @@ const maybeUseBareChildFallback = (
510
651
  readonly parsedFlags: Record<string, unknown>;
511
652
  readonly userSuppliedFlagKeys: ReadonlySet<string>;
512
653
  } => {
513
- const hasParentOnlySignal = fallback
514
- ? hasUserSuppliedOptionOutside(fallback.parentTarget, target)
515
- : false;
654
+ const hasParentOnlySignal =
655
+ fallback !== undefined &&
656
+ (hasUserSuppliedOptionOutside(fallback.parentTarget, target) ||
657
+ (hasUserSuppliedStructuredInput(fallback.parentTarget, undefined, true) &&
658
+ !hasStructuredInputAfterCommandPath(target)));
516
659
  if (
517
660
  !fallback ||
518
661
  hasAnyPositionalValue(cmd, parsedArgs) ||
662
+ hasUserSuppliedStructuredInput(target, fallback) ||
519
663
  hasUserSuppliedOptionOutside(target, fallback.parentTarget) ||
520
664
  (fallback.requiresParentSignal && !hasParentOnlySignal)
521
665
  ) {
@@ -776,7 +920,7 @@ export const toCommander = (
776
920
  options?: ToCommanderOptions
777
921
  ): Command => {
778
922
  validateCliCommands(commands);
779
- const program = new Command();
923
+ const program = new TrailsCommanderProgram(commands);
780
924
  applyOptions(program, options);
781
925
  const topoName = options?.topoName ?? options?.name ?? program.name();
782
926
  const nodes = new Map<string, CommandNodeState>();