@guanghechen/commander 2.1.0 → 3.1.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,9 +1,24 @@
1
1
  # Change Log
2
2
 
3
+ ## 3.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Implement option bubbling with shift/apply flow
8
+ - Add `shift()` method for bottom-up option consumption (leaf → root)
9
+ - Refactor `run()` with new flow: route → split → shift → apply → action
10
+ - Add `UnexpectedArgument` error type for positional args before `--`
11
+
3
12
  All notable changes to this project will be documented in this file. See
4
13
  [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
14
 
6
- ## 2.1.0 (2025-02-08)
15
+ ## 3.0.0 (2026-02-08)
16
+
17
+ ### BREAKING CHANGES
18
+
19
+ - Change subcommand registration API to `subcommand(name, cmd)`
20
+
21
+ ## 2.1.0 (2026-02-08)
7
22
 
8
23
  ### Features
9
24
 
@@ -30,4 +45,3 @@ All notable changes to this project will be documented in this file. See
30
45
  ### Features
31
46
 
32
47
  - Initial stable release: A minimal, type-safe command-line interface builder with fluent API
33
-
package/lib/cjs/index.cjs CHANGED
@@ -67,34 +67,25 @@ class Command {
67
67
  #name;
68
68
  #description;
69
69
  #version;
70
- #aliases;
71
70
  #helpSubcommandEnabled;
72
71
  #options = [];
73
72
  #arguments = [];
74
73
  #subcommands = [];
75
74
  #action;
76
- #parent;
77
75
  constructor(config) {
78
- this.#name = config.name;
76
+ this.#name = config.name ?? '';
79
77
  this.#description = config.description;
80
78
  this.#version = config.version;
81
- this.#aliases = config.aliases ?? [];
82
79
  this.#helpSubcommandEnabled = config.help ?? false;
83
80
  }
84
81
  get name() {
85
82
  return this.#name;
86
83
  }
87
- get aliases() {
88
- return this.#aliases;
89
- }
90
84
  get description() {
91
85
  return this.#description;
92
86
  }
93
87
  get version() {
94
- return this.#version ?? this.#parent?.version;
95
- }
96
- get parent() {
97
- return this.#parent;
88
+ return this.#version;
98
89
  }
99
90
  get options() {
100
91
  return [...this.#options];
@@ -117,46 +108,57 @@ class Command {
117
108
  this.#action = fn;
118
109
  return this;
119
110
  }
120
- subcommand(cmd) {
121
- if (this.#helpSubcommandEnabled && (cmd.#name === 'help' || cmd.#aliases.includes('help'))) {
111
+ subcommand(name, cmd) {
112
+ if (this.#helpSubcommandEnabled && name === 'help') {
122
113
  throw new CommanderError('ConfigurationError', '"help" is a reserved subcommand name when help subcommand is enabled', this.#getCommandPath());
123
114
  }
124
- cmd.#parent = this;
125
- this.#subcommands.push(cmd);
115
+ const existing = this.#subcommands.find(e => e.command === cmd);
116
+ if (existing) {
117
+ existing.aliases.push(name);
118
+ }
119
+ else {
120
+ cmd.#name = name;
121
+ this.#subcommands.push({ name, aliases: [], command: cmd });
122
+ }
126
123
  return this;
127
124
  }
128
125
  async run(params) {
129
126
  const { argv, envs, reporter } = params;
130
127
  try {
131
128
  const processedArgv = this.#processHelpSubcommand(argv);
132
- const { command, remaining } = this.#route(processedArgv);
133
- const allOptions = command.#getMergedOptions();
134
- const hasUserHelp = allOptions.some(o => o.long === 'help' && !command.#isBuiltinOption(o));
135
- const hasUserVersion = allOptions.some(o => o.long === 'version' && !command.#isBuiltinOption(o));
136
- if (!hasUserHelp && command.#hasHelpFlag(remaining, allOptions)) {
137
- 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());
138
137
  return;
139
138
  }
140
- if (!hasUserVersion && command.#hasVersionFlag(remaining, allOptions)) {
141
- console.log(command.version ?? 'unknown');
139
+ if (!hasUserVersion && leafCommand.#hasVersionFlag(optionTokens, leafOptions)) {
140
+ console.log(leafCommand.version ?? 'unknown');
142
141
  return;
143
142
  }
144
- const { opts, args } = command.parse(remaining);
143
+ const optsMap = this.#shiftChain(chain, optionTokens);
145
144
  const ctx = {
146
- cmd: command,
145
+ cmd: leafCommand,
147
146
  envs,
148
147
  reporter: reporter ?? new DefaultReporter(),
149
148
  argv,
150
149
  };
151
- for (const opt of allOptions) {
152
- if (opt.apply && opts[opt.long] !== undefined) {
153
- opt.apply(opts[opt.long], ctx);
154
- }
155
- }
156
- const actionParams = { ctx, opts, args };
157
- if (command.#action) {
150
+ this.#applyChain(chain, optsMap, ctx);
151
+ const mergedOpts = this.#mergeOpts(chain, optsMap);
152
+ const args = restArgs;
153
+ const requiredArgs = leafCommand.#arguments.filter(a => a.kind === 'required');
154
+ if (args.length < requiredArgs.length) {
155
+ const missing = requiredArgs.slice(args.length).map(a => a.name);
156
+ throw new CommanderError('MissingRequiredArgument', `missing required argument(s): ${missing.join(', ')}`, leafCommand.#getCommandPath());
157
+ }
158
+ const actionParams = { ctx, opts: mergedOpts, args };
159
+ if (leafCommand.#action) {
158
160
  try {
159
- await command.#action(actionParams);
161
+ await leafCommand.#action(actionParams);
160
162
  }
161
163
  catch (err) {
162
164
  if (err instanceof Error) {
@@ -168,11 +170,11 @@ class Command {
168
170
  process.exit(1);
169
171
  }
170
172
  }
171
- else if (command.#subcommands.length > 0) {
172
- console.log(command.formatHelp());
173
+ else if (leafCommand.#subcommands.length > 0) {
174
+ console.log(leafCommand.formatHelp());
173
175
  }
174
176
  else {
175
- throw new CommanderError('ConfigurationError', `no action defined for command "${command.#getCommandPath()}"`, command.#getCommandPath());
177
+ throw new CommanderError('ConfigurationError', `no action defined for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
176
178
  }
177
179
  }
178
180
  catch (err) {
@@ -250,6 +252,82 @@ class Command {
250
252
  }
251
253
  return { opts, args };
252
254
  }
255
+ shift(tokens) {
256
+ return this.#shiftWithShadowed(tokens, new Set());
257
+ }
258
+ #shiftWithShadowed(tokens, shadowed) {
259
+ const allDirectOptions = this.#getMergedOptions();
260
+ const directOptions = allDirectOptions.filter(o => !shadowed.has(o.long));
261
+ const opts = {};
262
+ for (const opt of directOptions) {
263
+ if (opt.default !== undefined) {
264
+ opts[opt.long] = opt.default;
265
+ }
266
+ else if (opt.type === 'boolean') {
267
+ opts[opt.long] = false;
268
+ }
269
+ else if (opt.type === 'string[]' || opt.type === 'number[]') {
270
+ opts[opt.long] = [];
271
+ }
272
+ }
273
+ let remaining = [...tokens];
274
+ const resolverOptions = directOptions.filter(o => o.resolver);
275
+ for (const opt of resolverOptions) {
276
+ const result = opt.resolver(remaining);
277
+ opts[opt.long] = result.value;
278
+ remaining = result.remaining;
279
+ }
280
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(directOptions, true);
281
+ const normalizedTokens = this.#normalizeArgv(remaining, booleanOptions);
282
+ const finalRemaining = [];
283
+ let i = 0;
284
+ while (i < normalizedTokens.length) {
285
+ const token = normalizedTokens[i];
286
+ if (token.startsWith('--')) {
287
+ const consumed = this.#tryConsumeLongOption(normalizedTokens, i, optionByLong, opts);
288
+ if (consumed > 0) {
289
+ i += consumed;
290
+ continue;
291
+ }
292
+ finalRemaining.push(token);
293
+ i += 1;
294
+ continue;
295
+ }
296
+ if (token.startsWith('-') && token.length > 1) {
297
+ const result = this.#tryConsumeShortOption(normalizedTokens, i, optionByShort, opts);
298
+ if (result.consumed) {
299
+ i = result.nextIdx;
300
+ if (result.remainingToken) {
301
+ finalRemaining.push(result.remainingToken);
302
+ }
303
+ continue;
304
+ }
305
+ finalRemaining.push(token);
306
+ i += 1;
307
+ continue;
308
+ }
309
+ finalRemaining.push(token);
310
+ i += 1;
311
+ }
312
+ for (const opt of directOptions) {
313
+ if (opt.required && opts[opt.long] === undefined) {
314
+ throw new CommanderError('MissingRequired', `missing required option "--${opt.long}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
315
+ }
316
+ }
317
+ for (const opt of directOptions) {
318
+ if (opt.choices && opts[opt.long] !== undefined) {
319
+ const value = opts[opt.long];
320
+ const values = Array.isArray(value) ? value : [value];
321
+ const choices = opt.choices;
322
+ for (const v of values) {
323
+ if (!choices.includes(v)) {
324
+ throw new CommanderError('InvalidChoice', `invalid value "${v}" for option "--${opt.long}". Allowed: ${opt.choices.join(', ')}`, this.#getCommandPath());
325
+ }
326
+ }
327
+ }
328
+ }
329
+ return { opts, remaining: finalRemaining };
330
+ }
253
331
  formatHelp() {
254
332
  const lines = [];
255
333
  const allOptions = this.#getMergedOptions();
@@ -313,12 +391,12 @@ class Command {
313
391
  if (showHelpSubcommand) {
314
392
  cmdLines.push({ name: 'help', desc: 'Show help for a command' });
315
393
  }
316
- for (const sub of this.#subcommands) {
317
- let name = sub.#name;
318
- if (sub.#aliases.length > 0) {
319
- name += `, ${sub.#aliases.join(', ')}`;
394
+ for (const entry of this.#subcommands) {
395
+ let name = entry.name;
396
+ if (entry.aliases.length > 0) {
397
+ name += `, ${entry.aliases.join(', ')}`;
320
398
  }
321
- cmdLines.push({ name, desc: sub.#description });
399
+ cmdLines.push({ name, desc: entry.command.#description });
322
400
  }
323
401
  const maxNameLen = Math.max(...cmdLines.map(l => l.name.length));
324
402
  for (const { name, desc } of cmdLines) {
@@ -345,9 +423,16 @@ class Command {
345
423
  return {
346
424
  name: this.#name,
347
425
  description: this.#description,
348
- aliases: this.#aliases,
426
+ aliases: [],
349
427
  options,
350
- subcommands: this.#subcommands.map(sub => sub.getCompletionMeta()),
428
+ subcommands: this.#subcommands.map(entry => {
429
+ const subMeta = entry.command.getCompletionMeta();
430
+ return {
431
+ ...subMeta,
432
+ name: entry.name,
433
+ aliases: entry.aliases,
434
+ };
435
+ }),
351
436
  };
352
437
  }
353
438
  #processHelpSubcommand(argv) {
@@ -359,26 +444,80 @@ class Command {
359
444
  return ['--help'];
360
445
  }
361
446
  const subName = argv[1];
362
- const sub = this.#subcommands.find(c => c.#name === subName || c.#aliases.includes(subName));
363
- if (sub) {
447
+ const entry = this.#subcommands.find(e => e.name === subName || e.aliases.includes(subName));
448
+ if (entry) {
364
449
  return [subName, '--help', ...argv.slice(2)];
365
450
  }
366
451
  return argv;
367
452
  }
368
- #route(argv) {
453
+ #routeChain(argv) {
454
+ const chain = [this];
369
455
  let current = this;
370
456
  let idx = 0;
371
457
  while (idx < argv.length) {
372
458
  const token = argv[idx];
373
459
  if (token.startsWith('-'))
374
460
  break;
375
- const sub = current.#subcommands.find(c => c.#name === token || c.#aliases.includes(token));
376
- if (!sub)
461
+ const entry = current.#subcommands.find(e => e.name === token || e.aliases.includes(token));
462
+ if (!entry)
377
463
  break;
378
- current = sub;
464
+ current = entry.command;
465
+ chain.push(current);
379
466
  idx += 1;
380
467
  }
381
- return { command: current, remaining: argv.slice(idx) };
468
+ return { chain, remaining: argv.slice(idx) };
469
+ }
470
+ #splitAtDoubleDash(tokens) {
471
+ const ddIdx = tokens.indexOf('--');
472
+ if (ddIdx === -1) {
473
+ return { optionTokens: tokens, restArgs: [] };
474
+ }
475
+ return {
476
+ optionTokens: tokens.slice(0, ddIdx),
477
+ restArgs: tokens.slice(ddIdx + 1),
478
+ };
479
+ }
480
+ #shiftChain(chain, tokens) {
481
+ const optsMap = new Map();
482
+ let remaining = [...tokens];
483
+ const shadowed = new Set();
484
+ for (let i = chain.length - 1; i >= 0; i--) {
485
+ const cmd = chain[i];
486
+ const result = cmd.#shiftWithShadowed(remaining, shadowed);
487
+ optsMap.set(cmd, result.opts);
488
+ remaining = result.remaining;
489
+ for (const opt of cmd.#options) {
490
+ shadowed.add(opt.long);
491
+ }
492
+ }
493
+ if (remaining.length > 0) {
494
+ const leafCommand = chain[chain.length - 1];
495
+ const firstToken = remaining[0];
496
+ if (firstToken.startsWith('-')) {
497
+ throw new CommanderError('UnknownOption', `unknown option "${firstToken}" for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
498
+ }
499
+ else {
500
+ throw new CommanderError('UnexpectedArgument', `unexpected argument "${firstToken}". Positional arguments must come after "--"`, leafCommand.#getCommandPath());
501
+ }
502
+ }
503
+ return optsMap;
504
+ }
505
+ #applyChain(chain, optsMap, ctx) {
506
+ for (const cmd of chain) {
507
+ const opts = optsMap.get(cmd) ?? {};
508
+ for (const opt of cmd.#getMergedOptions()) {
509
+ if (opt.apply && opts[opt.long] !== undefined) {
510
+ opt.apply(opts[opt.long], ctx);
511
+ }
512
+ }
513
+ }
514
+ }
515
+ #mergeOpts(chain, optsMap) {
516
+ const merged = {};
517
+ for (const cmd of chain) {
518
+ Object.assign(merged, optsMap.get(cmd) ?? {});
519
+ }
520
+ return merged;
382
521
  }
383
522
  #parseLongOption(argv, idx, optionByLong, opts) {
384
523
  const token = argv[idx];
@@ -489,33 +628,17 @@ class Command {
489
628
  }
490
629
  }
491
630
  #getMergedOptions() {
492
- const ancestors = [];
493
- for (let node = this; node; node = node.#parent) {
494
- ancestors.unshift(node);
495
- }
496
631
  const optionMap = new Map();
497
- const hasUserHelp = ancestors.some(c => c.#options.some(o => o.long === 'help'));
498
- const hasUserVersion = ancestors.some(c => c.#options.some(o => o.long === 'version'));
632
+ const hasUserHelp = this.#options.some(o => o.long === 'help');
633
+ const hasUserVersion = this.#options.some(o => o.long === 'version');
499
634
  if (!hasUserHelp) {
500
635
  optionMap.set('help', BUILTIN_HELP_OPTION);
501
636
  }
502
637
  if (!hasUserVersion) {
503
638
  optionMap.set('version', BUILTIN_VERSION_OPTION);
504
639
  }
505
- for (const ancestor of ancestors) {
506
- for (const opt of ancestor.#options) {
507
- optionMap.set(opt.long, opt);
508
- }
509
- }
510
- const shortToLong = new Map();
511
- for (const [long, opt] of optionMap) {
512
- if (opt.short) {
513
- const existing = shortToLong.get(opt.short);
514
- if (existing && existing !== long) {
515
- throw new CommanderError('OptionConflict', `short option "-${opt.short}" is used by both "--${existing}" and "--${long}"`, this.#getCommandPath());
516
- }
517
- shortToLong.set(opt.short, long);
518
- }
640
+ for (const opt of this.#options) {
641
+ optionMap.set(opt.long, opt);
519
642
  }
520
643
  return Array.from(optionMap.values());
521
644
  }
@@ -557,9 +680,6 @@ class Command {
557
680
  }
558
681
  }
559
682
  }
560
- #isBuiltinOption(opt) {
561
- return opt === BUILTIN_HELP_OPTION || opt === BUILTIN_VERSION_OPTION;
562
- }
563
683
  #buildOptionMaps(allOptions, excludeResolver = false) {
564
684
  const optionByLong = new Map();
565
685
  const optionByShort = new Map();
@@ -654,20 +774,109 @@ class Command {
654
774
  return result;
655
775
  }
656
776
  #getCommandPath() {
657
- const parts = [];
658
- for (let node = this; node; node = node.#parent) {
659
- parts.unshift(node.#name);
777
+ return this.#name;
778
+ }
779
+ #tryConsumeLongOption(tokens, idx, optionByLong, opts) {
780
+ const token = tokens[idx];
781
+ const eqIdx = token.indexOf('=');
782
+ let optName;
783
+ let inlineValue;
784
+ if (eqIdx !== -1) {
785
+ optName = token.slice(2, eqIdx);
786
+ inlineValue = token.slice(eqIdx + 1);
787
+ }
788
+ else {
789
+ optName = token.slice(2);
790
+ }
791
+ const opt = optionByLong.get(optName);
792
+ if (!opt) {
793
+ return 0;
794
+ }
795
+ if (opt.type === 'boolean') {
796
+ if (inlineValue !== undefined) {
797
+ if (inlineValue === 'true') {
798
+ opts[optName] = true;
799
+ }
800
+ else if (inlineValue === 'false') {
801
+ opts[optName] = false;
802
+ }
803
+ else {
804
+ throw new CommanderError('InvalidBooleanValue', `invalid value "${inlineValue}" for boolean option "--${optName}". Use "true" or "false"`, this.#getCommandPath());
805
+ }
806
+ }
807
+ else {
808
+ opts[optName] = true;
809
+ }
810
+ return 1;
811
+ }
812
+ let value;
813
+ let consumed = 1;
814
+ if (inlineValue !== undefined) {
815
+ value = inlineValue;
816
+ }
817
+ else if (idx + 1 < tokens.length) {
818
+ value = tokens[idx + 1];
819
+ consumed = 2;
820
+ }
821
+ else {
822
+ throw new CommanderError('MissingValue', `option "--${optName}" requires a value`, this.#getCommandPath());
823
+ }
824
+ this.#applyValue(opt, value, opts);
825
+ return consumed;
826
+ }
827
+ #tryConsumeShortOption(tokens, idx, optionByShort, opts) {
828
+ const token = tokens[idx];
829
+ if (token.includes('=')) {
830
+ const firstFlag = token[1];
831
+ if (!optionByShort.has(firstFlag)) {
832
+ return { consumed: false, nextIdx: idx + 1 };
833
+ }
834
+ throw new CommanderError('UnsupportedShortSyntax', `"-${token.slice(1)}" is not supported. Use "-${token[1]} ${token.slice(3)}" instead`, this.#getCommandPath());
835
+ }
836
+ const flags = token.slice(1);
837
+ let j = 0;
838
+ const consumedFlags = [];
839
+ const unconsumedFlags = [];
840
+ let nextIdx = idx + 1;
841
+ while (j < flags.length) {
842
+ const flag = flags[j];
843
+ const opt = optionByShort.get(flag);
844
+ if (!opt) {
845
+ unconsumedFlags.push(...flags.slice(j).split(''));
846
+ break;
847
+ }
848
+ consumedFlags.push(flag);
849
+ if (opt.type === 'boolean') {
850
+ opts[opt.long] = true;
851
+ j += 1;
852
+ continue;
853
+ }
854
+ if (j < flags.length - 1) {
855
+ throw new CommanderError('UnsupportedShortSyntax', `"-${flags}" is not supported. Use "-${flags.slice(0, j + 1)} ${flags.slice(j + 1)}" or separate options`, this.#getCommandPath());
856
+ }
857
+ if (idx + 1 < tokens.length && !tokens[idx + 1].startsWith('-')) {
858
+ const value = tokens[idx + 1];
859
+ this.#applyValue(opt, value, opts);
860
+ nextIdx = idx + 2;
861
+ }
862
+ else {
863
+ throw new CommanderError('MissingValue', `option "-${flag}" requires a value`, this.#getCommandPath());
864
+ }
865
+ j += 1;
866
+ }
867
+ if (consumedFlags.length > 0) {
868
+ const remainingToken = unconsumedFlags.length > 0 ? `-${unconsumedFlags.join('')}` : undefined;
869
+ return { consumed: true, nextIdx, remainingToken };
660
870
  }
661
- return parts.join(' ');
871
+ return { consumed: false, nextIdx: idx + 1 };
662
872
  }
663
873
  }
664
874
 
665
875
  class CompletionCommand extends Command {
666
876
  constructor(root, config) {
667
- const name = config.name ?? 'completion';
668
877
  const paths = config.paths;
878
+ const programName = config.programName ?? root.name;
669
879
  super({
670
- name,
671
880
  description: 'Generate shell completion script',
672
881
  });
673
882
  this.option({
@@ -694,7 +903,6 @@ class CompletionCommand extends Command {
694
903
  })
695
904
  .action(({ opts }) => {
696
905
  const meta = root.getCompletionMeta();
697
- const programName = root.name;
698
906
  const selectedShells = [
699
907
  opts['bash'] && 'bash',
700
908
  opts['fish'] && 'fish',
package/lib/esm/index.mjs CHANGED
@@ -45,34 +45,25 @@ class Command {
45
45
  #name;
46
46
  #description;
47
47
  #version;
48
- #aliases;
49
48
  #helpSubcommandEnabled;
50
49
  #options = [];
51
50
  #arguments = [];
52
51
  #subcommands = [];
53
52
  #action;
54
- #parent;
55
53
  constructor(config) {
56
- this.#name = config.name;
54
+ this.#name = config.name ?? '';
57
55
  this.#description = config.description;
58
56
  this.#version = config.version;
59
- this.#aliases = config.aliases ?? [];
60
57
  this.#helpSubcommandEnabled = config.help ?? false;
61
58
  }
62
59
  get name() {
63
60
  return this.#name;
64
61
  }
65
- get aliases() {
66
- return this.#aliases;
67
- }
68
62
  get description() {
69
63
  return this.#description;
70
64
  }
71
65
  get version() {
72
- return this.#version ?? this.#parent?.version;
73
- }
74
- get parent() {
75
- return this.#parent;
66
+ return this.#version;
76
67
  }
77
68
  get options() {
78
69
  return [...this.#options];
@@ -95,46 +86,57 @@ class Command {
95
86
  this.#action = fn;
96
87
  return this;
97
88
  }
98
- subcommand(cmd) {
99
- if (this.#helpSubcommandEnabled && (cmd.#name === 'help' || cmd.#aliases.includes('help'))) {
89
+ subcommand(name, cmd) {
90
+ if (this.#helpSubcommandEnabled && name === 'help') {
100
91
  throw new CommanderError('ConfigurationError', '"help" is a reserved subcommand name when help subcommand is enabled', this.#getCommandPath());
101
92
  }
102
- cmd.#parent = this;
103
- this.#subcommands.push(cmd);
93
+ const existing = this.#subcommands.find(e => e.command === cmd);
94
+ if (existing) {
95
+ existing.aliases.push(name);
96
+ }
97
+ else {
98
+ cmd.#name = name;
99
+ this.#subcommands.push({ name, aliases: [], command: cmd });
100
+ }
104
101
  return this;
105
102
  }
106
103
  async run(params) {
107
104
  const { argv, envs, reporter } = params;
108
105
  try {
109
106
  const processedArgv = this.#processHelpSubcommand(argv);
110
- const { command, remaining } = this.#route(processedArgv);
111
- const allOptions = command.#getMergedOptions();
112
- const hasUserHelp = allOptions.some(o => o.long === 'help' && !command.#isBuiltinOption(o));
113
- const hasUserVersion = allOptions.some(o => o.long === 'version' && !command.#isBuiltinOption(o));
114
- if (!hasUserHelp && command.#hasHelpFlag(remaining, allOptions)) {
115
- 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());
116
115
  return;
117
116
  }
118
- if (!hasUserVersion && command.#hasVersionFlag(remaining, allOptions)) {
119
- console.log(command.version ?? 'unknown');
117
+ if (!hasUserVersion && leafCommand.#hasVersionFlag(optionTokens, leafOptions)) {
118
+ console.log(leafCommand.version ?? 'unknown');
120
119
  return;
121
120
  }
122
- const { opts, args } = command.parse(remaining);
121
+ const optsMap = this.#shiftChain(chain, optionTokens);
123
122
  const ctx = {
124
- cmd: command,
123
+ cmd: leafCommand,
125
124
  envs,
126
125
  reporter: reporter ?? new DefaultReporter(),
127
126
  argv,
128
127
  };
129
- for (const opt of allOptions) {
130
- if (opt.apply && opts[opt.long] !== undefined) {
131
- opt.apply(opts[opt.long], ctx);
132
- }
128
+ this.#applyChain(chain, optsMap, ctx);
129
+ const mergedOpts = this.#mergeOpts(chain, optsMap);
130
+ const args = restArgs;
131
+ const requiredArgs = leafCommand.#arguments.filter(a => a.kind === 'required');
132
+ if (args.length < requiredArgs.length) {
133
+ const missing = requiredArgs.slice(args.length).map(a => a.name);
134
+ throw new CommanderError('MissingRequiredArgument', `missing required argument(s): ${missing.join(', ')}`, leafCommand.#getCommandPath());
133
135
  }
134
- const actionParams = { ctx, opts, args };
135
- if (command.#action) {
136
+ const actionParams = { ctx, opts: mergedOpts, args };
137
+ if (leafCommand.#action) {
136
138
  try {
137
- await command.#action(actionParams);
139
+ await leafCommand.#action(actionParams);
138
140
  }
139
141
  catch (err) {
140
142
  if (err instanceof Error) {
@@ -146,11 +148,11 @@ class Command {
146
148
  process.exit(1);
147
149
  }
148
150
  }
149
- else if (command.#subcommands.length > 0) {
150
- console.log(command.formatHelp());
151
+ else if (leafCommand.#subcommands.length > 0) {
152
+ console.log(leafCommand.formatHelp());
151
153
  }
152
154
  else {
153
- throw new CommanderError('ConfigurationError', `no action defined for command "${command.#getCommandPath()}"`, command.#getCommandPath());
155
+ throw new CommanderError('ConfigurationError', `no action defined for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
154
156
  }
155
157
  }
156
158
  catch (err) {
@@ -228,6 +230,82 @@ class Command {
228
230
  }
229
231
  return { opts, args };
230
232
  }
233
+ shift(tokens) {
234
+ return this.#shiftWithShadowed(tokens, new Set());
235
+ }
236
+ #shiftWithShadowed(tokens, shadowed) {
237
+ const allDirectOptions = this.#getMergedOptions();
238
+ const directOptions = allDirectOptions.filter(o => !shadowed.has(o.long));
239
+ const opts = {};
240
+ for (const opt of directOptions) {
241
+ if (opt.default !== undefined) {
242
+ opts[opt.long] = opt.default;
243
+ }
244
+ else if (opt.type === 'boolean') {
245
+ opts[opt.long] = false;
246
+ }
247
+ else if (opt.type === 'string[]' || opt.type === 'number[]') {
248
+ opts[opt.long] = [];
249
+ }
250
+ }
251
+ let remaining = [...tokens];
252
+ const resolverOptions = directOptions.filter(o => o.resolver);
253
+ for (const opt of resolverOptions) {
254
+ const result = opt.resolver(remaining);
255
+ opts[opt.long] = result.value;
256
+ remaining = result.remaining;
257
+ }
258
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(directOptions, true);
259
+ const normalizedTokens = this.#normalizeArgv(remaining, booleanOptions);
260
+ const finalRemaining = [];
261
+ let i = 0;
262
+ while (i < normalizedTokens.length) {
263
+ const token = normalizedTokens[i];
264
+ if (token.startsWith('--')) {
265
+ const consumed = this.#tryConsumeLongOption(normalizedTokens, i, optionByLong, opts);
266
+ if (consumed > 0) {
267
+ i += consumed;
268
+ continue;
269
+ }
270
+ finalRemaining.push(token);
271
+ i += 1;
272
+ continue;
273
+ }
274
+ if (token.startsWith('-') && token.length > 1) {
275
+ const result = this.#tryConsumeShortOption(normalizedTokens, i, optionByShort, opts);
276
+ if (result.consumed) {
277
+ i = result.nextIdx;
278
+ if (result.remainingToken) {
279
+ finalRemaining.push(result.remainingToken);
280
+ }
281
+ continue;
282
+ }
283
+ finalRemaining.push(token);
284
+ i += 1;
285
+ continue;
286
+ }
287
+ finalRemaining.push(token);
288
+ i += 1;
289
+ }
290
+ for (const opt of directOptions) {
291
+ if (opt.required && opts[opt.long] === undefined) {
292
+ throw new CommanderError('MissingRequired', `missing required option "--${opt.long}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
293
+ }
294
+ }
295
+ for (const opt of directOptions) {
296
+ if (opt.choices && opts[opt.long] !== undefined) {
297
+ const value = opts[opt.long];
298
+ const values = Array.isArray(value) ? value : [value];
299
+ const choices = opt.choices;
300
+ for (const v of values) {
301
+ if (!choices.includes(v)) {
302
+ throw new CommanderError('InvalidChoice', `invalid value "${v}" for option "--${opt.long}". Allowed: ${opt.choices.join(', ')}`, this.#getCommandPath());
303
+ }
304
+ }
305
+ }
306
+ }
307
+ return { opts, remaining: finalRemaining };
308
+ }
231
309
  formatHelp() {
232
310
  const lines = [];
233
311
  const allOptions = this.#getMergedOptions();
@@ -291,12 +369,12 @@ class Command {
291
369
  if (showHelpSubcommand) {
292
370
  cmdLines.push({ name: 'help', desc: 'Show help for a command' });
293
371
  }
294
- for (const sub of this.#subcommands) {
295
- let name = sub.#name;
296
- if (sub.#aliases.length > 0) {
297
- name += `, ${sub.#aliases.join(', ')}`;
372
+ for (const entry of this.#subcommands) {
373
+ let name = entry.name;
374
+ if (entry.aliases.length > 0) {
375
+ name += `, ${entry.aliases.join(', ')}`;
298
376
  }
299
- cmdLines.push({ name, desc: sub.#description });
377
+ cmdLines.push({ name, desc: entry.command.#description });
300
378
  }
301
379
  const maxNameLen = Math.max(...cmdLines.map(l => l.name.length));
302
380
  for (const { name, desc } of cmdLines) {
@@ -323,9 +401,16 @@ class Command {
323
401
  return {
324
402
  name: this.#name,
325
403
  description: this.#description,
326
- aliases: this.#aliases,
404
+ aliases: [],
327
405
  options,
328
- subcommands: this.#subcommands.map(sub => sub.getCompletionMeta()),
406
+ subcommands: this.#subcommands.map(entry => {
407
+ const subMeta = entry.command.getCompletionMeta();
408
+ return {
409
+ ...subMeta,
410
+ name: entry.name,
411
+ aliases: entry.aliases,
412
+ };
413
+ }),
329
414
  };
330
415
  }
331
416
  #processHelpSubcommand(argv) {
@@ -337,26 +422,80 @@ class Command {
337
422
  return ['--help'];
338
423
  }
339
424
  const subName = argv[1];
340
- const sub = this.#subcommands.find(c => c.#name === subName || c.#aliases.includes(subName));
341
- if (sub) {
425
+ const entry = this.#subcommands.find(e => e.name === subName || e.aliases.includes(subName));
426
+ if (entry) {
342
427
  return [subName, '--help', ...argv.slice(2)];
343
428
  }
344
429
  return argv;
345
430
  }
346
- #route(argv) {
431
+ #routeChain(argv) {
432
+ const chain = [this];
347
433
  let current = this;
348
434
  let idx = 0;
349
435
  while (idx < argv.length) {
350
436
  const token = argv[idx];
351
437
  if (token.startsWith('-'))
352
438
  break;
353
- const sub = current.#subcommands.find(c => c.#name === token || c.#aliases.includes(token));
354
- if (!sub)
439
+ const entry = current.#subcommands.find(e => e.name === token || e.aliases.includes(token));
440
+ if (!entry)
355
441
  break;
356
- current = sub;
442
+ current = entry.command;
443
+ chain.push(current);
357
444
  idx += 1;
358
445
  }
359
- return { command: current, remaining: argv.slice(idx) };
446
+ return { chain, remaining: argv.slice(idx) };
447
+ }
448
+ #splitAtDoubleDash(tokens) {
449
+ const ddIdx = tokens.indexOf('--');
450
+ if (ddIdx === -1) {
451
+ return { optionTokens: tokens, restArgs: [] };
452
+ }
453
+ return {
454
+ optionTokens: tokens.slice(0, ddIdx),
455
+ restArgs: tokens.slice(ddIdx + 1),
456
+ };
457
+ }
458
+ #shiftChain(chain, tokens) {
459
+ const optsMap = new Map();
460
+ let remaining = [...tokens];
461
+ const shadowed = new Set();
462
+ for (let i = chain.length - 1; i >= 0; i--) {
463
+ const cmd = chain[i];
464
+ const result = cmd.#shiftWithShadowed(remaining, shadowed);
465
+ optsMap.set(cmd, result.opts);
466
+ remaining = result.remaining;
467
+ for (const opt of cmd.#options) {
468
+ shadowed.add(opt.long);
469
+ }
470
+ }
471
+ if (remaining.length > 0) {
472
+ const leafCommand = chain[chain.length - 1];
473
+ const firstToken = remaining[0];
474
+ if (firstToken.startsWith('-')) {
475
+ throw new CommanderError('UnknownOption', `unknown option "${firstToken}" for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
476
+ }
477
+ else {
478
+ throw new CommanderError('UnexpectedArgument', `unexpected argument "${firstToken}". Positional arguments must come after "--"`, leafCommand.#getCommandPath());
479
+ }
480
+ }
481
+ return optsMap;
482
+ }
483
+ #applyChain(chain, optsMap, ctx) {
484
+ for (const cmd of chain) {
485
+ const opts = optsMap.get(cmd) ?? {};
486
+ for (const opt of cmd.#getMergedOptions()) {
487
+ if (opt.apply && opts[opt.long] !== undefined) {
488
+ opt.apply(opts[opt.long], ctx);
489
+ }
490
+ }
491
+ }
492
+ }
493
+ #mergeOpts(chain, optsMap) {
494
+ const merged = {};
495
+ for (const cmd of chain) {
496
+ Object.assign(merged, optsMap.get(cmd) ?? {});
497
+ }
498
+ return merged;
360
499
  }
361
500
  #parseLongOption(argv, idx, optionByLong, opts) {
362
501
  const token = argv[idx];
@@ -467,33 +606,17 @@ class Command {
467
606
  }
468
607
  }
469
608
  #getMergedOptions() {
470
- const ancestors = [];
471
- for (let node = this; node; node = node.#parent) {
472
- ancestors.unshift(node);
473
- }
474
609
  const optionMap = new Map();
475
- const hasUserHelp = ancestors.some(c => c.#options.some(o => o.long === 'help'));
476
- const hasUserVersion = ancestors.some(c => c.#options.some(o => o.long === 'version'));
610
+ const hasUserHelp = this.#options.some(o => o.long === 'help');
611
+ const hasUserVersion = this.#options.some(o => o.long === 'version');
477
612
  if (!hasUserHelp) {
478
613
  optionMap.set('help', BUILTIN_HELP_OPTION);
479
614
  }
480
615
  if (!hasUserVersion) {
481
616
  optionMap.set('version', BUILTIN_VERSION_OPTION);
482
617
  }
483
- for (const ancestor of ancestors) {
484
- for (const opt of ancestor.#options) {
485
- optionMap.set(opt.long, opt);
486
- }
487
- }
488
- const shortToLong = new Map();
489
- for (const [long, opt] of optionMap) {
490
- if (opt.short) {
491
- const existing = shortToLong.get(opt.short);
492
- if (existing && existing !== long) {
493
- throw new CommanderError('OptionConflict', `short option "-${opt.short}" is used by both "--${existing}" and "--${long}"`, this.#getCommandPath());
494
- }
495
- shortToLong.set(opt.short, long);
496
- }
618
+ for (const opt of this.#options) {
619
+ optionMap.set(opt.long, opt);
497
620
  }
498
621
  return Array.from(optionMap.values());
499
622
  }
@@ -535,9 +658,6 @@ class Command {
535
658
  }
536
659
  }
537
660
  }
538
- #isBuiltinOption(opt) {
539
- return opt === BUILTIN_HELP_OPTION || opt === BUILTIN_VERSION_OPTION;
540
- }
541
661
  #buildOptionMaps(allOptions, excludeResolver = false) {
542
662
  const optionByLong = new Map();
543
663
  const optionByShort = new Map();
@@ -632,20 +752,109 @@ class Command {
632
752
  return result;
633
753
  }
634
754
  #getCommandPath() {
635
- const parts = [];
636
- for (let node = this; node; node = node.#parent) {
637
- parts.unshift(node.#name);
755
+ return this.#name;
756
+ }
757
+ #tryConsumeLongOption(tokens, idx, optionByLong, opts) {
758
+ const token = tokens[idx];
759
+ const eqIdx = token.indexOf('=');
760
+ let optName;
761
+ let inlineValue;
762
+ if (eqIdx !== -1) {
763
+ optName = token.slice(2, eqIdx);
764
+ inlineValue = token.slice(eqIdx + 1);
765
+ }
766
+ else {
767
+ optName = token.slice(2);
768
+ }
769
+ const opt = optionByLong.get(optName);
770
+ if (!opt) {
771
+ return 0;
772
+ }
773
+ if (opt.type === 'boolean') {
774
+ if (inlineValue !== undefined) {
775
+ if (inlineValue === 'true') {
776
+ opts[optName] = true;
777
+ }
778
+ else if (inlineValue === 'false') {
779
+ opts[optName] = false;
780
+ }
781
+ else {
782
+ throw new CommanderError('InvalidBooleanValue', `invalid value "${inlineValue}" for boolean option "--${optName}". Use "true" or "false"`, this.#getCommandPath());
783
+ }
784
+ }
785
+ else {
786
+ opts[optName] = true;
787
+ }
788
+ return 1;
789
+ }
790
+ let value;
791
+ let consumed = 1;
792
+ if (inlineValue !== undefined) {
793
+ value = inlineValue;
794
+ }
795
+ else if (idx + 1 < tokens.length) {
796
+ value = tokens[idx + 1];
797
+ consumed = 2;
798
+ }
799
+ else {
800
+ throw new CommanderError('MissingValue', `option "--${optName}" requires a value`, this.#getCommandPath());
801
+ }
802
+ this.#applyValue(opt, value, opts);
803
+ return consumed;
804
+ }
805
+ #tryConsumeShortOption(tokens, idx, optionByShort, opts) {
806
+ const token = tokens[idx];
807
+ if (token.includes('=')) {
808
+ const firstFlag = token[1];
809
+ if (!optionByShort.has(firstFlag)) {
810
+ return { consumed: false, nextIdx: idx + 1 };
811
+ }
812
+ throw new CommanderError('UnsupportedShortSyntax', `"-${token.slice(1)}" is not supported. Use "-${token[1]} ${token.slice(3)}" instead`, this.#getCommandPath());
813
+ }
814
+ const flags = token.slice(1);
815
+ let j = 0;
816
+ const consumedFlags = [];
817
+ const unconsumedFlags = [];
818
+ let nextIdx = idx + 1;
819
+ while (j < flags.length) {
820
+ const flag = flags[j];
821
+ const opt = optionByShort.get(flag);
822
+ if (!opt) {
823
+ unconsumedFlags.push(...flags.slice(j).split(''));
824
+ break;
825
+ }
826
+ consumedFlags.push(flag);
827
+ if (opt.type === 'boolean') {
828
+ opts[opt.long] = true;
829
+ j += 1;
830
+ continue;
831
+ }
832
+ if (j < flags.length - 1) {
833
+ throw new CommanderError('UnsupportedShortSyntax', `"-${flags}" is not supported. Use "-${flags.slice(0, j + 1)} ${flags.slice(j + 1)}" or separate options`, this.#getCommandPath());
834
+ }
835
+ if (idx + 1 < tokens.length && !tokens[idx + 1].startsWith('-')) {
836
+ const value = tokens[idx + 1];
837
+ this.#applyValue(opt, value, opts);
838
+ nextIdx = idx + 2;
839
+ }
840
+ else {
841
+ throw new CommanderError('MissingValue', `option "-${flag}" requires a value`, this.#getCommandPath());
842
+ }
843
+ j += 1;
844
+ }
845
+ if (consumedFlags.length > 0) {
846
+ const remainingToken = unconsumedFlags.length > 0 ? `-${unconsumedFlags.join('')}` : undefined;
847
+ return { consumed: true, nextIdx, remainingToken };
638
848
  }
639
- return parts.join(' ');
849
+ return { consumed: false, nextIdx: idx + 1 };
640
850
  }
641
851
  }
642
852
 
643
853
  class CompletionCommand extends Command {
644
854
  constructor(root, config) {
645
- const name = config.name ?? 'completion';
646
855
  const paths = config.paths;
856
+ const programName = config.programName ?? root.name;
647
857
  super({
648
- name,
649
858
  description: 'Generate shell completion script',
650
859
  });
651
860
  this.option({
@@ -672,7 +881,6 @@ class CompletionCommand extends Command {
672
881
  })
673
882
  .action(({ opts }) => {
674
883
  const meta = root.getCompletionMeta();
675
- const programName = root.name;
676
884
  const selectedShells = [
677
885
  opts['bash'] && 'bash',
678
886
  opts['fish'] && 'fish',
@@ -57,10 +57,8 @@ interface IArgument {
57
57
  }
58
58
  /** Command configuration */
59
59
  interface ICommandConfig {
60
- /** Command name (used for routing) */
61
- name: string;
62
- /** Command aliases */
63
- aliases?: string[];
60
+ /** Command name (only effective for root command) */
61
+ name?: string;
64
62
  /** Command description */
65
63
  description: string;
66
64
  /** Version (only effective for root command) */
@@ -71,10 +69,8 @@ interface ICommandConfig {
71
69
  /** Forward declaration for Command class */
72
70
  interface ICommand {
73
71
  readonly name: string;
74
- readonly aliases: string[];
75
72
  readonly description: string;
76
73
  readonly version: string | undefined;
77
- readonly parent: ICommand | undefined;
78
74
  readonly options: IOption[];
79
75
  readonly arguments: IArgument[];
80
76
  }
@@ -116,8 +112,15 @@ interface IParseResult {
116
112
  /** Parsed positional arguments */
117
113
  args: string[];
118
114
  }
115
+ /** shift() method result */
116
+ interface IShiftResult {
117
+ /** Options consumed by this command */
118
+ opts: Record<string, unknown>;
119
+ /** Tokens not consumed, to be passed to parent */
120
+ remaining: string[];
121
+ }
119
122
  /** Error kinds for command parsing */
120
- type ICommanderErrorKind = 'UnknownOption' | 'MissingValue' | 'InvalidType' | 'UnsupportedShortSyntax' | 'OptionConflict' | 'MissingRequired' | 'InvalidChoice' | 'InvalidBooleanValue' | 'MissingRequiredArgument' | 'ConfigurationError';
123
+ type ICommanderErrorKind = 'UnknownOption' | 'UnexpectedArgument' | 'MissingValue' | 'InvalidType' | 'UnsupportedShortSyntax' | 'OptionConflict' | 'MissingRequired' | 'InvalidChoice' | 'InvalidBooleanValue' | 'MissingRequiredArgument' | 'ConfigurationError';
121
124
  /** Commander error with structured information */
122
125
  declare class CommanderError extends Error {
123
126
  readonly kind: ICommanderErrorKind;
@@ -155,8 +158,8 @@ interface ICompletionPaths {
155
158
  }
156
159
  /** CompletionCommand configuration */
157
160
  interface ICompletionCommandConfig {
158
- /** Subcommand name, defaults to 'completion' */
159
- name?: string;
161
+ /** Program name for completion scripts (defaults to root.name) */
162
+ programName?: string;
160
163
  /** Default completion file paths for each shell (required for --write support) */
161
164
  paths: ICompletionPaths;
162
165
  }
@@ -171,18 +174,21 @@ declare class Command implements ICommand {
171
174
  #private;
172
175
  constructor(config: ICommandConfig);
173
176
  get name(): string;
174
- get aliases(): string[];
175
177
  get description(): string;
176
178
  get version(): string | undefined;
177
- get parent(): Command | undefined;
178
179
  get options(): IOption[];
179
180
  get arguments(): IArgument[];
180
181
  option(opt: IOption): this;
181
182
  argument(arg: IArgument): this;
182
183
  action(fn: IAction): this;
183
- subcommand(cmd: Command): this;
184
+ subcommand(name: string, cmd: Command): this;
184
185
  run(params: IRunParams): Promise<void>;
185
186
  parse(argv: string[]): IParseResult;
187
+ /**
188
+ * Shift options from tokens that this command recognizes.
189
+ * Unrecognized tokens are returned in `remaining` for parent commands.
190
+ */
191
+ shift(tokens: string[]): IShiftResult;
186
192
  formatHelp(): string;
187
193
  getCompletionMeta(): ICompletionMeta;
188
194
  }
@@ -199,7 +205,7 @@ declare class Command implements ICommand {
199
205
  * @example
200
206
  * ```typescript
201
207
  * const root = new Command({ name: 'mycli', description: 'My CLI' })
202
- * root.subcommand(new CompletionCommand(root, {
208
+ * root.subcommand('completion', new CompletionCommand(root, {
203
209
  * paths: {
204
210
  * bash: `~/.local/share/bash-completion/completions/mycli`,
205
211
  * fish: `~/.config/fish/completions/mycli.fish`,
@@ -233,4 +239,4 @@ declare class PwshCompletion {
233
239
  }
234
240
 
235
241
  export { BashCompletion, Command, CommanderError, CompletionCommand, FishCompletion, PwshCompletion };
236
- export type { IAction, IActionParams, IArgument, IArgumentKind, ICommand, ICommandConfig, ICommandContext, ICommanderErrorKind, ICompletionCommandConfig, ICompletionMeta, ICompletionOptionMeta, ICompletionPaths, IOption, IOptionType, IParseResult, IReporter, IRunParams, IShellType };
242
+ 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": "2.1.0",
3
+ "version": "3.1.0",
4
4
  "description": "A minimal, type-safe command-line interface builder with fluent API",
5
5
  "author": {
6
6
  "name": "guanghechen",