@guanghechen/commander 2.1.0 → 3.0.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
@@ -3,7 +3,13 @@
3
3
  All notable changes to this project will be documented in this file. See
4
4
  [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
- ## 2.1.0 (2025-02-08)
6
+ ## 3.0.0 (2026-02-08)
7
+
8
+ ### BREAKING CHANGES
9
+
10
+ - Change subcommand registration API to `subcommand(name, cmd)`
11
+
12
+ ## 2.1.0 (2026-02-08)
7
13
 
8
14
  ### Features
9
15
 
@@ -30,4 +36,3 @@ All notable changes to this project will be documented in this file. See
30
36
  ### Features
31
37
 
32
38
  - 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,12 +108,18 @@ 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) {
@@ -313,12 +310,12 @@ class Command {
313
310
  if (showHelpSubcommand) {
314
311
  cmdLines.push({ name: 'help', desc: 'Show help for a command' });
315
312
  }
316
- for (const sub of this.#subcommands) {
317
- let name = sub.#name;
318
- if (sub.#aliases.length > 0) {
319
- name += `, ${sub.#aliases.join(', ')}`;
313
+ for (const entry of this.#subcommands) {
314
+ let name = entry.name;
315
+ if (entry.aliases.length > 0) {
316
+ name += `, ${entry.aliases.join(', ')}`;
320
317
  }
321
- cmdLines.push({ name, desc: sub.#description });
318
+ cmdLines.push({ name, desc: entry.command.#description });
322
319
  }
323
320
  const maxNameLen = Math.max(...cmdLines.map(l => l.name.length));
324
321
  for (const { name, desc } of cmdLines) {
@@ -345,9 +342,16 @@ class Command {
345
342
  return {
346
343
  name: this.#name,
347
344
  description: this.#description,
348
- aliases: this.#aliases,
345
+ aliases: [],
349
346
  options,
350
- subcommands: this.#subcommands.map(sub => sub.getCompletionMeta()),
347
+ subcommands: this.#subcommands.map(entry => {
348
+ const subMeta = entry.command.getCompletionMeta();
349
+ return {
350
+ ...subMeta,
351
+ name: entry.name,
352
+ aliases: entry.aliases,
353
+ };
354
+ }),
351
355
  };
352
356
  }
353
357
  #processHelpSubcommand(argv) {
@@ -359,8 +363,8 @@ class Command {
359
363
  return ['--help'];
360
364
  }
361
365
  const subName = argv[1];
362
- const sub = this.#subcommands.find(c => c.#name === subName || c.#aliases.includes(subName));
363
- if (sub) {
366
+ const entry = this.#subcommands.find(e => e.name === subName || e.aliases.includes(subName));
367
+ if (entry) {
364
368
  return [subName, '--help', ...argv.slice(2)];
365
369
  }
366
370
  return argv;
@@ -372,10 +376,10 @@ class Command {
372
376
  const token = argv[idx];
373
377
  if (token.startsWith('-'))
374
378
  break;
375
- const sub = current.#subcommands.find(c => c.#name === token || c.#aliases.includes(token));
376
- if (!sub)
379
+ const entry = current.#subcommands.find(e => e.name === token || e.aliases.includes(token));
380
+ if (!entry)
377
381
  break;
378
- current = sub;
382
+ current = entry.command;
379
383
  idx += 1;
380
384
  }
381
385
  return { command: current, remaining: argv.slice(idx) };
@@ -489,33 +493,17 @@ class Command {
489
493
  }
490
494
  }
491
495
  #getMergedOptions() {
492
- const ancestors = [];
493
- for (let node = this; node; node = node.#parent) {
494
- ancestors.unshift(node);
495
- }
496
496
  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'));
497
+ const hasUserHelp = this.#options.some(o => o.long === 'help');
498
+ const hasUserVersion = this.#options.some(o => o.long === 'version');
499
499
  if (!hasUserHelp) {
500
500
  optionMap.set('help', BUILTIN_HELP_OPTION);
501
501
  }
502
502
  if (!hasUserVersion) {
503
503
  optionMap.set('version', BUILTIN_VERSION_OPTION);
504
504
  }
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
- }
505
+ for (const opt of this.#options) {
506
+ optionMap.set(opt.long, opt);
519
507
  }
520
508
  return Array.from(optionMap.values());
521
509
  }
@@ -654,20 +642,15 @@ class Command {
654
642
  return result;
655
643
  }
656
644
  #getCommandPath() {
657
- const parts = [];
658
- for (let node = this; node; node = node.#parent) {
659
- parts.unshift(node.#name);
660
- }
661
- return parts.join(' ');
645
+ return this.#name;
662
646
  }
663
647
  }
664
648
 
665
649
  class CompletionCommand extends Command {
666
650
  constructor(root, config) {
667
- const name = config.name ?? 'completion';
668
651
  const paths = config.paths;
652
+ const programName = config.programName ?? root.name;
669
653
  super({
670
- name,
671
654
  description: 'Generate shell completion script',
672
655
  });
673
656
  this.option({
@@ -694,7 +677,6 @@ class CompletionCommand extends Command {
694
677
  })
695
678
  .action(({ opts }) => {
696
679
  const meta = root.getCompletionMeta();
697
- const programName = root.name;
698
680
  const selectedShells = [
699
681
  opts['bash'] && 'bash',
700
682
  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,12 +86,18 @@ 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) {
@@ -291,12 +288,12 @@ class Command {
291
288
  if (showHelpSubcommand) {
292
289
  cmdLines.push({ name: 'help', desc: 'Show help for a command' });
293
290
  }
294
- for (const sub of this.#subcommands) {
295
- let name = sub.#name;
296
- if (sub.#aliases.length > 0) {
297
- name += `, ${sub.#aliases.join(', ')}`;
291
+ for (const entry of this.#subcommands) {
292
+ let name = entry.name;
293
+ if (entry.aliases.length > 0) {
294
+ name += `, ${entry.aliases.join(', ')}`;
298
295
  }
299
- cmdLines.push({ name, desc: sub.#description });
296
+ cmdLines.push({ name, desc: entry.command.#description });
300
297
  }
301
298
  const maxNameLen = Math.max(...cmdLines.map(l => l.name.length));
302
299
  for (const { name, desc } of cmdLines) {
@@ -323,9 +320,16 @@ class Command {
323
320
  return {
324
321
  name: this.#name,
325
322
  description: this.#description,
326
- aliases: this.#aliases,
323
+ aliases: [],
327
324
  options,
328
- subcommands: this.#subcommands.map(sub => sub.getCompletionMeta()),
325
+ subcommands: this.#subcommands.map(entry => {
326
+ const subMeta = entry.command.getCompletionMeta();
327
+ return {
328
+ ...subMeta,
329
+ name: entry.name,
330
+ aliases: entry.aliases,
331
+ };
332
+ }),
329
333
  };
330
334
  }
331
335
  #processHelpSubcommand(argv) {
@@ -337,8 +341,8 @@ class Command {
337
341
  return ['--help'];
338
342
  }
339
343
  const subName = argv[1];
340
- const sub = this.#subcommands.find(c => c.#name === subName || c.#aliases.includes(subName));
341
- if (sub) {
344
+ const entry = this.#subcommands.find(e => e.name === subName || e.aliases.includes(subName));
345
+ if (entry) {
342
346
  return [subName, '--help', ...argv.slice(2)];
343
347
  }
344
348
  return argv;
@@ -350,10 +354,10 @@ class Command {
350
354
  const token = argv[idx];
351
355
  if (token.startsWith('-'))
352
356
  break;
353
- const sub = current.#subcommands.find(c => c.#name === token || c.#aliases.includes(token));
354
- if (!sub)
357
+ const entry = current.#subcommands.find(e => e.name === token || e.aliases.includes(token));
358
+ if (!entry)
355
359
  break;
356
- current = sub;
360
+ current = entry.command;
357
361
  idx += 1;
358
362
  }
359
363
  return { command: current, remaining: argv.slice(idx) };
@@ -467,33 +471,17 @@ class Command {
467
471
  }
468
472
  }
469
473
  #getMergedOptions() {
470
- const ancestors = [];
471
- for (let node = this; node; node = node.#parent) {
472
- ancestors.unshift(node);
473
- }
474
474
  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'));
475
+ const hasUserHelp = this.#options.some(o => o.long === 'help');
476
+ const hasUserVersion = this.#options.some(o => o.long === 'version');
477
477
  if (!hasUserHelp) {
478
478
  optionMap.set('help', BUILTIN_HELP_OPTION);
479
479
  }
480
480
  if (!hasUserVersion) {
481
481
  optionMap.set('version', BUILTIN_VERSION_OPTION);
482
482
  }
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
- }
483
+ for (const opt of this.#options) {
484
+ optionMap.set(opt.long, opt);
497
485
  }
498
486
  return Array.from(optionMap.values());
499
487
  }
@@ -632,20 +620,15 @@ class Command {
632
620
  return result;
633
621
  }
634
622
  #getCommandPath() {
635
- const parts = [];
636
- for (let node = this; node; node = node.#parent) {
637
- parts.unshift(node.#name);
638
- }
639
- return parts.join(' ');
623
+ return this.#name;
640
624
  }
641
625
  }
642
626
 
643
627
  class CompletionCommand extends Command {
644
628
  constructor(root, config) {
645
- const name = config.name ?? 'completion';
646
629
  const paths = config.paths;
630
+ const programName = config.programName ?? root.name;
647
631
  super({
648
- name,
649
632
  description: 'Generate shell completion script',
650
633
  });
651
634
  this.option({
@@ -672,7 +655,6 @@ class CompletionCommand extends Command {
672
655
  })
673
656
  .action(({ opts }) => {
674
657
  const meta = root.getCompletionMeta();
675
- const programName = root.name;
676
658
  const selectedShells = [
677
659
  opts['bash'] && 'bash',
678
660
  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
  }
@@ -155,8 +151,8 @@ interface ICompletionPaths {
155
151
  }
156
152
  /** CompletionCommand configuration */
157
153
  interface ICompletionCommandConfig {
158
- /** Subcommand name, defaults to 'completion' */
159
- name?: string;
154
+ /** Program name for completion scripts (defaults to root.name) */
155
+ programName?: string;
160
156
  /** Default completion file paths for each shell (required for --write support) */
161
157
  paths: ICompletionPaths;
162
158
  }
@@ -171,16 +167,14 @@ declare class Command implements ICommand {
171
167
  #private;
172
168
  constructor(config: ICommandConfig);
173
169
  get name(): string;
174
- get aliases(): string[];
175
170
  get description(): string;
176
171
  get version(): string | undefined;
177
- get parent(): Command | undefined;
178
172
  get options(): IOption[];
179
173
  get arguments(): IArgument[];
180
174
  option(opt: IOption): this;
181
175
  argument(arg: IArgument): this;
182
176
  action(fn: IAction): this;
183
- subcommand(cmd: Command): this;
177
+ subcommand(name: string, cmd: Command): this;
184
178
  run(params: IRunParams): Promise<void>;
185
179
  parse(argv: string[]): IParseResult;
186
180
  formatHelp(): string;
@@ -199,7 +193,7 @@ declare class Command implements ICommand {
199
193
  * @example
200
194
  * ```typescript
201
195
  * const root = new Command({ name: 'mycli', description: 'My CLI' })
202
- * root.subcommand(new CompletionCommand(root, {
196
+ * root.subcommand('completion', new CompletionCommand(root, {
203
197
  * paths: {
204
198
  * bash: `~/.local/share/bash-completion/completions/mycli`,
205
199
  * fish: `~/.config/fish/completions/mycli.fish`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanghechen/commander",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
4
4
  "description": "A minimal, type-safe command-line interface builder with fluent API",
5
5
  "author": {
6
6
  "name": "guanghechen",