@ontrails/commander 1.0.0-beta.41 → 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,11 @@
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
+
3
9
  ## 1.0.0-beta.41
4
10
 
5
11
  ## 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.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.41",
26
- "@ontrails/core": "^1.0.0-beta.41",
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
  // ---------------------------------------------------------------------------
@@ -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;
@@ -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>();