@guanghechen/commander 3.2.0 → 3.3.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,15 @@
1
1
  # Change Log
2
2
 
3
+ ## 3.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Apply code review fixes:
8
+ - Fix `#getCommandPath` to return full command path (e.g., "cli sub" instead of just "sub")
9
+ - Remove unused `#parseLongOption` and `#parseShortOption` dead code
10
+ - Improve `--no-{option}` help description to "Negate --{option}"
11
+ - Document short option negative value limitation in spec
12
+
3
13
  ## 3.2.0
4
14
 
5
15
  ### Minor Changes
package/README.md CHANGED
@@ -124,7 +124,6 @@ const root = new Command({
124
124
  })
125
125
 
126
126
  const clone = new Command({
127
- name: 'clone',
128
127
  description: 'Clone a repository',
129
128
  })
130
129
  .argument({ name: 'url', kind: 'required', description: 'Repository URL' })
@@ -134,8 +133,6 @@ const clone = new Command({
134
133
  })
135
134
 
136
135
  const commit = new Command({
137
- name: 'commit',
138
- aliases: ['ci'],
139
136
  description: 'Record changes to the repository',
140
137
  })
141
138
  .option({ long: 'message', short: 'm', type: 'string', required: true, description: 'Commit message' })
@@ -144,7 +141,7 @@ const commit = new Command({
144
141
  console.log(`Committing: ${opts['message']}`)
145
142
  })
146
143
 
147
- root.subcommand(clone).subcommand(commit)
144
+ root.subcommand('clone', clone).subcommand('commit', commit).subcommand('ci', commit)
148
145
 
149
146
  root.run({ argv: process.argv.slice(2), envs: process.env })
150
147
  ```
@@ -161,7 +158,7 @@ const root = new Command({
161
158
  })
162
159
 
163
160
  // Add completion subcommand
164
- root.subcommand(new CompletionCommand(root))
161
+ root.subcommand('completion', new CompletionCommand(root))
165
162
 
166
163
  // Generate completion scripts:
167
164
  // mycli completion --bash > ~/.local/share/bash-completion/completions/mycli
package/lib/cjs/index.cjs CHANGED
@@ -68,6 +68,8 @@ class Command {
68
68
  #description;
69
69
  #version;
70
70
  #helpSubcommandEnabled;
71
+ #reporter;
72
+ #parent;
71
73
  #options = [];
72
74
  #arguments = [];
73
75
  #subcommands = [];
@@ -77,6 +79,7 @@ class Command {
77
79
  this.#description = config.description;
78
80
  this.#version = config.version;
79
81
  this.#helpSubcommandEnabled = config.help ?? false;
82
+ this.#reporter = config.reporter;
80
83
  }
81
84
  get name() {
82
85
  return this.#name;
@@ -87,6 +90,9 @@ class Command {
87
90
  get version() {
88
91
  return this.#version;
89
92
  }
93
+ get parent() {
94
+ return this.#parent;
95
+ }
90
96
  get options() {
91
97
  return [...this.#options];
92
98
  }
@@ -112,12 +118,16 @@ class Command {
112
118
  if (this.#helpSubcommandEnabled && name === 'help') {
113
119
  throw new CommanderError('ConfigurationError', '"help" is a reserved subcommand name when help subcommand is enabled', this.#getCommandPath());
114
120
  }
121
+ if (cmd.#parent && cmd.#parent !== this) {
122
+ throw new CommanderError('ConfigurationError', `command "${cmd.#name}" already has a parent`, this.#getCommandPath());
123
+ }
115
124
  const existing = this.#subcommands.find(e => e.command === cmd);
116
125
  if (existing) {
117
126
  existing.aliases.push(name);
118
127
  }
119
128
  else {
120
129
  cmd.#name = name;
130
+ cmd.#parent = this;
121
131
  this.#subcommands.push({ name, aliases: [], command: cmd });
122
132
  }
123
133
  return this;
@@ -128,28 +138,34 @@ class Command {
128
138
  const processedArgv = this.#processHelpSubcommand(argv);
129
139
  const { chain, remaining } = this.#routeChain(processedArgv);
130
140
  const leafCommand = chain[chain.length - 1];
141
+ const rootCommand = chain[0];
142
+ const includeRootVersion = chain.length === 1;
143
+ this.#validateMergedShortOptions(chain, includeRootVersion);
131
144
  const { optionTokens, restArgs } = this.#splitAtDoubleDash(remaining);
132
- const leafOptions = leafCommand.#getMergedOptions();
145
+ const leafOptions = leafCommand.#getMergedOptions(leafCommand === rootCommand);
133
146
  const hasUserHelp = leafCommand.#options.some(o => o.long === 'help');
134
147
  const hasUserVersion = leafCommand.#options.some(o => o.long === 'version');
135
148
  if (!hasUserHelp && leafCommand.#hasHelpFlag(optionTokens, leafOptions)) {
136
149
  console.log(leafCommand.formatHelp());
137
150
  return;
138
151
  }
139
- if (!hasUserVersion && leafCommand.#hasVersionFlag(optionTokens, leafOptions)) {
140
- console.log(leafCommand.version ?? 'unknown');
141
- return;
152
+ if (!hasUserVersion && leafCommand === rootCommand) {
153
+ if (leafCommand.#hasVersionFlag(optionTokens, leafOptions)) {
154
+ console.log(leafCommand.version ?? 'unknown');
155
+ return;
156
+ }
142
157
  }
143
- const optsMap = this.#shiftChain(chain, optionTokens);
158
+ const { optsMap, positionalArgs } = this.#shiftChain(chain, optionTokens, includeRootVersion);
144
159
  const ctx = {
145
160
  cmd: leafCommand,
146
161
  envs,
147
- reporter: reporter ?? new DefaultReporter(),
162
+ reporter: reporter ?? this.#reporter ?? new DefaultReporter(),
148
163
  argv,
149
164
  };
150
165
  this.#applyChain(chain, optsMap, ctx);
151
166
  const mergedOpts = this.#mergeOpts(chain, optsMap);
152
- const { args, rawArgs } = leafCommand.#parseArguments(restArgs);
167
+ const allArgs = [...positionalArgs, ...restArgs];
168
+ const { args, rawArgs } = leafCommand.#parseArguments(allArgs);
153
169
  const actionParams = { ctx, opts: mergedOpts, args, rawArgs };
154
170
  if (leafCommand.#action) {
155
171
  try {
@@ -182,72 +198,23 @@ class Command {
182
198
  }
183
199
  }
184
200
  parse(argv) {
185
- const allOptions = this.#getMergedOptions();
186
- const opts = {};
187
- const rawArgs = [];
188
- for (const opt of allOptions) {
189
- if (opt.default !== undefined) {
190
- opts[opt.long] = opt.default;
191
- }
192
- else if (opt.type === 'boolean') {
193
- opts[opt.long] = false;
194
- }
195
- else if (opt.type === 'string[]' || opt.type === 'number[]') {
196
- opts[opt.long] = [];
197
- }
198
- }
199
- let remaining = [...argv];
200
- const resolverOptions = allOptions.filter(o => o.resolver);
201
- for (const opt of resolverOptions) {
202
- const result = opt.resolver(remaining);
203
- opts[opt.long] = result.value;
204
- remaining = result.remaining;
205
- }
206
- const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(allOptions, true);
207
- remaining = this.#normalizeArgv(remaining, booleanOptions);
208
- let i = 0;
209
- while (i < remaining.length) {
210
- const token = remaining[i];
211
- if (token === '--') {
212
- rawArgs.push(...remaining.slice(i + 1));
213
- break;
214
- }
215
- if (token.startsWith('--')) {
216
- i = this.#parseLongOption(remaining, i, optionByLong, opts);
217
- continue;
218
- }
219
- if (token.startsWith('-') && token.length > 1) {
220
- i = this.#parseShortOption(remaining, i, optionByShort, opts);
221
- continue;
222
- }
223
- rawArgs.push(token);
224
- i += 1;
225
- }
226
- for (const opt of allOptions) {
227
- if (opt.required && opts[opt.long] === undefined) {
228
- throw new CommanderError('MissingRequired', `missing required option "--${opt.long}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
229
- }
230
- }
231
- for (const opt of allOptions) {
232
- if (opt.choices && opts[opt.long] !== undefined) {
233
- const value = opts[opt.long];
234
- const values = Array.isArray(value) ? value : [value];
235
- const choices = opt.choices;
236
- for (const v of values) {
237
- if (!choices.includes(v)) {
238
- throw new CommanderError('InvalidChoice', `invalid value "${v}" for option "--${opt.long}". Allowed: ${opt.choices.join(', ')}`, this.#getCommandPath());
239
- }
240
- }
241
- }
242
- }
243
- const { args } = this.#parseArguments(rawArgs);
244
- return { opts, args, rawArgs };
201
+ const processedArgv = this.#processHelpSubcommand(argv);
202
+ const { chain, remaining } = this.#routeChain(processedArgv);
203
+ const leafCommand = chain[chain.length - 1];
204
+ const includeRootVersion = chain.length === 1;
205
+ this.#validateMergedShortOptions(chain, includeRootVersion);
206
+ const { optionTokens, restArgs } = this.#splitAtDoubleDash(remaining);
207
+ const { optsMap, positionalArgs } = this.#shiftChain(chain, optionTokens, includeRootVersion);
208
+ const mergedOpts = this.#mergeOpts(chain, optsMap);
209
+ const allArgs = [...positionalArgs, ...restArgs];
210
+ const { args, rawArgs } = leafCommand.#parseArguments(allArgs);
211
+ return { opts: mergedOpts, args, rawArgs };
245
212
  }
246
213
  shift(tokens) {
247
214
  return this.#shiftWithShadowed(tokens, new Set());
248
215
  }
249
- #shiftWithShadowed(tokens, shadowed) {
250
- const allDirectOptions = this.#getMergedOptions();
216
+ #shiftWithShadowed(tokens, shadowed, includeVersion = !this.#parent) {
217
+ const allDirectOptions = this.#getMergedOptions(includeVersion);
251
218
  const directOptions = allDirectOptions.filter(o => !shadowed.has(o.long));
252
219
  const opts = {};
253
220
  for (const opt of directOptions) {
@@ -364,7 +331,7 @@ class Command {
364
331
  if (effectiveType === 'boolean') {
365
332
  optLines.push({
366
333
  sig: ` --no-${opt.long}`,
367
- desc: opt.description,
334
+ desc: `Negate --${opt.long}`,
368
335
  });
369
336
  }
370
337
  }
@@ -427,11 +394,11 @@ class Command {
427
394
  };
428
395
  }
429
396
  #processHelpSubcommand(argv) {
430
- if (!this.#helpSubcommandEnabled || this.#subcommands.length === 0)
397
+ if (!this.#helpSubcommandEnabled)
431
398
  return argv;
432
399
  if (argv.length < 1 || argv[0] !== 'help')
433
400
  return argv;
434
- if (argv.length === 1) {
401
+ if (argv.length === 1 || this.#subcommands.length === 0) {
435
402
  return ['--help'];
436
403
  }
437
404
  const subName = argv[1];
@@ -468,30 +435,34 @@ class Command {
468
435
  restArgs: tokens.slice(ddIdx + 1),
469
436
  };
470
437
  }
471
- #shiftChain(chain, tokens) {
438
+ #shiftChain(chain, tokens, includeRootVersion) {
472
439
  const optsMap = new Map();
473
440
  let remaining = [...tokens];
441
+ const rootCommand = chain[0];
474
442
  const shadowed = new Set();
475
443
  for (let i = chain.length - 1; i >= 0; i--) {
476
444
  const cmd = chain[i];
477
- const result = cmd.#shiftWithShadowed(remaining, shadowed);
445
+ const includeVersion = cmd === rootCommand && includeRootVersion;
446
+ const result = cmd.#shiftWithShadowed(remaining, shadowed, includeVersion);
478
447
  optsMap.set(cmd, result.opts);
479
448
  remaining = result.remaining;
480
449
  for (const opt of cmd.#options) {
481
450
  shadowed.add(opt.long);
482
451
  }
483
452
  }
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());
453
+ const positionalArgs = [];
454
+ for (const token of remaining) {
455
+ if (token.startsWith('-')) {
456
+ const leafCommand = chain[chain.length - 1];
457
+ if (!token.startsWith('--') && token.length > 2) {
458
+ const flag = token[1];
459
+ throw new CommanderError('UnknownOption', `unknown option "-${flag}" for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
460
+ }
461
+ throw new CommanderError('UnknownOption', `unknown option "${token}" for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
492
462
  }
463
+ positionalArgs.push(token);
493
464
  }
494
- return optsMap;
465
+ return { optsMap, positionalArgs };
495
466
  }
496
467
  #applyChain(chain, optsMap, ctx) {
497
468
  for (const cmd of chain) {
@@ -510,82 +481,6 @@ class Command {
510
481
  }
511
482
  return merged;
512
483
  }
513
- #parseLongOption(argv, idx, optionByLong, opts) {
514
- const token = argv[idx];
515
- const eqIdx = token.indexOf('=');
516
- let optName;
517
- let inlineValue;
518
- if (eqIdx !== -1) {
519
- optName = token.slice(2, eqIdx);
520
- inlineValue = token.slice(eqIdx + 1);
521
- }
522
- else {
523
- optName = token.slice(2);
524
- }
525
- const opt = optionByLong.get(optName);
526
- if (!opt) {
527
- throw new CommanderError('UnknownOption', `unknown option "--${optName}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
528
- }
529
- if (opt.type === 'boolean') {
530
- if (inlineValue !== undefined) {
531
- if (inlineValue === 'true') {
532
- opts[optName] = true;
533
- }
534
- else if (inlineValue === 'false') {
535
- opts[optName] = false;
536
- }
537
- else {
538
- throw new CommanderError('InvalidBooleanValue', `invalid value "${inlineValue}" for boolean option "--${optName}". Use "true" or "false"`, this.#getCommandPath());
539
- }
540
- }
541
- else {
542
- opts[optName] = true;
543
- }
544
- return idx + 1;
545
- }
546
- let value;
547
- let nextIdx = idx;
548
- if (inlineValue !== undefined) {
549
- value = inlineValue;
550
- }
551
- else if (idx + 1 < argv.length) {
552
- value = argv[idx + 1];
553
- nextIdx += 1;
554
- }
555
- else {
556
- throw new CommanderError('MissingValue', `option "--${optName}" requires a value`, this.#getCommandPath());
557
- }
558
- this.#applyValue(opt, value, opts);
559
- return nextIdx + 1;
560
- }
561
- #parseShortOption(argv, idx, optionByShort, opts) {
562
- const token = argv[idx];
563
- if (token.includes('=')) {
564
- throw new CommanderError('UnsupportedShortSyntax', `"-${token.slice(1)}" is not supported. Use "-${token[1]} ${token.slice(3)}" instead`, this.#getCommandPath());
565
- }
566
- const flags = token.slice(1);
567
- for (let j = 0; j < flags.length; j++) {
568
- const flag = flags[j];
569
- const opt = optionByShort.get(flag);
570
- if (!opt) {
571
- throw new CommanderError('UnknownOption', `unknown option "-${flag}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
572
- }
573
- if (opt.type === 'boolean') {
574
- opts[opt.long] = true;
575
- continue;
576
- }
577
- if (j < flags.length - 1) {
578
- throw new CommanderError('UnsupportedShortSyntax', `"-${flags}" is not supported. Use "-${flags.slice(0, j + 1)} ${flags.slice(j + 1)}" or separate options`, this.#getCommandPath());
579
- }
580
- if (idx + 1 < argv.length && !argv[idx + 1].startsWith('-')) {
581
- const value = argv[idx + 1];
582
- this.#applyValue(opt, value, opts);
583
- return idx + 2;
584
- }
585
- throw new CommanderError('MissingValue', `option "-${flag}" requires a value`, this.#getCommandPath());
586
- }
587
- return idx + 1;
588
- }
589
484
  #applyValue(opt, rawValue, opts) {
590
485
  const type = opt.type ?? 'string';
591
486
  let parsedValue = rawValue;
@@ -618,14 +513,14 @@ class Command {
618
513
  opts[opt.long] = parsedValue;
619
514
  }
620
515
  }
621
- #getMergedOptions() {
516
+ #getMergedOptions(includeVersion = !this.#parent) {
622
517
  const optionMap = new Map();
623
518
  const hasUserHelp = this.#options.some(o => o.long === 'help');
624
519
  const hasUserVersion = this.#options.some(o => o.long === 'version');
625
520
  if (!hasUserHelp) {
626
521
  optionMap.set('help', BUILTIN_HELP_OPTION);
627
522
  }
628
- if (!hasUserVersion) {
523
+ if (!hasUserVersion && includeVersion) {
629
524
  optionMap.set('version', BUILTIN_VERSION_OPTION);
630
525
  }
631
526
  for (const opt of this.#options) {
@@ -633,6 +528,26 @@ class Command {
633
528
  }
634
529
  return Array.from(optionMap.values());
635
530
  }
531
+ #validateMergedShortOptions(chain, includeRootVersion) {
532
+ const mergedByLong = new Map();
533
+ const rootCommand = chain[0];
534
+ for (const cmd of chain) {
535
+ const includeVersion = cmd === rootCommand && includeRootVersion;
536
+ for (const opt of cmd.#getMergedOptions(includeVersion)) {
537
+ mergedByLong.set(opt.long, opt);
538
+ }
539
+ }
540
+ const shortMap = new Map();
541
+ for (const opt of mergedByLong.values()) {
542
+ if (!opt.short)
543
+ continue;
544
+ const existingLong = shortMap.get(opt.short);
545
+ if (existingLong && existingLong !== opt.long) {
546
+ throw new CommanderError('OptionConflict', `short option "-${opt.short}" conflicts with "--${existingLong}"`, this.#getCommandPath());
547
+ }
548
+ shortMap.set(opt.short, opt.long);
549
+ }
550
+ }
636
551
  #validateOptionConfig(opt) {
637
552
  if (opt.long.startsWith('no-')) {
638
553
  throw new CommanderError('ConfigurationError', `option long name cannot start with "no-": "${opt.long}"`, this.#getCommandPath());
@@ -823,7 +738,15 @@ class Command {
823
738
  return result;
824
739
  }
825
740
  #getCommandPath() {
826
- return this.#name;
741
+ const parts = [];
742
+ let current = this;
743
+ while (current) {
744
+ if (current.#name) {
745
+ parts.unshift(current.#name);
746
+ }
747
+ current = current.#parent;
748
+ }
749
+ return parts.join(' ') || this.#name;
827
750
  }
828
751
  #tryConsumeLongOption(tokens, idx, optionByLong, opts) {
829
752
  const token = tokens[idx];
package/lib/esm/index.mjs CHANGED
@@ -46,6 +46,8 @@ class Command {
46
46
  #description;
47
47
  #version;
48
48
  #helpSubcommandEnabled;
49
+ #reporter;
50
+ #parent;
49
51
  #options = [];
50
52
  #arguments = [];
51
53
  #subcommands = [];
@@ -55,6 +57,7 @@ class Command {
55
57
  this.#description = config.description;
56
58
  this.#version = config.version;
57
59
  this.#helpSubcommandEnabled = config.help ?? false;
60
+ this.#reporter = config.reporter;
58
61
  }
59
62
  get name() {
60
63
  return this.#name;
@@ -65,6 +68,9 @@ class Command {
65
68
  get version() {
66
69
  return this.#version;
67
70
  }
71
+ get parent() {
72
+ return this.#parent;
73
+ }
68
74
  get options() {
69
75
  return [...this.#options];
70
76
  }
@@ -90,12 +96,16 @@ class Command {
90
96
  if (this.#helpSubcommandEnabled && name === 'help') {
91
97
  throw new CommanderError('ConfigurationError', '"help" is a reserved subcommand name when help subcommand is enabled', this.#getCommandPath());
92
98
  }
99
+ if (cmd.#parent && cmd.#parent !== this) {
100
+ throw new CommanderError('ConfigurationError', `command "${cmd.#name}" already has a parent`, this.#getCommandPath());
101
+ }
93
102
  const existing = this.#subcommands.find(e => e.command === cmd);
94
103
  if (existing) {
95
104
  existing.aliases.push(name);
96
105
  }
97
106
  else {
98
107
  cmd.#name = name;
108
+ cmd.#parent = this;
99
109
  this.#subcommands.push({ name, aliases: [], command: cmd });
100
110
  }
101
111
  return this;
@@ -106,28 +116,34 @@ class Command {
106
116
  const processedArgv = this.#processHelpSubcommand(argv);
107
117
  const { chain, remaining } = this.#routeChain(processedArgv);
108
118
  const leafCommand = chain[chain.length - 1];
119
+ const rootCommand = chain[0];
120
+ const includeRootVersion = chain.length === 1;
121
+ this.#validateMergedShortOptions(chain, includeRootVersion);
109
122
  const { optionTokens, restArgs } = this.#splitAtDoubleDash(remaining);
110
- const leafOptions = leafCommand.#getMergedOptions();
123
+ const leafOptions = leafCommand.#getMergedOptions(leafCommand === rootCommand);
111
124
  const hasUserHelp = leafCommand.#options.some(o => o.long === 'help');
112
125
  const hasUserVersion = leafCommand.#options.some(o => o.long === 'version');
113
126
  if (!hasUserHelp && leafCommand.#hasHelpFlag(optionTokens, leafOptions)) {
114
127
  console.log(leafCommand.formatHelp());
115
128
  return;
116
129
  }
117
- if (!hasUserVersion && leafCommand.#hasVersionFlag(optionTokens, leafOptions)) {
118
- console.log(leafCommand.version ?? 'unknown');
119
- return;
130
+ if (!hasUserVersion && leafCommand === rootCommand) {
131
+ if (leafCommand.#hasVersionFlag(optionTokens, leafOptions)) {
132
+ console.log(leafCommand.version ?? 'unknown');
133
+ return;
134
+ }
120
135
  }
121
- const optsMap = this.#shiftChain(chain, optionTokens);
136
+ const { optsMap, positionalArgs } = this.#shiftChain(chain, optionTokens, includeRootVersion);
122
137
  const ctx = {
123
138
  cmd: leafCommand,
124
139
  envs,
125
- reporter: reporter ?? new DefaultReporter(),
140
+ reporter: reporter ?? this.#reporter ?? new DefaultReporter(),
126
141
  argv,
127
142
  };
128
143
  this.#applyChain(chain, optsMap, ctx);
129
144
  const mergedOpts = this.#mergeOpts(chain, optsMap);
130
- const { args, rawArgs } = leafCommand.#parseArguments(restArgs);
145
+ const allArgs = [...positionalArgs, ...restArgs];
146
+ const { args, rawArgs } = leafCommand.#parseArguments(allArgs);
131
147
  const actionParams = { ctx, opts: mergedOpts, args, rawArgs };
132
148
  if (leafCommand.#action) {
133
149
  try {
@@ -160,72 +176,23 @@ class Command {
160
176
  }
161
177
  }
162
178
  parse(argv) {
163
- const allOptions = this.#getMergedOptions();
164
- const opts = {};
165
- const rawArgs = [];
166
- for (const opt of allOptions) {
167
- if (opt.default !== undefined) {
168
- opts[opt.long] = opt.default;
169
- }
170
- else if (opt.type === 'boolean') {
171
- opts[opt.long] = false;
172
- }
173
- else if (opt.type === 'string[]' || opt.type === 'number[]') {
174
- opts[opt.long] = [];
175
- }
176
- }
177
- let remaining = [...argv];
178
- const resolverOptions = allOptions.filter(o => o.resolver);
179
- for (const opt of resolverOptions) {
180
- const result = opt.resolver(remaining);
181
- opts[opt.long] = result.value;
182
- remaining = result.remaining;
183
- }
184
- const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(allOptions, true);
185
- remaining = this.#normalizeArgv(remaining, booleanOptions);
186
- let i = 0;
187
- while (i < remaining.length) {
188
- const token = remaining[i];
189
- if (token === '--') {
190
- rawArgs.push(...remaining.slice(i + 1));
191
- break;
192
- }
193
- if (token.startsWith('--')) {
194
- i = this.#parseLongOption(remaining, i, optionByLong, opts);
195
- continue;
196
- }
197
- if (token.startsWith('-') && token.length > 1) {
198
- i = this.#parseShortOption(remaining, i, optionByShort, opts);
199
- continue;
200
- }
201
- rawArgs.push(token);
202
- i += 1;
203
- }
204
- for (const opt of allOptions) {
205
- if (opt.required && opts[opt.long] === undefined) {
206
- throw new CommanderError('MissingRequired', `missing required option "--${opt.long}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
207
- }
208
- }
209
- for (const opt of allOptions) {
210
- if (opt.choices && opts[opt.long] !== undefined) {
211
- const value = opts[opt.long];
212
- const values = Array.isArray(value) ? value : [value];
213
- const choices = opt.choices;
214
- for (const v of values) {
215
- if (!choices.includes(v)) {
216
- throw new CommanderError('InvalidChoice', `invalid value "${v}" for option "--${opt.long}". Allowed: ${opt.choices.join(', ')}`, this.#getCommandPath());
217
- }
218
- }
219
- }
220
- }
221
- const { args } = this.#parseArguments(rawArgs);
222
- return { opts, args, rawArgs };
179
+ const processedArgv = this.#processHelpSubcommand(argv);
180
+ const { chain, remaining } = this.#routeChain(processedArgv);
181
+ const leafCommand = chain[chain.length - 1];
182
+ const includeRootVersion = chain.length === 1;
183
+ this.#validateMergedShortOptions(chain, includeRootVersion);
184
+ const { optionTokens, restArgs } = this.#splitAtDoubleDash(remaining);
185
+ const { optsMap, positionalArgs } = this.#shiftChain(chain, optionTokens, includeRootVersion);
186
+ const mergedOpts = this.#mergeOpts(chain, optsMap);
187
+ const allArgs = [...positionalArgs, ...restArgs];
188
+ const { args, rawArgs } = leafCommand.#parseArguments(allArgs);
189
+ return { opts: mergedOpts, args, rawArgs };
223
190
  }
224
191
  shift(tokens) {
225
192
  return this.#shiftWithShadowed(tokens, new Set());
226
193
  }
227
- #shiftWithShadowed(tokens, shadowed) {
228
- const allDirectOptions = this.#getMergedOptions();
194
+ #shiftWithShadowed(tokens, shadowed, includeVersion = !this.#parent) {
195
+ const allDirectOptions = this.#getMergedOptions(includeVersion);
229
196
  const directOptions = allDirectOptions.filter(o => !shadowed.has(o.long));
230
197
  const opts = {};
231
198
  for (const opt of directOptions) {
@@ -342,7 +309,7 @@ class Command {
342
309
  if (effectiveType === 'boolean') {
343
310
  optLines.push({
344
311
  sig: ` --no-${opt.long}`,
345
- desc: opt.description,
312
+ desc: `Negate --${opt.long}`,
346
313
  });
347
314
  }
348
315
  }
@@ -405,11 +372,11 @@ class Command {
405
372
  };
406
373
  }
407
374
  #processHelpSubcommand(argv) {
408
- if (!this.#helpSubcommandEnabled || this.#subcommands.length === 0)
375
+ if (!this.#helpSubcommandEnabled)
409
376
  return argv;
410
377
  if (argv.length < 1 || argv[0] !== 'help')
411
378
  return argv;
412
- if (argv.length === 1) {
379
+ if (argv.length === 1 || this.#subcommands.length === 0) {
413
380
  return ['--help'];
414
381
  }
415
382
  const subName = argv[1];
@@ -446,30 +413,34 @@ class Command {
446
413
  restArgs: tokens.slice(ddIdx + 1),
447
414
  };
448
415
  }
449
- #shiftChain(chain, tokens) {
416
+ #shiftChain(chain, tokens, includeRootVersion) {
450
417
  const optsMap = new Map();
451
418
  let remaining = [...tokens];
419
+ const rootCommand = chain[0];
452
420
  const shadowed = new Set();
453
421
  for (let i = chain.length - 1; i >= 0; i--) {
454
422
  const cmd = chain[i];
455
- const result = cmd.#shiftWithShadowed(remaining, shadowed);
423
+ const includeVersion = cmd === rootCommand && includeRootVersion;
424
+ const result = cmd.#shiftWithShadowed(remaining, shadowed, includeVersion);
456
425
  optsMap.set(cmd, result.opts);
457
426
  remaining = result.remaining;
458
427
  for (const opt of cmd.#options) {
459
428
  shadowed.add(opt.long);
460
429
  }
461
430
  }
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());
431
+ const positionalArgs = [];
432
+ for (const token of remaining) {
433
+ if (token.startsWith('-')) {
434
+ const leafCommand = chain[chain.length - 1];
435
+ if (!token.startsWith('--') && token.length > 2) {
436
+ const flag = token[1];
437
+ throw new CommanderError('UnknownOption', `unknown option "-${flag}" for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
438
+ }
439
+ throw new CommanderError('UnknownOption', `unknown option "${token}" for command "${leafCommand.#getCommandPath()}"`, leafCommand.#getCommandPath());
470
440
  }
441
+ positionalArgs.push(token);
471
442
  }
472
- return optsMap;
443
+ return { optsMap, positionalArgs };
473
444
  }
474
445
  #applyChain(chain, optsMap, ctx) {
475
446
  for (const cmd of chain) {
@@ -488,82 +459,6 @@ class Command {
488
459
  }
489
460
  return merged;
490
461
  }
491
- #parseLongOption(argv, idx, optionByLong, opts) {
492
- const token = argv[idx];
493
- const eqIdx = token.indexOf('=');
494
- let optName;
495
- let inlineValue;
496
- if (eqIdx !== -1) {
497
- optName = token.slice(2, eqIdx);
498
- inlineValue = token.slice(eqIdx + 1);
499
- }
500
- else {
501
- optName = token.slice(2);
502
- }
503
- const opt = optionByLong.get(optName);
504
- if (!opt) {
505
- throw new CommanderError('UnknownOption', `unknown option "--${optName}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
506
- }
507
- if (opt.type === 'boolean') {
508
- if (inlineValue !== undefined) {
509
- if (inlineValue === 'true') {
510
- opts[optName] = true;
511
- }
512
- else if (inlineValue === 'false') {
513
- opts[optName] = false;
514
- }
515
- else {
516
- throw new CommanderError('InvalidBooleanValue', `invalid value "${inlineValue}" for boolean option "--${optName}". Use "true" or "false"`, this.#getCommandPath());
517
- }
518
- }
519
- else {
520
- opts[optName] = true;
521
- }
522
- return idx + 1;
523
- }
524
- let value;
525
- let nextIdx = idx;
526
- if (inlineValue !== undefined) {
527
- value = inlineValue;
528
- }
529
- else if (idx + 1 < argv.length) {
530
- value = argv[idx + 1];
531
- nextIdx += 1;
532
- }
533
- else {
534
- throw new CommanderError('MissingValue', `option "--${optName}" requires a value`, this.#getCommandPath());
535
- }
536
- this.#applyValue(opt, value, opts);
537
- return nextIdx + 1;
538
- }
539
- #parseShortOption(argv, idx, optionByShort, opts) {
540
- const token = argv[idx];
541
- if (token.includes('=')) {
542
- throw new CommanderError('UnsupportedShortSyntax', `"-${token.slice(1)}" is not supported. Use "-${token[1]} ${token.slice(3)}" instead`, this.#getCommandPath());
543
- }
544
- const flags = token.slice(1);
545
- for (let j = 0; j < flags.length; j++) {
546
- const flag = flags[j];
547
- const opt = optionByShort.get(flag);
548
- if (!opt) {
549
- throw new CommanderError('UnknownOption', `unknown option "-${flag}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
550
- }
551
- if (opt.type === 'boolean') {
552
- opts[opt.long] = true;
553
- continue;
554
- }
555
- if (j < flags.length - 1) {
556
- throw new CommanderError('UnsupportedShortSyntax', `"-${flags}" is not supported. Use "-${flags.slice(0, j + 1)} ${flags.slice(j + 1)}" or separate options`, this.#getCommandPath());
557
- }
558
- if (idx + 1 < argv.length && !argv[idx + 1].startsWith('-')) {
559
- const value = argv[idx + 1];
560
- this.#applyValue(opt, value, opts);
561
- return idx + 2;
562
- }
563
- throw new CommanderError('MissingValue', `option "-${flag}" requires a value`, this.#getCommandPath());
564
- }
565
- return idx + 1;
566
- }
567
462
  #applyValue(opt, rawValue, opts) {
568
463
  const type = opt.type ?? 'string';
569
464
  let parsedValue = rawValue;
@@ -596,14 +491,14 @@ class Command {
596
491
  opts[opt.long] = parsedValue;
597
492
  }
598
493
  }
599
- #getMergedOptions() {
494
+ #getMergedOptions(includeVersion = !this.#parent) {
600
495
  const optionMap = new Map();
601
496
  const hasUserHelp = this.#options.some(o => o.long === 'help');
602
497
  const hasUserVersion = this.#options.some(o => o.long === 'version');
603
498
  if (!hasUserHelp) {
604
499
  optionMap.set('help', BUILTIN_HELP_OPTION);
605
500
  }
606
- if (!hasUserVersion) {
501
+ if (!hasUserVersion && includeVersion) {
607
502
  optionMap.set('version', BUILTIN_VERSION_OPTION);
608
503
  }
609
504
  for (const opt of this.#options) {
@@ -611,6 +506,26 @@ class Command {
611
506
  }
612
507
  return Array.from(optionMap.values());
613
508
  }
509
+ #validateMergedShortOptions(chain, includeRootVersion) {
510
+ const mergedByLong = new Map();
511
+ const rootCommand = chain[0];
512
+ for (const cmd of chain) {
513
+ const includeVersion = cmd === rootCommand && includeRootVersion;
514
+ for (const opt of cmd.#getMergedOptions(includeVersion)) {
515
+ mergedByLong.set(opt.long, opt);
516
+ }
517
+ }
518
+ const shortMap = new Map();
519
+ for (const opt of mergedByLong.values()) {
520
+ if (!opt.short)
521
+ continue;
522
+ const existingLong = shortMap.get(opt.short);
523
+ if (existingLong && existingLong !== opt.long) {
524
+ throw new CommanderError('OptionConflict', `short option "-${opt.short}" conflicts with "--${existingLong}"`, this.#getCommandPath());
525
+ }
526
+ shortMap.set(opt.short, opt.long);
527
+ }
528
+ }
614
529
  #validateOptionConfig(opt) {
615
530
  if (opt.long.startsWith('no-')) {
616
531
  throw new CommanderError('ConfigurationError', `option long name cannot start with "no-": "${opt.long}"`, this.#getCommandPath());
@@ -801,7 +716,15 @@ class Command {
801
716
  return result;
802
717
  }
803
718
  #getCommandPath() {
804
- return this.#name;
719
+ const parts = [];
720
+ let current = this;
721
+ while (current) {
722
+ if (current.#name) {
723
+ parts.unshift(current.#name);
724
+ }
725
+ current = current.#parent;
726
+ }
727
+ return parts.join(' ') || this.#name;
805
728
  }
806
729
  #tryConsumeLongOption(tokens, idx, optionByLong, opts) {
807
730
  const token = tokens[idx];
@@ -72,16 +72,19 @@ interface ICommandConfig {
72
72
  name?: string;
73
73
  /** Command description */
74
74
  description: string;
75
- /** Version (only effective for root command) */
75
+ /** Version (only effective for built-in root --version) */
76
76
  version?: string;
77
77
  /** Enable built-in "help" subcommand (only effective when command has subcommands) */
78
78
  help?: boolean;
79
+ /** Default reporter for this command (can be overridden by run params) */
80
+ reporter?: IReporter;
79
81
  }
80
82
  /** Forward declaration for Command class */
81
83
  interface ICommand {
82
84
  readonly name: string;
83
85
  readonly description: string;
84
86
  readonly version: string | undefined;
87
+ readonly parent?: ICommand;
85
88
  readonly options: IOption[];
86
89
  readonly arguments: IArgument[];
87
90
  }
@@ -115,7 +118,7 @@ interface IRunParams {
115
118
  argv: string[];
116
119
  /** Environment variables (usually process.env) */
117
120
  envs: Record<string, string | undefined>;
118
- /** Optional reporter for logging (defaults to console reporter) */
121
+ /** Optional reporter override (defaults to command's reporter or console reporter) */
119
122
  reporter?: IReporter;
120
123
  }
121
124
  /** parse() method result */
@@ -191,6 +194,7 @@ declare class Command implements ICommand {
191
194
  get name(): string;
192
195
  get description(): string;
193
196
  get version(): string | undefined;
197
+ get parent(): Command | undefined;
194
198
  get options(): IOption[];
195
199
  get arguments(): IArgument[];
196
200
  option(opt: IOption): this;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanghechen/commander",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "A minimal, type-safe command-line interface builder with fluent API",
5
5
  "author": {
6
6
  "name": "guanghechen",