@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 +6 -0
- package/README.md +11 -0
- package/package.json +3 -3
- package/src/multiselect-argv.ts +182 -0
- package/src/to-commander.ts +16 -95
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.
|
|
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.
|
|
26
|
-
"@ontrails/core": "^1.0.0-beta.
|
|
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
|
+
}
|
package/src/to-commander.ts
CHANGED
|
@@ -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
|
-
|
|
510
|
-
|
|
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
|
|
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>();
|