@guanghechen/commander 3.0.0 → 3.2.0

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
  # Change Log
2
2
 
3
+ ## 3.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Change args from string[] to Record<string, unknown> with type/coerce/default support:
8
+ - `args` is now `Record<string, unknown>` keyed by argument name
9
+ - Add `rawArgs: string[]` for original argument strings before type conversion
10
+ - IArgument now supports `type`, `default`, and `coerce` properties
11
+ - Add `TooManyArguments` error kind for extra arguments validation
12
+
13
+ ## 3.1.0
14
+
15
+ ### Minor Changes
16
+
17
+ - Implement option bubbling with shift/apply flow
18
+ - Add `shift()` method for bottom-up option consumption (leaf → root)
19
+ - Refactor `run()` with new flow: route → split → shift → apply → action
20
+ - Add `UnexpectedArgument` error type for positional args before `--`
21
+
3
22
  All notable changes to this project will be documented in this file. See
4
23
  [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
24
 
package/lib/cjs/index.cjs CHANGED
@@ -126,34 +126,34 @@ class Command {
126
126
  const { argv, envs, reporter } = params;
127
127
  try {
128
128
  const processedArgv = this.#processHelpSubcommand(argv);
129
- const { command, remaining } = this.#route(processedArgv);
130
- const allOptions = command.#getMergedOptions();
131
- const hasUserHelp = allOptions.some(o => o.long === 'help' && !command.#isBuiltinOption(o));
132
- const hasUserVersion = allOptions.some(o => o.long === 'version' && !command.#isBuiltinOption(o));
133
- if (!hasUserHelp && command.#hasHelpFlag(remaining, allOptions)) {
134
- console.log(command.formatHelp());
129
+ const { chain, remaining } = this.#routeChain(processedArgv);
130
+ const leafCommand = chain[chain.length - 1];
131
+ const { optionTokens, restArgs } = this.#splitAtDoubleDash(remaining);
132
+ const leafOptions = leafCommand.#getMergedOptions();
133
+ const hasUserHelp = leafCommand.#options.some(o => o.long === 'help');
134
+ const hasUserVersion = leafCommand.#options.some(o => o.long === 'version');
135
+ if (!hasUserHelp && leafCommand.#hasHelpFlag(optionTokens, leafOptions)) {
136
+ console.log(leafCommand.formatHelp());
135
137
  return;
136
138
  }
137
- if (!hasUserVersion && command.#hasVersionFlag(remaining, allOptions)) {
138
- console.log(command.version ?? 'unknown');
139
+ if (!hasUserVersion && leafCommand.#hasVersionFlag(optionTokens, leafOptions)) {
140
+ console.log(leafCommand.version ?? 'unknown');
139
141
  return;
140
142
  }
141
- const { opts, args } = command.parse(remaining);
143
+ const optsMap = this.#shiftChain(chain, optionTokens);
142
144
  const ctx = {
143
- cmd: command,
145
+ cmd: leafCommand,
144
146
  envs,
145
147
  reporter: reporter ?? new DefaultReporter(),
146
148
  argv,
147
149
  };
148
- for (const opt of allOptions) {
149
- if (opt.apply && opts[opt.long] !== undefined) {
150
- opt.apply(opts[opt.long], ctx);
151
- }
152
- }
153
- const actionParams = { ctx, opts, args };
154
- if (command.#action) {
150
+ this.#applyChain(chain, optsMap, ctx);
151
+ const mergedOpts = this.#mergeOpts(chain, optsMap);
152
+ const { args, rawArgs } = leafCommand.#parseArguments(restArgs);
153
+ const actionParams = { ctx, opts: mergedOpts, args, rawArgs };
154
+ if (leafCommand.#action) {
155
155
  try {
156
- await command.#action(actionParams);
156
+ await leafCommand.#action(actionParams);
157
157
  }
158
158
  catch (err) {
159
159
  if (err instanceof Error) {
@@ -165,11 +165,11 @@ class Command {
165
165
  process.exit(1);
166
166
  }
167
167
  }
168
- else if (command.#subcommands.length > 0) {
169
- console.log(command.formatHelp());
168
+ else if (leafCommand.#subcommands.length > 0) {
169
+ console.log(leafCommand.formatHelp());
170
170
  }
171
171
  else {
172
- throw new CommanderError('ConfigurationError', `no action defined for command "${command.#getCommandPath()}"`, command.#getCommandPath());
172
+ throw new CommanderError('ConfigurationError', `no action defined for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
173
173
  }
174
174
  }
175
175
  catch (err) {
@@ -184,7 +184,7 @@ class Command {
184
184
  parse(argv) {
185
185
  const allOptions = this.#getMergedOptions();
186
186
  const opts = {};
187
- const args = [];
187
+ const rawArgs = [];
188
188
  for (const opt of allOptions) {
189
189
  if (opt.default !== undefined) {
190
190
  opts[opt.long] = opt.default;
@@ -209,7 +209,7 @@ class Command {
209
209
  while (i < remaining.length) {
210
210
  const token = remaining[i];
211
211
  if (token === '--') {
212
- args.push(...remaining.slice(i + 1));
212
+ rawArgs.push(...remaining.slice(i + 1));
213
213
  break;
214
214
  }
215
215
  if (token.startsWith('--')) {
@@ -220,7 +220,7 @@ class Command {
220
220
  i = this.#parseShortOption(remaining, i, optionByShort, opts);
221
221
  continue;
222
222
  }
223
- args.push(token);
223
+ rawArgs.push(token);
224
224
  i += 1;
225
225
  }
226
226
  for (const opt of allOptions) {
@@ -240,12 +240,84 @@ class Command {
240
240
  }
241
241
  }
242
242
  }
243
- const requiredArgs = this.#arguments.filter(a => a.kind === 'required');
244
- if (args.length < requiredArgs.length) {
245
- const missing = requiredArgs.slice(args.length).map(a => a.name);
246
- throw new CommanderError('MissingRequiredArgument', `missing required argument(s): ${missing.join(', ')}`, this.#getCommandPath());
243
+ const { args } = this.#parseArguments(rawArgs);
244
+ return { opts, args, rawArgs };
245
+ }
246
+ shift(tokens) {
247
+ return this.#shiftWithShadowed(tokens, new Set());
248
+ }
249
+ #shiftWithShadowed(tokens, shadowed) {
250
+ const allDirectOptions = this.#getMergedOptions();
251
+ const directOptions = allDirectOptions.filter(o => !shadowed.has(o.long));
252
+ const opts = {};
253
+ for (const opt of directOptions) {
254
+ if (opt.default !== undefined) {
255
+ opts[opt.long] = opt.default;
256
+ }
257
+ else if (opt.type === 'boolean') {
258
+ opts[opt.long] = false;
259
+ }
260
+ else if (opt.type === 'string[]' || opt.type === 'number[]') {
261
+ opts[opt.long] = [];
262
+ }
263
+ }
264
+ let remaining = [...tokens];
265
+ const resolverOptions = directOptions.filter(o => o.resolver);
266
+ for (const opt of resolverOptions) {
267
+ const result = opt.resolver(remaining);
268
+ opts[opt.long] = result.value;
269
+ remaining = result.remaining;
270
+ }
271
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(directOptions, true);
272
+ const normalizedTokens = this.#normalizeArgv(remaining, booleanOptions);
273
+ const finalRemaining = [];
274
+ let i = 0;
275
+ while (i < normalizedTokens.length) {
276
+ const token = normalizedTokens[i];
277
+ if (token.startsWith('--')) {
278
+ const consumed = this.#tryConsumeLongOption(normalizedTokens, i, optionByLong, opts);
279
+ if (consumed > 0) {
280
+ i += consumed;
281
+ continue;
282
+ }
283
+ finalRemaining.push(token);
284
+ i += 1;
285
+ continue;
286
+ }
287
+ if (token.startsWith('-') && token.length > 1) {
288
+ const result = this.#tryConsumeShortOption(normalizedTokens, i, optionByShort, opts);
289
+ if (result.consumed) {
290
+ i = result.nextIdx;
291
+ if (result.remainingToken) {
292
+ finalRemaining.push(result.remainingToken);
293
+ }
294
+ continue;
295
+ }
296
+ finalRemaining.push(token);
297
+ i += 1;
298
+ continue;
299
+ }
300
+ finalRemaining.push(token);
301
+ i += 1;
302
+ }
303
+ for (const opt of directOptions) {
304
+ if (opt.required && opts[opt.long] === undefined) {
305
+ throw new CommanderError('MissingRequired', `missing required option "--${opt.long}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
306
+ }
247
307
  }
248
- return { opts, args };
308
+ for (const opt of directOptions) {
309
+ if (opt.choices && opts[opt.long] !== undefined) {
310
+ const value = opts[opt.long];
311
+ const values = Array.isArray(value) ? value : [value];
312
+ const choices = opt.choices;
313
+ for (const v of values) {
314
+ if (!choices.includes(v)) {
315
+ throw new CommanderError('InvalidChoice', `invalid value "${v}" for option "--${opt.long}". Allowed: ${opt.choices.join(', ')}`, this.#getCommandPath());
316
+ }
317
+ }
318
+ }
319
+ }
320
+ return { opts, remaining: finalRemaining };
249
321
  }
250
322
  formatHelp() {
251
323
  const lines = [];
@@ -369,7 +441,8 @@ class Command {
369
441
  }
370
442
  return argv;
371
443
  }
372
- #route(argv) {
444
+ #routeChain(argv) {
445
+ const chain = [this];
373
446
  let current = this;
374
447
  let idx = 0;
375
448
  while (idx < argv.length) {
@@ -380,9 +453,62 @@ class Command {
380
453
  if (!entry)
381
454
  break;
382
455
  current = entry.command;
456
+ chain.push(current);
383
457
  idx += 1;
384
458
  }
385
- return { command: current, remaining: argv.slice(idx) };
459
+ return { chain, remaining: argv.slice(idx) };
460
+ }
461
+ #splitAtDoubleDash(tokens) {
462
+ const ddIdx = tokens.indexOf('--');
463
+ if (ddIdx === -1) {
464
+ return { optionTokens: tokens, restArgs: [] };
465
+ }
466
+ return {
467
+ optionTokens: tokens.slice(0, ddIdx),
468
+ restArgs: tokens.slice(ddIdx + 1),
469
+ };
470
+ }
471
+ #shiftChain(chain, tokens) {
472
+ const optsMap = new Map();
473
+ let remaining = [...tokens];
474
+ const shadowed = new Set();
475
+ for (let i = chain.length - 1; i >= 0; i--) {
476
+ const cmd = chain[i];
477
+ const result = cmd.#shiftWithShadowed(remaining, shadowed);
478
+ optsMap.set(cmd, result.opts);
479
+ remaining = result.remaining;
480
+ for (const opt of cmd.#options) {
481
+ shadowed.add(opt.long);
482
+ }
483
+ }
484
+ if (remaining.length > 0) {
485
+ const leafCommand = chain[chain.length - 1];
486
+ const firstToken = remaining[0];
487
+ if (firstToken.startsWith('-')) {
488
+ throw new CommanderError('UnknownOption', `unknown option "${firstToken}" for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
489
+ }
490
+ else {
491
+ throw new CommanderError('UnexpectedArgument', `unexpected argument "${firstToken}". Positional arguments must come after "--"`, leafCommand.#getCommandPath());
492
+ }
493
+ }
494
+ return optsMap;
495
+ }
496
+ #applyChain(chain, optsMap, ctx) {
497
+ for (const cmd of chain) {
498
+ const opts = optsMap.get(cmd) ?? {};
499
+ for (const opt of cmd.#getMergedOptions()) {
500
+ if (opt.apply && opts[opt.long] !== undefined) {
501
+ opt.apply(opts[opt.long], ctx);
502
+ }
503
+ }
504
+ }
505
+ }
506
+ #mergeOpts(chain, optsMap) {
507
+ const merged = {};
508
+ for (const cmd of chain) {
509
+ Object.assign(merged, optsMap.get(cmd) ?? {});
510
+ }
511
+ return merged;
386
512
  }
387
513
  #parseLongOption(argv, idx, optionByLong, opts) {
388
514
  const token = argv[idx];
@@ -527,6 +653,9 @@ class Command {
527
653
  }
528
654
  }
529
655
  #validateArgumentConfig(arg) {
656
+ if (arg.kind === 'required' && arg.default !== undefined) {
657
+ throw new CommanderError('ConfigurationError', `required argument "${arg.name}" cannot have a default value`, this.#getCommandPath());
658
+ }
530
659
  if (arg.kind === 'variadic') {
531
660
  if (this.#arguments.some(a => a.kind === 'variadic')) {
532
661
  throw new CommanderError('ConfigurationError', 'only one variadic argument is allowed', this.#getCommandPath());
@@ -545,8 +674,60 @@ class Command {
545
674
  }
546
675
  }
547
676
  }
548
- #isBuiltinOption(opt) {
549
- return opt === BUILTIN_HELP_OPTION || opt === BUILTIN_VERSION_OPTION;
677
+ #parseArguments(rawArgs) {
678
+ const argumentDefs = this.#arguments;
679
+ const args = {};
680
+ const requiredCount = argumentDefs.filter(a => a.kind === 'required').length;
681
+ if (rawArgs.length < requiredCount) {
682
+ const missing = argumentDefs
683
+ .filter(a => a.kind === 'required')
684
+ .slice(rawArgs.length)
685
+ .map(a => a.name);
686
+ throw new CommanderError('MissingRequiredArgument', `missing required argument(s): ${missing.join(', ')}`, this.#getCommandPath());
687
+ }
688
+ let index = 0;
689
+ for (const def of argumentDefs) {
690
+ if (def.kind === 'variadic') {
691
+ const rest = rawArgs.slice(index);
692
+ args[def.name] = rest.map(raw => this.#convertArgument(def, raw));
693
+ index = rawArgs.length;
694
+ break;
695
+ }
696
+ const raw = rawArgs[index];
697
+ if (raw === undefined) {
698
+ if (def.kind === 'optional') {
699
+ args[def.name] = def.default ?? undefined;
700
+ continue;
701
+ }
702
+ }
703
+ else {
704
+ args[def.name] = this.#convertArgument(def, raw);
705
+ index += 1;
706
+ }
707
+ }
708
+ const hasVariadic = argumentDefs.some(a => a.kind === 'variadic');
709
+ if (!hasVariadic && index < rawArgs.length) {
710
+ throw new CommanderError('TooManyArguments', `too many arguments: expected ${argumentDefs.length}, got ${rawArgs.length}`, this.#getCommandPath());
711
+ }
712
+ return { args, rawArgs };
713
+ }
714
+ #convertArgument(def, raw) {
715
+ if (def.coerce) {
716
+ try {
717
+ return def.coerce(raw);
718
+ }
719
+ catch {
720
+ throw new CommanderError('InvalidType', `invalid value "${raw}" for argument "${def.name}"`, this.#getCommandPath());
721
+ }
722
+ }
723
+ if (def.type === 'number') {
724
+ const n = Number(raw);
725
+ if (Number.isNaN(n)) {
726
+ throw new CommanderError('InvalidType', `invalid number "${raw}" for argument "${def.name}"`, this.#getCommandPath());
727
+ }
728
+ return n;
729
+ }
730
+ return raw;
550
731
  }
551
732
  #buildOptionMaps(allOptions, excludeResolver = false) {
552
733
  const optionByLong = new Map();
@@ -644,6 +825,100 @@ class Command {
644
825
  #getCommandPath() {
645
826
  return this.#name;
646
827
  }
828
+ #tryConsumeLongOption(tokens, idx, optionByLong, opts) {
829
+ const token = tokens[idx];
830
+ const eqIdx = token.indexOf('=');
831
+ let optName;
832
+ let inlineValue;
833
+ if (eqIdx !== -1) {
834
+ optName = token.slice(2, eqIdx);
835
+ inlineValue = token.slice(eqIdx + 1);
836
+ }
837
+ else {
838
+ optName = token.slice(2);
839
+ }
840
+ const opt = optionByLong.get(optName);
841
+ if (!opt) {
842
+ return 0;
843
+ }
844
+ if (opt.type === 'boolean') {
845
+ if (inlineValue !== undefined) {
846
+ if (inlineValue === 'true') {
847
+ opts[optName] = true;
848
+ }
849
+ else if (inlineValue === 'false') {
850
+ opts[optName] = false;
851
+ }
852
+ else {
853
+ throw new CommanderError('InvalidBooleanValue', `invalid value "${inlineValue}" for boolean option "--${optName}". Use "true" or "false"`, this.#getCommandPath());
854
+ }
855
+ }
856
+ else {
857
+ opts[optName] = true;
858
+ }
859
+ return 1;
860
+ }
861
+ let value;
862
+ let consumed = 1;
863
+ if (inlineValue !== undefined) {
864
+ value = inlineValue;
865
+ }
866
+ else if (idx + 1 < tokens.length) {
867
+ value = tokens[idx + 1];
868
+ consumed = 2;
869
+ }
870
+ else {
871
+ throw new CommanderError('MissingValue', `option "--${optName}" requires a value`, this.#getCommandPath());
872
+ }
873
+ this.#applyValue(opt, value, opts);
874
+ return consumed;
875
+ }
876
+ #tryConsumeShortOption(tokens, idx, optionByShort, opts) {
877
+ const token = tokens[idx];
878
+ if (token.includes('=')) {
879
+ const firstFlag = token[1];
880
+ if (!optionByShort.has(firstFlag)) {
881
+ return { consumed: false, nextIdx: idx + 1 };
882
+ }
883
+ throw new CommanderError('UnsupportedShortSyntax', `"-${token.slice(1)}" is not supported. Use "-${token[1]} ${token.slice(3)}" instead`, this.#getCommandPath());
884
+ }
885
+ const flags = token.slice(1);
886
+ let j = 0;
887
+ const consumedFlags = [];
888
+ const unconsumedFlags = [];
889
+ let nextIdx = idx + 1;
890
+ while (j < flags.length) {
891
+ const flag = flags[j];
892
+ const opt = optionByShort.get(flag);
893
+ if (!opt) {
894
+ unconsumedFlags.push(...flags.slice(j).split(''));
895
+ break;
896
+ }
897
+ consumedFlags.push(flag);
898
+ if (opt.type === 'boolean') {
899
+ opts[opt.long] = true;
900
+ j += 1;
901
+ continue;
902
+ }
903
+ if (j < flags.length - 1) {
904
+ throw new CommanderError('UnsupportedShortSyntax', `"-${flags}" is not supported. Use "-${flags.slice(0, j + 1)} ${flags.slice(j + 1)}" or separate options`, this.#getCommandPath());
905
+ }
906
+ if (idx + 1 < tokens.length && !tokens[idx + 1].startsWith('-')) {
907
+ const value = tokens[idx + 1];
908
+ this.#applyValue(opt, value, opts);
909
+ nextIdx = idx + 2;
910
+ }
911
+ else {
912
+ throw new CommanderError('MissingValue', `option "-${flag}" requires a value`, this.#getCommandPath());
913
+ }
914
+ j += 1;
915
+ }
916
+ if (consumedFlags.length > 0) {
917
+ const remainingToken = unconsumedFlags.length > 0 ? `-${unconsumedFlags.join('')}` : undefined;
918
+ return { consumed: true, nextIdx, remainingToken };
919
+ }
920
+ return { consumed: false, nextIdx: idx + 1 };
921
+ }
647
922
  }
648
923
 
649
924
  class CompletionCommand extends Command {
package/lib/esm/index.mjs CHANGED
@@ -104,34 +104,34 @@ class Command {
104
104
  const { argv, envs, reporter } = params;
105
105
  try {
106
106
  const processedArgv = this.#processHelpSubcommand(argv);
107
- const { command, remaining } = this.#route(processedArgv);
108
- const allOptions = command.#getMergedOptions();
109
- const hasUserHelp = allOptions.some(o => o.long === 'help' && !command.#isBuiltinOption(o));
110
- const hasUserVersion = allOptions.some(o => o.long === 'version' && !command.#isBuiltinOption(o));
111
- if (!hasUserHelp && command.#hasHelpFlag(remaining, allOptions)) {
112
- console.log(command.formatHelp());
107
+ const { chain, remaining } = this.#routeChain(processedArgv);
108
+ const leafCommand = chain[chain.length - 1];
109
+ const { optionTokens, restArgs } = this.#splitAtDoubleDash(remaining);
110
+ const leafOptions = leafCommand.#getMergedOptions();
111
+ const hasUserHelp = leafCommand.#options.some(o => o.long === 'help');
112
+ const hasUserVersion = leafCommand.#options.some(o => o.long === 'version');
113
+ if (!hasUserHelp && leafCommand.#hasHelpFlag(optionTokens, leafOptions)) {
114
+ console.log(leafCommand.formatHelp());
113
115
  return;
114
116
  }
115
- if (!hasUserVersion && command.#hasVersionFlag(remaining, allOptions)) {
116
- console.log(command.version ?? 'unknown');
117
+ if (!hasUserVersion && leafCommand.#hasVersionFlag(optionTokens, leafOptions)) {
118
+ console.log(leafCommand.version ?? 'unknown');
117
119
  return;
118
120
  }
119
- const { opts, args } = command.parse(remaining);
121
+ const optsMap = this.#shiftChain(chain, optionTokens);
120
122
  const ctx = {
121
- cmd: command,
123
+ cmd: leafCommand,
122
124
  envs,
123
125
  reporter: reporter ?? new DefaultReporter(),
124
126
  argv,
125
127
  };
126
- for (const opt of allOptions) {
127
- if (opt.apply && opts[opt.long] !== undefined) {
128
- opt.apply(opts[opt.long], ctx);
129
- }
130
- }
131
- const actionParams = { ctx, opts, args };
132
- if (command.#action) {
128
+ this.#applyChain(chain, optsMap, ctx);
129
+ const mergedOpts = this.#mergeOpts(chain, optsMap);
130
+ const { args, rawArgs } = leafCommand.#parseArguments(restArgs);
131
+ const actionParams = { ctx, opts: mergedOpts, args, rawArgs };
132
+ if (leafCommand.#action) {
133
133
  try {
134
- await command.#action(actionParams);
134
+ await leafCommand.#action(actionParams);
135
135
  }
136
136
  catch (err) {
137
137
  if (err instanceof Error) {
@@ -143,11 +143,11 @@ class Command {
143
143
  process.exit(1);
144
144
  }
145
145
  }
146
- else if (command.#subcommands.length > 0) {
147
- console.log(command.formatHelp());
146
+ else if (leafCommand.#subcommands.length > 0) {
147
+ console.log(leafCommand.formatHelp());
148
148
  }
149
149
  else {
150
- throw new CommanderError('ConfigurationError', `no action defined for command "${command.#getCommandPath()}"`, command.#getCommandPath());
150
+ throw new CommanderError('ConfigurationError', `no action defined for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
151
151
  }
152
152
  }
153
153
  catch (err) {
@@ -162,7 +162,7 @@ class Command {
162
162
  parse(argv) {
163
163
  const allOptions = this.#getMergedOptions();
164
164
  const opts = {};
165
- const args = [];
165
+ const rawArgs = [];
166
166
  for (const opt of allOptions) {
167
167
  if (opt.default !== undefined) {
168
168
  opts[opt.long] = opt.default;
@@ -187,7 +187,7 @@ class Command {
187
187
  while (i < remaining.length) {
188
188
  const token = remaining[i];
189
189
  if (token === '--') {
190
- args.push(...remaining.slice(i + 1));
190
+ rawArgs.push(...remaining.slice(i + 1));
191
191
  break;
192
192
  }
193
193
  if (token.startsWith('--')) {
@@ -198,7 +198,7 @@ class Command {
198
198
  i = this.#parseShortOption(remaining, i, optionByShort, opts);
199
199
  continue;
200
200
  }
201
- args.push(token);
201
+ rawArgs.push(token);
202
202
  i += 1;
203
203
  }
204
204
  for (const opt of allOptions) {
@@ -218,12 +218,84 @@ class Command {
218
218
  }
219
219
  }
220
220
  }
221
- const requiredArgs = this.#arguments.filter(a => a.kind === 'required');
222
- if (args.length < requiredArgs.length) {
223
- const missing = requiredArgs.slice(args.length).map(a => a.name);
224
- throw new CommanderError('MissingRequiredArgument', `missing required argument(s): ${missing.join(', ')}`, this.#getCommandPath());
221
+ const { args } = this.#parseArguments(rawArgs);
222
+ return { opts, args, rawArgs };
223
+ }
224
+ shift(tokens) {
225
+ return this.#shiftWithShadowed(tokens, new Set());
226
+ }
227
+ #shiftWithShadowed(tokens, shadowed) {
228
+ const allDirectOptions = this.#getMergedOptions();
229
+ const directOptions = allDirectOptions.filter(o => !shadowed.has(o.long));
230
+ const opts = {};
231
+ for (const opt of directOptions) {
232
+ if (opt.default !== undefined) {
233
+ opts[opt.long] = opt.default;
234
+ }
235
+ else if (opt.type === 'boolean') {
236
+ opts[opt.long] = false;
237
+ }
238
+ else if (opt.type === 'string[]' || opt.type === 'number[]') {
239
+ opts[opt.long] = [];
240
+ }
241
+ }
242
+ let remaining = [...tokens];
243
+ const resolverOptions = directOptions.filter(o => o.resolver);
244
+ for (const opt of resolverOptions) {
245
+ const result = opt.resolver(remaining);
246
+ opts[opt.long] = result.value;
247
+ remaining = result.remaining;
248
+ }
249
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(directOptions, true);
250
+ const normalizedTokens = this.#normalizeArgv(remaining, booleanOptions);
251
+ const finalRemaining = [];
252
+ let i = 0;
253
+ while (i < normalizedTokens.length) {
254
+ const token = normalizedTokens[i];
255
+ if (token.startsWith('--')) {
256
+ const consumed = this.#tryConsumeLongOption(normalizedTokens, i, optionByLong, opts);
257
+ if (consumed > 0) {
258
+ i += consumed;
259
+ continue;
260
+ }
261
+ finalRemaining.push(token);
262
+ i += 1;
263
+ continue;
264
+ }
265
+ if (token.startsWith('-') && token.length > 1) {
266
+ const result = this.#tryConsumeShortOption(normalizedTokens, i, optionByShort, opts);
267
+ if (result.consumed) {
268
+ i = result.nextIdx;
269
+ if (result.remainingToken) {
270
+ finalRemaining.push(result.remainingToken);
271
+ }
272
+ continue;
273
+ }
274
+ finalRemaining.push(token);
275
+ i += 1;
276
+ continue;
277
+ }
278
+ finalRemaining.push(token);
279
+ i += 1;
280
+ }
281
+ for (const opt of directOptions) {
282
+ if (opt.required && opts[opt.long] === undefined) {
283
+ throw new CommanderError('MissingRequired', `missing required option "--${opt.long}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
284
+ }
225
285
  }
226
- return { opts, args };
286
+ for (const opt of directOptions) {
287
+ if (opt.choices && opts[opt.long] !== undefined) {
288
+ const value = opts[opt.long];
289
+ const values = Array.isArray(value) ? value : [value];
290
+ const choices = opt.choices;
291
+ for (const v of values) {
292
+ if (!choices.includes(v)) {
293
+ throw new CommanderError('InvalidChoice', `invalid value "${v}" for option "--${opt.long}". Allowed: ${opt.choices.join(', ')}`, this.#getCommandPath());
294
+ }
295
+ }
296
+ }
297
+ }
298
+ return { opts, remaining: finalRemaining };
227
299
  }
228
300
  formatHelp() {
229
301
  const lines = [];
@@ -347,7 +419,8 @@ class Command {
347
419
  }
348
420
  return argv;
349
421
  }
350
- #route(argv) {
422
+ #routeChain(argv) {
423
+ const chain = [this];
351
424
  let current = this;
352
425
  let idx = 0;
353
426
  while (idx < argv.length) {
@@ -358,9 +431,62 @@ class Command {
358
431
  if (!entry)
359
432
  break;
360
433
  current = entry.command;
434
+ chain.push(current);
361
435
  idx += 1;
362
436
  }
363
- return { command: current, remaining: argv.slice(idx) };
437
+ return { chain, remaining: argv.slice(idx) };
438
+ }
439
+ #splitAtDoubleDash(tokens) {
440
+ const ddIdx = tokens.indexOf('--');
441
+ if (ddIdx === -1) {
442
+ return { optionTokens: tokens, restArgs: [] };
443
+ }
444
+ return {
445
+ optionTokens: tokens.slice(0, ddIdx),
446
+ restArgs: tokens.slice(ddIdx + 1),
447
+ };
448
+ }
449
+ #shiftChain(chain, tokens) {
450
+ const optsMap = new Map();
451
+ let remaining = [...tokens];
452
+ const shadowed = new Set();
453
+ for (let i = chain.length - 1; i >= 0; i--) {
454
+ const cmd = chain[i];
455
+ const result = cmd.#shiftWithShadowed(remaining, shadowed);
456
+ optsMap.set(cmd, result.opts);
457
+ remaining = result.remaining;
458
+ for (const opt of cmd.#options) {
459
+ shadowed.add(opt.long);
460
+ }
461
+ }
462
+ if (remaining.length > 0) {
463
+ const leafCommand = chain[chain.length - 1];
464
+ const firstToken = remaining[0];
465
+ if (firstToken.startsWith('-')) {
466
+ throw new CommanderError('UnknownOption', `unknown option "${firstToken}" for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
467
+ }
468
+ else {
469
+ throw new CommanderError('UnexpectedArgument', `unexpected argument "${firstToken}". Positional arguments must come after "--"`, leafCommand.#getCommandPath());
470
+ }
471
+ }
472
+ return optsMap;
473
+ }
474
+ #applyChain(chain, optsMap, ctx) {
475
+ for (const cmd of chain) {
476
+ const opts = optsMap.get(cmd) ?? {};
477
+ for (const opt of cmd.#getMergedOptions()) {
478
+ if (opt.apply && opts[opt.long] !== undefined) {
479
+ opt.apply(opts[opt.long], ctx);
480
+ }
481
+ }
482
+ }
483
+ }
484
+ #mergeOpts(chain, optsMap) {
485
+ const merged = {};
486
+ for (const cmd of chain) {
487
+ Object.assign(merged, optsMap.get(cmd) ?? {});
488
+ }
489
+ return merged;
364
490
  }
365
491
  #parseLongOption(argv, idx, optionByLong, opts) {
366
492
  const token = argv[idx];
@@ -505,6 +631,9 @@ class Command {
505
631
  }
506
632
  }
507
633
  #validateArgumentConfig(arg) {
634
+ if (arg.kind === 'required' && arg.default !== undefined) {
635
+ throw new CommanderError('ConfigurationError', `required argument "${arg.name}" cannot have a default value`, this.#getCommandPath());
636
+ }
508
637
  if (arg.kind === 'variadic') {
509
638
  if (this.#arguments.some(a => a.kind === 'variadic')) {
510
639
  throw new CommanderError('ConfigurationError', 'only one variadic argument is allowed', this.#getCommandPath());
@@ -523,8 +652,60 @@ class Command {
523
652
  }
524
653
  }
525
654
  }
526
- #isBuiltinOption(opt) {
527
- return opt === BUILTIN_HELP_OPTION || opt === BUILTIN_VERSION_OPTION;
655
+ #parseArguments(rawArgs) {
656
+ const argumentDefs = this.#arguments;
657
+ const args = {};
658
+ const requiredCount = argumentDefs.filter(a => a.kind === 'required').length;
659
+ if (rawArgs.length < requiredCount) {
660
+ const missing = argumentDefs
661
+ .filter(a => a.kind === 'required')
662
+ .slice(rawArgs.length)
663
+ .map(a => a.name);
664
+ throw new CommanderError('MissingRequiredArgument', `missing required argument(s): ${missing.join(', ')}`, this.#getCommandPath());
665
+ }
666
+ let index = 0;
667
+ for (const def of argumentDefs) {
668
+ if (def.kind === 'variadic') {
669
+ const rest = rawArgs.slice(index);
670
+ args[def.name] = rest.map(raw => this.#convertArgument(def, raw));
671
+ index = rawArgs.length;
672
+ break;
673
+ }
674
+ const raw = rawArgs[index];
675
+ if (raw === undefined) {
676
+ if (def.kind === 'optional') {
677
+ args[def.name] = def.default ?? undefined;
678
+ continue;
679
+ }
680
+ }
681
+ else {
682
+ args[def.name] = this.#convertArgument(def, raw);
683
+ index += 1;
684
+ }
685
+ }
686
+ const hasVariadic = argumentDefs.some(a => a.kind === 'variadic');
687
+ if (!hasVariadic && index < rawArgs.length) {
688
+ throw new CommanderError('TooManyArguments', `too many arguments: expected ${argumentDefs.length}, got ${rawArgs.length}`, this.#getCommandPath());
689
+ }
690
+ return { args, rawArgs };
691
+ }
692
+ #convertArgument(def, raw) {
693
+ if (def.coerce) {
694
+ try {
695
+ return def.coerce(raw);
696
+ }
697
+ catch {
698
+ throw new CommanderError('InvalidType', `invalid value "${raw}" for argument "${def.name}"`, this.#getCommandPath());
699
+ }
700
+ }
701
+ if (def.type === 'number') {
702
+ const n = Number(raw);
703
+ if (Number.isNaN(n)) {
704
+ throw new CommanderError('InvalidType', `invalid number "${raw}" for argument "${def.name}"`, this.#getCommandPath());
705
+ }
706
+ return n;
707
+ }
708
+ return raw;
528
709
  }
529
710
  #buildOptionMaps(allOptions, excludeResolver = false) {
530
711
  const optionByLong = new Map();
@@ -622,6 +803,100 @@ class Command {
622
803
  #getCommandPath() {
623
804
  return this.#name;
624
805
  }
806
+ #tryConsumeLongOption(tokens, idx, optionByLong, opts) {
807
+ const token = tokens[idx];
808
+ const eqIdx = token.indexOf('=');
809
+ let optName;
810
+ let inlineValue;
811
+ if (eqIdx !== -1) {
812
+ optName = token.slice(2, eqIdx);
813
+ inlineValue = token.slice(eqIdx + 1);
814
+ }
815
+ else {
816
+ optName = token.slice(2);
817
+ }
818
+ const opt = optionByLong.get(optName);
819
+ if (!opt) {
820
+ return 0;
821
+ }
822
+ if (opt.type === 'boolean') {
823
+ if (inlineValue !== undefined) {
824
+ if (inlineValue === 'true') {
825
+ opts[optName] = true;
826
+ }
827
+ else if (inlineValue === 'false') {
828
+ opts[optName] = false;
829
+ }
830
+ else {
831
+ throw new CommanderError('InvalidBooleanValue', `invalid value "${inlineValue}" for boolean option "--${optName}". Use "true" or "false"`, this.#getCommandPath());
832
+ }
833
+ }
834
+ else {
835
+ opts[optName] = true;
836
+ }
837
+ return 1;
838
+ }
839
+ let value;
840
+ let consumed = 1;
841
+ if (inlineValue !== undefined) {
842
+ value = inlineValue;
843
+ }
844
+ else if (idx + 1 < tokens.length) {
845
+ value = tokens[idx + 1];
846
+ consumed = 2;
847
+ }
848
+ else {
849
+ throw new CommanderError('MissingValue', `option "--${optName}" requires a value`, this.#getCommandPath());
850
+ }
851
+ this.#applyValue(opt, value, opts);
852
+ return consumed;
853
+ }
854
+ #tryConsumeShortOption(tokens, idx, optionByShort, opts) {
855
+ const token = tokens[idx];
856
+ if (token.includes('=')) {
857
+ const firstFlag = token[1];
858
+ if (!optionByShort.has(firstFlag)) {
859
+ return { consumed: false, nextIdx: idx + 1 };
860
+ }
861
+ throw new CommanderError('UnsupportedShortSyntax', `"-${token.slice(1)}" is not supported. Use "-${token[1]} ${token.slice(3)}" instead`, this.#getCommandPath());
862
+ }
863
+ const flags = token.slice(1);
864
+ let j = 0;
865
+ const consumedFlags = [];
866
+ const unconsumedFlags = [];
867
+ let nextIdx = idx + 1;
868
+ while (j < flags.length) {
869
+ const flag = flags[j];
870
+ const opt = optionByShort.get(flag);
871
+ if (!opt) {
872
+ unconsumedFlags.push(...flags.slice(j).split(''));
873
+ break;
874
+ }
875
+ consumedFlags.push(flag);
876
+ if (opt.type === 'boolean') {
877
+ opts[opt.long] = true;
878
+ j += 1;
879
+ continue;
880
+ }
881
+ if (j < flags.length - 1) {
882
+ throw new CommanderError('UnsupportedShortSyntax', `"-${flags}" is not supported. Use "-${flags.slice(0, j + 1)} ${flags.slice(j + 1)}" or separate options`, this.#getCommandPath());
883
+ }
884
+ if (idx + 1 < tokens.length && !tokens[idx + 1].startsWith('-')) {
885
+ const value = tokens[idx + 1];
886
+ this.#applyValue(opt, value, opts);
887
+ nextIdx = idx + 2;
888
+ }
889
+ else {
890
+ throw new CommanderError('MissingValue', `option "-${flag}" requires a value`, this.#getCommandPath());
891
+ }
892
+ j += 1;
893
+ }
894
+ if (consumedFlags.length > 0) {
895
+ const remainingToken = unconsumedFlags.length > 0 ? `-${unconsumedFlags.join('')}` : undefined;
896
+ return { consumed: true, nextIdx, remainingToken };
897
+ }
898
+ return { consumed: false, nextIdx: idx + 1 };
899
+ }
625
900
  }
626
901
 
627
902
  class CompletionCommand extends Command {
@@ -46,14 +46,25 @@ interface IOption<T = unknown> {
46
46
  }
47
47
  /** Argument kind */
48
48
  type IArgumentKind = 'required' | 'optional' | 'variadic';
49
- /** Positional argument definition */
50
- interface IArgument {
49
+ /** Argument value type */
50
+ type IArgumentType = 'string' | 'number';
51
+ /**
52
+ * Positional argument definition.
53
+ * @template T - The type of the argument value
54
+ */
55
+ interface IArgument<T = unknown> {
51
56
  /** Argument name */
52
57
  name: string;
53
58
  /** Argument description */
54
59
  description: string;
55
60
  /** Argument kind: required / optional / variadic */
56
61
  kind: IArgumentKind;
62
+ /** Value type, defaults to 'string' */
63
+ type?: IArgumentType;
64
+ /** Default value when not provided (only effective for optional arguments) */
65
+ default?: T;
66
+ /** Custom value transformation (takes precedence over type conversion) */
67
+ coerce?: (rawValue: string) => T;
57
68
  }
58
69
  /** Command configuration */
59
70
  interface ICommandConfig {
@@ -91,8 +102,10 @@ interface IActionParams {
91
102
  ctx: ICommandContext;
92
103
  /** Parsed options */
93
104
  opts: Record<string, unknown>;
94
- /** Parsed positional arguments */
95
- args: string[];
105
+ /** Parsed positional arguments (keyed by argument name) */
106
+ args: Record<string, unknown>;
107
+ /** Raw positional argument strings (before type conversion) */
108
+ rawArgs: string[];
96
109
  }
97
110
  /** Action handler function */
98
111
  type IAction = (params: IActionParams) => void | Promise<void>;
@@ -109,11 +122,20 @@ interface IRunParams {
109
122
  interface IParseResult {
110
123
  /** Parsed options */
111
124
  opts: Record<string, unknown>;
112
- /** Parsed positional arguments */
113
- args: string[];
125
+ /** Parsed positional arguments (keyed by argument name) */
126
+ args: Record<string, unknown>;
127
+ /** Raw positional argument strings (before type conversion) */
128
+ rawArgs: string[];
129
+ }
130
+ /** shift() method result */
131
+ interface IShiftResult {
132
+ /** Options consumed by this command */
133
+ opts: Record<string, unknown>;
134
+ /** Tokens not consumed, to be passed to parent */
135
+ remaining: string[];
114
136
  }
115
137
  /** Error kinds for command parsing */
116
- type ICommanderErrorKind = 'UnknownOption' | 'MissingValue' | 'InvalidType' | 'UnsupportedShortSyntax' | 'OptionConflict' | 'MissingRequired' | 'InvalidChoice' | 'InvalidBooleanValue' | 'MissingRequiredArgument' | 'ConfigurationError';
138
+ type ICommanderErrorKind = 'UnknownOption' | 'UnexpectedArgument' | 'MissingValue' | 'InvalidType' | 'UnsupportedShortSyntax' | 'OptionConflict' | 'MissingRequired' | 'InvalidChoice' | 'InvalidBooleanValue' | 'MissingRequiredArgument' | 'TooManyArguments' | 'ConfigurationError';
117
139
  /** Commander error with structured information */
118
140
  declare class CommanderError extends Error {
119
141
  readonly kind: ICommanderErrorKind;
@@ -177,6 +199,11 @@ declare class Command implements ICommand {
177
199
  subcommand(name: string, cmd: Command): this;
178
200
  run(params: IRunParams): Promise<void>;
179
201
  parse(argv: string[]): IParseResult;
202
+ /**
203
+ * Shift options from tokens that this command recognizes.
204
+ * Unrecognized tokens are returned in `remaining` for parent commands.
205
+ */
206
+ shift(tokens: string[]): IShiftResult;
180
207
  formatHelp(): string;
181
208
  getCompletionMeta(): ICompletionMeta;
182
209
  }
@@ -227,4 +254,4 @@ declare class PwshCompletion {
227
254
  }
228
255
 
229
256
  export { BashCompletion, Command, CommanderError, CompletionCommand, FishCompletion, PwshCompletion };
230
- export type { IAction, IActionParams, IArgument, IArgumentKind, ICommand, ICommandConfig, ICommandContext, ICommanderErrorKind, ICompletionCommandConfig, ICompletionMeta, ICompletionOptionMeta, ICompletionPaths, IOption, IOptionType, IParseResult, IReporter, IRunParams, IShellType };
257
+ export type { IAction, IActionParams, IArgument, IArgumentKind, ICommand, ICommandConfig, ICommandContext, ICommanderErrorKind, ICompletionCommandConfig, ICompletionMeta, ICompletionOptionMeta, ICompletionPaths, IOption, IOptionType, IParseResult, IReporter, IRunParams, IShellType, IShiftResult };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanghechen/commander",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "A minimal, type-safe command-line interface builder with fluent API",
5
5
  "author": {
6
6
  "name": "guanghechen",