@guanghechen/commander 2.0.1 → 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 ADDED
@@ -0,0 +1,38 @@
1
+ # Change Log
2
+
3
+ All notable changes to this project will be documented in this file. See
4
+ [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
+
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)
13
+
14
+ ### Features
15
+
16
+ - Add `--write` option to `CompletionCommand` for direct file output
17
+ - Add `help` subcommand support for commands with subcommands
18
+ - Detect `--help`/`--version` before parsing to avoid required argument errors
19
+ - Add `#normalizeArgv()` preprocessing to simplify `--no-*` option handling
20
+ - Add `implements ICommand` for explicit interface implementation
21
+
22
+ ## 2.0.1 (2025-02-07)
23
+
24
+ ### Documentation
25
+
26
+ - Update README.md
27
+
28
+ ### Miscellaneous
29
+
30
+ - Add LICENSE file
31
+ - Clean up build configs and standardize package exports
32
+ - Migrate from lerna to changesets
33
+
34
+ ## 2.0.0 (2025-01-15)
35
+
36
+ ### Features
37
+
38
+ - Initial stable release: A minimal, type-safe command-line interface builder with fluent API
package/lib/cjs/index.cjs CHANGED
@@ -1,5 +1,28 @@
1
1
  'use strict';
2
2
 
3
+ var fs = require('node:fs');
4
+ var path = require('node:path');
5
+
6
+ function _interopNamespaceDefault(e) {
7
+ var n = Object.create(null);
8
+ if (e) {
9
+ Object.keys(e).forEach(function (k) {
10
+ if (k !== 'default') {
11
+ var d = Object.getOwnPropertyDescriptor(e, k);
12
+ Object.defineProperty(n, k, d.get ? d : {
13
+ enumerable: true,
14
+ get: function () { return e[k]; }
15
+ });
16
+ }
17
+ });
18
+ }
19
+ n.default = e;
20
+ return Object.freeze(n);
21
+ }
22
+
23
+ var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
24
+ var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
25
+
3
26
  class CommanderError extends Error {
4
27
  kind;
5
28
  commandPath;
@@ -44,32 +67,25 @@ class Command {
44
67
  #name;
45
68
  #description;
46
69
  #version;
47
- #aliases;
70
+ #helpSubcommandEnabled;
48
71
  #options = [];
49
72
  #arguments = [];
50
73
  #subcommands = [];
51
74
  #action;
52
- #parent;
53
75
  constructor(config) {
54
- this.#name = config.name;
76
+ this.#name = config.name ?? '';
55
77
  this.#description = config.description;
56
78
  this.#version = config.version;
57
- this.#aliases = config.aliases ?? [];
79
+ this.#helpSubcommandEnabled = config.help ?? false;
58
80
  }
59
81
  get name() {
60
82
  return this.#name;
61
83
  }
62
- get aliases() {
63
- return this.#aliases;
64
- }
65
84
  get description() {
66
85
  return this.#description;
67
86
  }
68
87
  get version() {
69
- return this.#version ?? this.#parent?.version;
70
- }
71
- get parent() {
72
- return this.#parent;
88
+ return this.#version;
73
89
  }
74
90
  get options() {
75
91
  return [...this.#options];
@@ -92,33 +108,43 @@ class Command {
92
108
  this.#action = fn;
93
109
  return this;
94
110
  }
95
- subcommand(cmd) {
96
- cmd.#parent = this;
97
- this.#subcommands.push(cmd);
111
+ subcommand(name, cmd) {
112
+ if (this.#helpSubcommandEnabled && name === 'help') {
113
+ throw new CommanderError('ConfigurationError', '"help" is a reserved subcommand name when help subcommand is enabled', this.#getCommandPath());
114
+ }
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
+ }
98
123
  return this;
99
124
  }
100
125
  async run(params) {
101
126
  const { argv, envs, reporter } = params;
102
127
  try {
103
- const { command, remaining } = this.#route(argv);
104
- const { opts, args } = command.parse(remaining);
105
- const ctx = {
106
- cmd: command,
107
- envs,
108
- reporter: reporter ?? new DefaultReporter(),
109
- argv,
110
- };
128
+ const processedArgv = this.#processHelpSubcommand(argv);
129
+ const { command, remaining } = this.#route(processedArgv);
111
130
  const allOptions = command.#getMergedOptions();
112
131
  const hasUserHelp = allOptions.some(o => o.long === 'help' && !command.#isBuiltinOption(o));
113
132
  const hasUserVersion = allOptions.some(o => o.long === 'version' && !command.#isBuiltinOption(o));
114
- if (!hasUserHelp && opts['help'] === true) {
133
+ if (!hasUserHelp && command.#hasHelpFlag(remaining, allOptions)) {
115
134
  console.log(command.formatHelp());
116
135
  return;
117
136
  }
118
- if (!hasUserVersion && opts['version'] === true) {
137
+ if (!hasUserVersion && command.#hasVersionFlag(remaining, allOptions)) {
119
138
  console.log(command.version ?? 'unknown');
120
139
  return;
121
140
  }
141
+ const { opts, args } = command.parse(remaining);
142
+ const ctx = {
143
+ cmd: command,
144
+ envs,
145
+ reporter: reporter ?? new DefaultReporter(),
146
+ argv,
147
+ };
122
148
  for (const opt of allOptions) {
123
149
  if (opt.apply && opts[opt.long] !== undefined) {
124
150
  opt.apply(opts[opt.long], ctx);
@@ -177,16 +203,8 @@ class Command {
177
203
  opts[opt.long] = result.value;
178
204
  remaining = result.remaining;
179
205
  }
180
- const optionByLong = new Map();
181
- const optionByShort = new Map();
182
- for (const opt of allOptions) {
183
- if (!opt.resolver) {
184
- optionByLong.set(opt.long, opt);
185
- if (opt.short) {
186
- optionByShort.set(opt.short, opt);
187
- }
188
- }
189
- }
206
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(allOptions, true);
207
+ remaining = this.#normalizeArgv(remaining, booleanOptions);
190
208
  let i = 0;
191
209
  while (i < remaining.length) {
192
210
  const token = remaining[i];
@@ -285,15 +303,19 @@ class Command {
285
303
  }
286
304
  lines.push('');
287
305
  }
306
+ const showHelpSubcommand = this.#helpSubcommandEnabled && this.#subcommands.length > 0;
288
307
  if (this.#subcommands.length > 0) {
289
308
  lines.push('Commands:');
290
309
  const cmdLines = [];
291
- for (const sub of this.#subcommands) {
292
- let name = sub.#name;
293
- if (sub.#aliases.length > 0) {
294
- name += `, ${sub.#aliases.join(', ')}`;
310
+ if (showHelpSubcommand) {
311
+ cmdLines.push({ name: 'help', desc: 'Show help for a command' });
312
+ }
313
+ for (const entry of this.#subcommands) {
314
+ let name = entry.name;
315
+ if (entry.aliases.length > 0) {
316
+ name += `, ${entry.aliases.join(', ')}`;
295
317
  }
296
- cmdLines.push({ name, desc: sub.#description });
318
+ cmdLines.push({ name, desc: entry.command.#description });
297
319
  }
298
320
  const maxNameLen = Math.max(...cmdLines.map(l => l.name.length));
299
321
  for (const { name, desc } of cmdLines) {
@@ -320,11 +342,33 @@ class Command {
320
342
  return {
321
343
  name: this.#name,
322
344
  description: this.#description,
323
- aliases: this.#aliases,
345
+ aliases: [],
324
346
  options,
325
- 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
+ }),
326
355
  };
327
356
  }
357
+ #processHelpSubcommand(argv) {
358
+ if (!this.#helpSubcommandEnabled || this.#subcommands.length === 0)
359
+ return argv;
360
+ if (argv.length < 1 || argv[0] !== 'help')
361
+ return argv;
362
+ if (argv.length === 1) {
363
+ return ['--help'];
364
+ }
365
+ const subName = argv[1];
366
+ const entry = this.#subcommands.find(e => e.name === subName || e.aliases.includes(subName));
367
+ if (entry) {
368
+ return [subName, '--help', ...argv.slice(2)];
369
+ }
370
+ return argv;
371
+ }
328
372
  #route(argv) {
329
373
  let current = this;
330
374
  let idx = 0;
@@ -332,10 +376,10 @@ class Command {
332
376
  const token = argv[idx];
333
377
  if (token.startsWith('-'))
334
378
  break;
335
- const sub = current.#subcommands.find(c => c.#name === token || c.#aliases.includes(token));
336
- if (!sub)
379
+ const entry = current.#subcommands.find(e => e.name === token || e.aliases.includes(token));
380
+ if (!entry)
337
381
  break;
338
- current = sub;
382
+ current = entry.command;
339
383
  idx += 1;
340
384
  }
341
385
  return { command: current, remaining: argv.slice(idx) };
@@ -352,17 +396,6 @@ class Command {
352
396
  else {
353
397
  optName = token.slice(2);
354
398
  }
355
- if (optName.startsWith('no-')) {
356
- const actualName = optName.slice(3);
357
- const opt = optionByLong.get(actualName);
358
- if (opt && opt.type === 'boolean') {
359
- if (inlineValue !== undefined) {
360
- throw new CommanderError('InvalidBooleanValue', `"--no-${actualName}" does not accept a value`, this.#getCommandPath());
361
- }
362
- opts[actualName] = false;
363
- return idx + 1;
364
- }
365
- }
366
399
  const opt = optionByLong.get(optName);
367
400
  if (!opt) {
368
401
  throw new CommanderError('UnknownOption', `unknown option "--${optName}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
@@ -429,7 +462,7 @@ class Command {
429
462
  }
430
463
  #applyValue(opt, rawValue, opts) {
431
464
  const type = opt.type ?? 'string';
432
- let parsedValue;
465
+ let parsedValue = rawValue;
433
466
  if (opt.coerce) {
434
467
  parsedValue = opt.coerce(rawValue);
435
468
  }
@@ -448,8 +481,6 @@ class Command {
448
481
  parsedValue = num;
449
482
  break;
450
483
  }
451
- default:
452
- parsedValue = rawValue;
453
484
  }
454
485
  }
455
486
  if (type === 'string[]' || type === 'number[]') {
@@ -462,33 +493,17 @@ class Command {
462
493
  }
463
494
  }
464
495
  #getMergedOptions() {
465
- const ancestors = [];
466
- for (let node = this; node; node = node.#parent) {
467
- ancestors.unshift(node);
468
- }
469
496
  const optionMap = new Map();
470
- const hasUserHelp = ancestors.some(c => c.#options.some(o => o.long === 'help'));
471
- 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');
472
499
  if (!hasUserHelp) {
473
500
  optionMap.set('help', BUILTIN_HELP_OPTION);
474
501
  }
475
502
  if (!hasUserVersion) {
476
503
  optionMap.set('version', BUILTIN_VERSION_OPTION);
477
504
  }
478
- for (const ancestor of ancestors) {
479
- for (const opt of ancestor.#options) {
480
- optionMap.set(opt.long, opt);
481
- }
482
- }
483
- const shortToLong = new Map();
484
- for (const [long, opt] of optionMap) {
485
- if (opt.short) {
486
- const existing = shortToLong.get(opt.short);
487
- if (existing && existing !== long) {
488
- throw new CommanderError('OptionConflict', `short option "-${opt.short}" is used by both "--${existing}" and "--${long}"`, this.#getCommandPath());
489
- }
490
- shortToLong.set(opt.short, long);
491
- }
505
+ for (const opt of this.#options) {
506
+ optionMap.set(opt.long, opt);
492
507
  }
493
508
  return Array.from(optionMap.values());
494
509
  }
@@ -533,20 +548,109 @@ class Command {
533
548
  #isBuiltinOption(opt) {
534
549
  return opt === BUILTIN_HELP_OPTION || opt === BUILTIN_VERSION_OPTION;
535
550
  }
536
- #getCommandPath() {
537
- const parts = [];
538
- for (let node = this; node; node = node.#parent) {
539
- parts.unshift(node.#name);
551
+ #buildOptionMaps(allOptions, excludeResolver = false) {
552
+ const optionByLong = new Map();
553
+ const optionByShort = new Map();
554
+ const booleanOptions = new Set();
555
+ for (const opt of allOptions) {
556
+ if (excludeResolver && opt.resolver)
557
+ continue;
558
+ optionByLong.set(opt.long, opt);
559
+ if (opt.short) {
560
+ optionByShort.set(opt.short, opt);
561
+ }
562
+ if (opt.type === 'boolean') {
563
+ booleanOptions.add(opt.long);
564
+ }
565
+ }
566
+ return { optionByLong, optionByShort, booleanOptions };
567
+ }
568
+ #hasHelpFlag(argv, allOptions) {
569
+ return this.#hasBuiltinFlag(argv, 'help', 'h', allOptions);
570
+ }
571
+ #hasVersionFlag(argv, allOptions) {
572
+ return this.#hasBuiltinFlag(argv, 'version', 'V', allOptions);
573
+ }
574
+ #hasBuiltinFlag(argv, flagLong, flagShort, allOptions) {
575
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(allOptions);
576
+ const normalizedArgv = this.#normalizeArgv(argv, booleanOptions);
577
+ for (let i = 0; i < normalizedArgv.length; i++) {
578
+ const arg = normalizedArgv[i];
579
+ if (arg === '--') {
580
+ break;
581
+ }
582
+ if (arg === `--${flagLong}` || (flagShort && arg === `-${flagShort}`)) {
583
+ return true;
584
+ }
585
+ if (this.#optionConsumesNextValue(arg, optionByLong, optionByShort)) {
586
+ i += 1;
587
+ }
540
588
  }
541
- return parts.join(' ');
589
+ return false;
590
+ }
591
+ #optionConsumesNextValue(arg, optionByLong, optionByShort) {
592
+ if (arg.startsWith('--')) {
593
+ const eqIdx = arg.indexOf('=');
594
+ if (eqIdx !== -1) {
595
+ return false;
596
+ }
597
+ const optName = arg.slice(2);
598
+ const opt = optionByLong.get(optName);
599
+ if (!opt) {
600
+ return false;
601
+ }
602
+ const type = opt.type ?? 'string';
603
+ return type !== 'boolean';
604
+ }
605
+ if (arg.startsWith('-') && arg.length === 2) {
606
+ const opt = optionByShort.get(arg[1]);
607
+ if (!opt) {
608
+ return false;
609
+ }
610
+ const type = opt.type ?? 'string';
611
+ return type !== 'boolean';
612
+ }
613
+ return false;
614
+ }
615
+ #normalizeArgv(argv, booleanOptions) {
616
+ const result = [];
617
+ let seenDoubleDash = false;
618
+ for (const arg of argv) {
619
+ if (arg === '--') {
620
+ seenDoubleDash = true;
621
+ result.push(arg);
622
+ continue;
623
+ }
624
+ if (!seenDoubleDash && arg.startsWith('--no-')) {
625
+ const eqIdx = arg.indexOf('=');
626
+ if (eqIdx !== -1) {
627
+ const optName = arg.slice(5, eqIdx);
628
+ if (booleanOptions.has(optName)) {
629
+ throw new CommanderError('InvalidBooleanValue', `"--no-${optName}" does not accept a value`, this.#getCommandPath());
630
+ }
631
+ }
632
+ else {
633
+ const optName = arg.slice(5);
634
+ if (booleanOptions.has(optName)) {
635
+ result.push(`--${optName}=false`);
636
+ continue;
637
+ }
638
+ }
639
+ }
640
+ result.push(arg);
641
+ }
642
+ return result;
643
+ }
644
+ #getCommandPath() {
645
+ return this.#name;
542
646
  }
543
647
  }
544
648
 
545
649
  class CompletionCommand extends Command {
546
650
  constructor(root, config) {
547
- const name = config?.name ?? 'completion';
651
+ const paths = config.paths;
652
+ const programName = config.programName ?? root.name;
548
653
  super({
549
- name,
550
654
  description: 'Generate shell completion script',
551
655
  });
552
656
  this.option({
@@ -563,10 +667,16 @@ class CompletionCommand extends Command {
563
667
  long: 'pwsh',
564
668
  type: 'boolean',
565
669
  description: 'Generate PowerShell completion script',
670
+ })
671
+ .option({
672
+ long: 'write',
673
+ short: 'w',
674
+ type: 'string',
675
+ description: 'Write to file (default path if no value given)',
676
+ resolver: argv => resolveOptionalStringOption(argv, 'write', 'w'),
566
677
  })
567
678
  .action(({ opts }) => {
568
679
  const meta = root.getCompletionMeta();
569
- const programName = root.name;
570
680
  const selectedShells = [
571
681
  opts['bash'] && 'bash',
572
682
  opts['fish'] && 'fish',
@@ -575,25 +685,89 @@ class CompletionCommand extends Command {
575
685
  if (selectedShells.length === 0) {
576
686
  console.error('Please specify a shell: --bash, --fish, or --pwsh');
577
687
  process.exit(1);
688
+ return;
578
689
  }
579
690
  if (selectedShells.length > 1) {
580
691
  console.error('Please specify only one shell option');
581
692
  process.exit(1);
693
+ return;
582
694
  }
583
- switch (selectedShells[0]) {
695
+ const shell = selectedShells[0];
696
+ let script;
697
+ switch (shell) {
584
698
  case 'bash':
585
- console.log(new BashCompletion(meta, programName).generate());
699
+ script = new BashCompletion(meta, programName).generate();
586
700
  break;
587
701
  case 'fish':
588
- console.log(new FishCompletion(meta, programName).generate());
702
+ script = new FishCompletion(meta, programName).generate();
589
703
  break;
590
704
  case 'pwsh':
591
- console.log(new PwshCompletion(meta, programName).generate());
705
+ script = new PwshCompletion(meta, programName).generate();
592
706
  break;
593
707
  }
708
+ const writeOpt = opts['write'];
709
+ if (writeOpt !== undefined) {
710
+ const filePath = typeof writeOpt === 'string' && writeOpt !== '' ? writeOpt : paths[shell];
711
+ const expandedPath = expandHome(filePath);
712
+ const dir = path__namespace.dirname(expandedPath);
713
+ if (!fs__namespace.existsSync(dir)) {
714
+ fs__namespace.mkdirSync(dir, { recursive: true });
715
+ }
716
+ fs__namespace.writeFileSync(expandedPath, script, 'utf-8');
717
+ console.log(`Completion script written to: ${expandedPath}`);
718
+ }
719
+ else {
720
+ console.log(script);
721
+ }
594
722
  });
595
723
  }
596
724
  }
725
+ function expandHome(filepath) {
726
+ if (filepath.startsWith('~/') || filepath === '~') {
727
+ const home = process.env['HOME'] || process.env['USERPROFILE'] || '';
728
+ return filepath.replace(/^~/, home);
729
+ }
730
+ return filepath;
731
+ }
732
+ function resolveOptionalStringOption(argv, longName, shortName) {
733
+ const remaining = [];
734
+ let value;
735
+ for (let i = 0; i < argv.length; i++) {
736
+ const arg = argv[i];
737
+ if (arg.startsWith(`--${longName}=`)) {
738
+ value = arg.slice(`--${longName}=`.length);
739
+ continue;
740
+ }
741
+ if (arg === `--${longName}`) {
742
+ const next = argv[i + 1];
743
+ if (next !== undefined && !next.startsWith('-')) {
744
+ value = next;
745
+ i += 1;
746
+ }
747
+ else {
748
+ value = '';
749
+ }
750
+ continue;
751
+ }
752
+ if (arg.startsWith(`-${shortName}=`)) {
753
+ value = arg.slice(`-${shortName}=`.length);
754
+ continue;
755
+ }
756
+ if (arg === `-${shortName}`) {
757
+ const next = argv[i + 1];
758
+ if (next !== undefined && !next.startsWith('-')) {
759
+ value = next;
760
+ i += 1;
761
+ }
762
+ else {
763
+ value = '';
764
+ }
765
+ continue;
766
+ }
767
+ remaining.push(arg);
768
+ }
769
+ return { value, remaining };
770
+ }
597
771
  class BashCompletion {
598
772
  #meta;
599
773
  #programName;
package/lib/esm/index.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+
1
4
  class CommanderError extends Error {
2
5
  kind;
3
6
  commandPath;
@@ -42,32 +45,25 @@ class Command {
42
45
  #name;
43
46
  #description;
44
47
  #version;
45
- #aliases;
48
+ #helpSubcommandEnabled;
46
49
  #options = [];
47
50
  #arguments = [];
48
51
  #subcommands = [];
49
52
  #action;
50
- #parent;
51
53
  constructor(config) {
52
- this.#name = config.name;
54
+ this.#name = config.name ?? '';
53
55
  this.#description = config.description;
54
56
  this.#version = config.version;
55
- this.#aliases = config.aliases ?? [];
57
+ this.#helpSubcommandEnabled = config.help ?? false;
56
58
  }
57
59
  get name() {
58
60
  return this.#name;
59
61
  }
60
- get aliases() {
61
- return this.#aliases;
62
- }
63
62
  get description() {
64
63
  return this.#description;
65
64
  }
66
65
  get version() {
67
- return this.#version ?? this.#parent?.version;
68
- }
69
- get parent() {
70
- return this.#parent;
66
+ return this.#version;
71
67
  }
72
68
  get options() {
73
69
  return [...this.#options];
@@ -90,33 +86,43 @@ class Command {
90
86
  this.#action = fn;
91
87
  return this;
92
88
  }
93
- subcommand(cmd) {
94
- cmd.#parent = this;
95
- this.#subcommands.push(cmd);
89
+ subcommand(name, cmd) {
90
+ if (this.#helpSubcommandEnabled && name === 'help') {
91
+ throw new CommanderError('ConfigurationError', '"help" is a reserved subcommand name when help subcommand is enabled', this.#getCommandPath());
92
+ }
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
+ }
96
101
  return this;
97
102
  }
98
103
  async run(params) {
99
104
  const { argv, envs, reporter } = params;
100
105
  try {
101
- const { command, remaining } = this.#route(argv);
102
- const { opts, args } = command.parse(remaining);
103
- const ctx = {
104
- cmd: command,
105
- envs,
106
- reporter: reporter ?? new DefaultReporter(),
107
- argv,
108
- };
106
+ const processedArgv = this.#processHelpSubcommand(argv);
107
+ const { command, remaining } = this.#route(processedArgv);
109
108
  const allOptions = command.#getMergedOptions();
110
109
  const hasUserHelp = allOptions.some(o => o.long === 'help' && !command.#isBuiltinOption(o));
111
110
  const hasUserVersion = allOptions.some(o => o.long === 'version' && !command.#isBuiltinOption(o));
112
- if (!hasUserHelp && opts['help'] === true) {
111
+ if (!hasUserHelp && command.#hasHelpFlag(remaining, allOptions)) {
113
112
  console.log(command.formatHelp());
114
113
  return;
115
114
  }
116
- if (!hasUserVersion && opts['version'] === true) {
115
+ if (!hasUserVersion && command.#hasVersionFlag(remaining, allOptions)) {
117
116
  console.log(command.version ?? 'unknown');
118
117
  return;
119
118
  }
119
+ const { opts, args } = command.parse(remaining);
120
+ const ctx = {
121
+ cmd: command,
122
+ envs,
123
+ reporter: reporter ?? new DefaultReporter(),
124
+ argv,
125
+ };
120
126
  for (const opt of allOptions) {
121
127
  if (opt.apply && opts[opt.long] !== undefined) {
122
128
  opt.apply(opts[opt.long], ctx);
@@ -175,16 +181,8 @@ class Command {
175
181
  opts[opt.long] = result.value;
176
182
  remaining = result.remaining;
177
183
  }
178
- const optionByLong = new Map();
179
- const optionByShort = new Map();
180
- for (const opt of allOptions) {
181
- if (!opt.resolver) {
182
- optionByLong.set(opt.long, opt);
183
- if (opt.short) {
184
- optionByShort.set(opt.short, opt);
185
- }
186
- }
187
- }
184
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(allOptions, true);
185
+ remaining = this.#normalizeArgv(remaining, booleanOptions);
188
186
  let i = 0;
189
187
  while (i < remaining.length) {
190
188
  const token = remaining[i];
@@ -283,15 +281,19 @@ class Command {
283
281
  }
284
282
  lines.push('');
285
283
  }
284
+ const showHelpSubcommand = this.#helpSubcommandEnabled && this.#subcommands.length > 0;
286
285
  if (this.#subcommands.length > 0) {
287
286
  lines.push('Commands:');
288
287
  const cmdLines = [];
289
- for (const sub of this.#subcommands) {
290
- let name = sub.#name;
291
- if (sub.#aliases.length > 0) {
292
- name += `, ${sub.#aliases.join(', ')}`;
288
+ if (showHelpSubcommand) {
289
+ cmdLines.push({ name: 'help', desc: 'Show help for a command' });
290
+ }
291
+ for (const entry of this.#subcommands) {
292
+ let name = entry.name;
293
+ if (entry.aliases.length > 0) {
294
+ name += `, ${entry.aliases.join(', ')}`;
293
295
  }
294
- cmdLines.push({ name, desc: sub.#description });
296
+ cmdLines.push({ name, desc: entry.command.#description });
295
297
  }
296
298
  const maxNameLen = Math.max(...cmdLines.map(l => l.name.length));
297
299
  for (const { name, desc } of cmdLines) {
@@ -318,11 +320,33 @@ class Command {
318
320
  return {
319
321
  name: this.#name,
320
322
  description: this.#description,
321
- aliases: this.#aliases,
323
+ aliases: [],
322
324
  options,
323
- 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
+ }),
324
333
  };
325
334
  }
335
+ #processHelpSubcommand(argv) {
336
+ if (!this.#helpSubcommandEnabled || this.#subcommands.length === 0)
337
+ return argv;
338
+ if (argv.length < 1 || argv[0] !== 'help')
339
+ return argv;
340
+ if (argv.length === 1) {
341
+ return ['--help'];
342
+ }
343
+ const subName = argv[1];
344
+ const entry = this.#subcommands.find(e => e.name === subName || e.aliases.includes(subName));
345
+ if (entry) {
346
+ return [subName, '--help', ...argv.slice(2)];
347
+ }
348
+ return argv;
349
+ }
326
350
  #route(argv) {
327
351
  let current = this;
328
352
  let idx = 0;
@@ -330,10 +354,10 @@ class Command {
330
354
  const token = argv[idx];
331
355
  if (token.startsWith('-'))
332
356
  break;
333
- const sub = current.#subcommands.find(c => c.#name === token || c.#aliases.includes(token));
334
- if (!sub)
357
+ const entry = current.#subcommands.find(e => e.name === token || e.aliases.includes(token));
358
+ if (!entry)
335
359
  break;
336
- current = sub;
360
+ current = entry.command;
337
361
  idx += 1;
338
362
  }
339
363
  return { command: current, remaining: argv.slice(idx) };
@@ -350,17 +374,6 @@ class Command {
350
374
  else {
351
375
  optName = token.slice(2);
352
376
  }
353
- if (optName.startsWith('no-')) {
354
- const actualName = optName.slice(3);
355
- const opt = optionByLong.get(actualName);
356
- if (opt && opt.type === 'boolean') {
357
- if (inlineValue !== undefined) {
358
- throw new CommanderError('InvalidBooleanValue', `"--no-${actualName}" does not accept a value`, this.#getCommandPath());
359
- }
360
- opts[actualName] = false;
361
- return idx + 1;
362
- }
363
- }
364
377
  const opt = optionByLong.get(optName);
365
378
  if (!opt) {
366
379
  throw new CommanderError('UnknownOption', `unknown option "--${optName}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
@@ -427,7 +440,7 @@ class Command {
427
440
  }
428
441
  #applyValue(opt, rawValue, opts) {
429
442
  const type = opt.type ?? 'string';
430
- let parsedValue;
443
+ let parsedValue = rawValue;
431
444
  if (opt.coerce) {
432
445
  parsedValue = opt.coerce(rawValue);
433
446
  }
@@ -446,8 +459,6 @@ class Command {
446
459
  parsedValue = num;
447
460
  break;
448
461
  }
449
- default:
450
- parsedValue = rawValue;
451
462
  }
452
463
  }
453
464
  if (type === 'string[]' || type === 'number[]') {
@@ -460,33 +471,17 @@ class Command {
460
471
  }
461
472
  }
462
473
  #getMergedOptions() {
463
- const ancestors = [];
464
- for (let node = this; node; node = node.#parent) {
465
- ancestors.unshift(node);
466
- }
467
474
  const optionMap = new Map();
468
- const hasUserHelp = ancestors.some(c => c.#options.some(o => o.long === 'help'));
469
- 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');
470
477
  if (!hasUserHelp) {
471
478
  optionMap.set('help', BUILTIN_HELP_OPTION);
472
479
  }
473
480
  if (!hasUserVersion) {
474
481
  optionMap.set('version', BUILTIN_VERSION_OPTION);
475
482
  }
476
- for (const ancestor of ancestors) {
477
- for (const opt of ancestor.#options) {
478
- optionMap.set(opt.long, opt);
479
- }
480
- }
481
- const shortToLong = new Map();
482
- for (const [long, opt] of optionMap) {
483
- if (opt.short) {
484
- const existing = shortToLong.get(opt.short);
485
- if (existing && existing !== long) {
486
- throw new CommanderError('OptionConflict', `short option "-${opt.short}" is used by both "--${existing}" and "--${long}"`, this.#getCommandPath());
487
- }
488
- shortToLong.set(opt.short, long);
489
- }
483
+ for (const opt of this.#options) {
484
+ optionMap.set(opt.long, opt);
490
485
  }
491
486
  return Array.from(optionMap.values());
492
487
  }
@@ -531,20 +526,109 @@ class Command {
531
526
  #isBuiltinOption(opt) {
532
527
  return opt === BUILTIN_HELP_OPTION || opt === BUILTIN_VERSION_OPTION;
533
528
  }
534
- #getCommandPath() {
535
- const parts = [];
536
- for (let node = this; node; node = node.#parent) {
537
- parts.unshift(node.#name);
529
+ #buildOptionMaps(allOptions, excludeResolver = false) {
530
+ const optionByLong = new Map();
531
+ const optionByShort = new Map();
532
+ const booleanOptions = new Set();
533
+ for (const opt of allOptions) {
534
+ if (excludeResolver && opt.resolver)
535
+ continue;
536
+ optionByLong.set(opt.long, opt);
537
+ if (opt.short) {
538
+ optionByShort.set(opt.short, opt);
539
+ }
540
+ if (opt.type === 'boolean') {
541
+ booleanOptions.add(opt.long);
542
+ }
538
543
  }
539
- return parts.join(' ');
544
+ return { optionByLong, optionByShort, booleanOptions };
545
+ }
546
+ #hasHelpFlag(argv, allOptions) {
547
+ return this.#hasBuiltinFlag(argv, 'help', 'h', allOptions);
548
+ }
549
+ #hasVersionFlag(argv, allOptions) {
550
+ return this.#hasBuiltinFlag(argv, 'version', 'V', allOptions);
551
+ }
552
+ #hasBuiltinFlag(argv, flagLong, flagShort, allOptions) {
553
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(allOptions);
554
+ const normalizedArgv = this.#normalizeArgv(argv, booleanOptions);
555
+ for (let i = 0; i < normalizedArgv.length; i++) {
556
+ const arg = normalizedArgv[i];
557
+ if (arg === '--') {
558
+ break;
559
+ }
560
+ if (arg === `--${flagLong}` || (flagShort && arg === `-${flagShort}`)) {
561
+ return true;
562
+ }
563
+ if (this.#optionConsumesNextValue(arg, optionByLong, optionByShort)) {
564
+ i += 1;
565
+ }
566
+ }
567
+ return false;
568
+ }
569
+ #optionConsumesNextValue(arg, optionByLong, optionByShort) {
570
+ if (arg.startsWith('--')) {
571
+ const eqIdx = arg.indexOf('=');
572
+ if (eqIdx !== -1) {
573
+ return false;
574
+ }
575
+ const optName = arg.slice(2);
576
+ const opt = optionByLong.get(optName);
577
+ if (!opt) {
578
+ return false;
579
+ }
580
+ const type = opt.type ?? 'string';
581
+ return type !== 'boolean';
582
+ }
583
+ if (arg.startsWith('-') && arg.length === 2) {
584
+ const opt = optionByShort.get(arg[1]);
585
+ if (!opt) {
586
+ return false;
587
+ }
588
+ const type = opt.type ?? 'string';
589
+ return type !== 'boolean';
590
+ }
591
+ return false;
592
+ }
593
+ #normalizeArgv(argv, booleanOptions) {
594
+ const result = [];
595
+ let seenDoubleDash = false;
596
+ for (const arg of argv) {
597
+ if (arg === '--') {
598
+ seenDoubleDash = true;
599
+ result.push(arg);
600
+ continue;
601
+ }
602
+ if (!seenDoubleDash && arg.startsWith('--no-')) {
603
+ const eqIdx = arg.indexOf('=');
604
+ if (eqIdx !== -1) {
605
+ const optName = arg.slice(5, eqIdx);
606
+ if (booleanOptions.has(optName)) {
607
+ throw new CommanderError('InvalidBooleanValue', `"--no-${optName}" does not accept a value`, this.#getCommandPath());
608
+ }
609
+ }
610
+ else {
611
+ const optName = arg.slice(5);
612
+ if (booleanOptions.has(optName)) {
613
+ result.push(`--${optName}=false`);
614
+ continue;
615
+ }
616
+ }
617
+ }
618
+ result.push(arg);
619
+ }
620
+ return result;
621
+ }
622
+ #getCommandPath() {
623
+ return this.#name;
540
624
  }
541
625
  }
542
626
 
543
627
  class CompletionCommand extends Command {
544
628
  constructor(root, config) {
545
- const name = config?.name ?? 'completion';
629
+ const paths = config.paths;
630
+ const programName = config.programName ?? root.name;
546
631
  super({
547
- name,
548
632
  description: 'Generate shell completion script',
549
633
  });
550
634
  this.option({
@@ -561,10 +645,16 @@ class CompletionCommand extends Command {
561
645
  long: 'pwsh',
562
646
  type: 'boolean',
563
647
  description: 'Generate PowerShell completion script',
648
+ })
649
+ .option({
650
+ long: 'write',
651
+ short: 'w',
652
+ type: 'string',
653
+ description: 'Write to file (default path if no value given)',
654
+ resolver: argv => resolveOptionalStringOption(argv, 'write', 'w'),
564
655
  })
565
656
  .action(({ opts }) => {
566
657
  const meta = root.getCompletionMeta();
567
- const programName = root.name;
568
658
  const selectedShells = [
569
659
  opts['bash'] && 'bash',
570
660
  opts['fish'] && 'fish',
@@ -573,25 +663,89 @@ class CompletionCommand extends Command {
573
663
  if (selectedShells.length === 0) {
574
664
  console.error('Please specify a shell: --bash, --fish, or --pwsh');
575
665
  process.exit(1);
666
+ return;
576
667
  }
577
668
  if (selectedShells.length > 1) {
578
669
  console.error('Please specify only one shell option');
579
670
  process.exit(1);
671
+ return;
580
672
  }
581
- switch (selectedShells[0]) {
673
+ const shell = selectedShells[0];
674
+ let script;
675
+ switch (shell) {
582
676
  case 'bash':
583
- console.log(new BashCompletion(meta, programName).generate());
677
+ script = new BashCompletion(meta, programName).generate();
584
678
  break;
585
679
  case 'fish':
586
- console.log(new FishCompletion(meta, programName).generate());
680
+ script = new FishCompletion(meta, programName).generate();
587
681
  break;
588
682
  case 'pwsh':
589
- console.log(new PwshCompletion(meta, programName).generate());
683
+ script = new PwshCompletion(meta, programName).generate();
590
684
  break;
591
685
  }
686
+ const writeOpt = opts['write'];
687
+ if (writeOpt !== undefined) {
688
+ const filePath = typeof writeOpt === 'string' && writeOpt !== '' ? writeOpt : paths[shell];
689
+ const expandedPath = expandHome(filePath);
690
+ const dir = path.dirname(expandedPath);
691
+ if (!fs.existsSync(dir)) {
692
+ fs.mkdirSync(dir, { recursive: true });
693
+ }
694
+ fs.writeFileSync(expandedPath, script, 'utf-8');
695
+ console.log(`Completion script written to: ${expandedPath}`);
696
+ }
697
+ else {
698
+ console.log(script);
699
+ }
592
700
  });
593
701
  }
594
702
  }
703
+ function expandHome(filepath) {
704
+ if (filepath.startsWith('~/') || filepath === '~') {
705
+ const home = process.env['HOME'] || process.env['USERPROFILE'] || '';
706
+ return filepath.replace(/^~/, home);
707
+ }
708
+ return filepath;
709
+ }
710
+ function resolveOptionalStringOption(argv, longName, shortName) {
711
+ const remaining = [];
712
+ let value;
713
+ for (let i = 0; i < argv.length; i++) {
714
+ const arg = argv[i];
715
+ if (arg.startsWith(`--${longName}=`)) {
716
+ value = arg.slice(`--${longName}=`.length);
717
+ continue;
718
+ }
719
+ if (arg === `--${longName}`) {
720
+ const next = argv[i + 1];
721
+ if (next !== undefined && !next.startsWith('-')) {
722
+ value = next;
723
+ i += 1;
724
+ }
725
+ else {
726
+ value = '';
727
+ }
728
+ continue;
729
+ }
730
+ if (arg.startsWith(`-${shortName}=`)) {
731
+ value = arg.slice(`-${shortName}=`.length);
732
+ continue;
733
+ }
734
+ if (arg === `-${shortName}`) {
735
+ const next = argv[i + 1];
736
+ if (next !== undefined && !next.startsWith('-')) {
737
+ value = next;
738
+ i += 1;
739
+ }
740
+ else {
741
+ value = '';
742
+ }
743
+ continue;
744
+ }
745
+ remaining.push(arg);
746
+ }
747
+ return { value, remaining };
748
+ }
595
749
  class BashCompletion {
596
750
  #meta;
597
751
  #programName;
@@ -57,22 +57,20 @@ 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) */
67
65
  version?: string;
66
+ /** Enable built-in "help" subcommand (only effective when command has subcommands) */
67
+ help?: boolean;
68
68
  }
69
69
  /** Forward declaration for Command class */
70
70
  interface ICommand {
71
71
  readonly name: string;
72
- readonly aliases: string[];
73
72
  readonly description: string;
74
73
  readonly version: string | undefined;
75
- readonly parent: ICommand | undefined;
76
74
  readonly options: IOption[];
77
75
  readonly arguments: IArgument[];
78
76
  }
@@ -142,10 +140,21 @@ interface ICompletionMeta {
142
140
  options: ICompletionOptionMeta[];
143
141
  subcommands: ICompletionMeta[];
144
142
  }
143
+ /** Shell completion paths configuration */
144
+ interface ICompletionPaths {
145
+ /** Bash completion file path (e.g., ~/.local/share/bash-completion/completions/{name}) */
146
+ bash: string;
147
+ /** Fish completion file path (e.g., ~/.config/fish/completions/{name}.fish) */
148
+ fish: string;
149
+ /** PowerShell completion file path (only ~ expansion supported, not $PROFILE) */
150
+ pwsh: string;
151
+ }
145
152
  /** CompletionCommand configuration */
146
153
  interface ICompletionCommandConfig {
147
- /** Subcommand name, defaults to 'completion' */
148
- name?: string;
154
+ /** Program name for completion scripts (defaults to root.name) */
155
+ programName?: string;
156
+ /** Default completion file paths for each shell (required for --write support) */
157
+ paths: ICompletionPaths;
149
158
  }
150
159
 
151
160
  /**
@@ -154,20 +163,18 @@ interface ICompletionCommandConfig {
154
163
  * @module @guanghechen/commander
155
164
  */
156
165
 
157
- declare class Command {
166
+ declare class Command implements ICommand {
158
167
  #private;
159
168
  constructor(config: ICommandConfig);
160
169
  get name(): string;
161
- get aliases(): string[];
162
170
  get description(): string;
163
171
  get version(): string | undefined;
164
- get parent(): Command | undefined;
165
172
  get options(): IOption[];
166
173
  get arguments(): IArgument[];
167
174
  option(opt: IOption): this;
168
175
  argument(arg: IArgument): this;
169
176
  action(fn: IAction): this;
170
- subcommand(cmd: Command): this;
177
+ subcommand(name: string, cmd: Command): this;
171
178
  run(params: IRunParams): Promise<void>;
172
179
  parse(argv: string[]): IParseResult;
173
180
  formatHelp(): string;
@@ -186,16 +193,22 @@ declare class Command {
186
193
  * @example
187
194
  * ```typescript
188
195
  * const root = new Command({ name: 'mycli', description: 'My CLI' })
189
- * root.subcommand(new CompletionCommand(root))
196
+ * root.subcommand('completion', new CompletionCommand(root, {
197
+ * paths: {
198
+ * bash: `~/.local/share/bash-completion/completions/mycli`,
199
+ * fish: `~/.config/fish/completions/mycli.fish`,
200
+ * pwsh: `~/.config/powershell/Microsoft.PowerShell_profile.ps1`,
201
+ * }
202
+ * }))
190
203
  *
191
204
  * // Usage:
192
205
  * // mycli completion --bash > ~/.local/share/bash-completion/completions/mycli
193
- * // mycli completion --fish > ~/.config/fish/completions/mycli.fish
194
- * // mycli completion --pwsh >> $PROFILE
206
+ * // mycli completion --fish --write (writes to default path)
207
+ * // mycli completion --fish --write /custom/path.fish
195
208
  * ```
196
209
  */
197
210
  declare class CompletionCommand extends Command {
198
- constructor(root: Command, config?: ICompletionCommandConfig);
211
+ constructor(root: Command, config: ICompletionCommandConfig);
199
212
  }
200
213
  declare class BashCompletion {
201
214
  #private;
@@ -214,4 +227,4 @@ declare class PwshCompletion {
214
227
  }
215
228
 
216
229
  export { BashCompletion, Command, CommanderError, CompletionCommand, FishCompletion, PwshCompletion };
217
- export type { IAction, IActionParams, IArgument, IArgumentKind, ICommand, ICommandConfig, ICommandContext, ICommanderErrorKind, ICompletionCommandConfig, ICompletionMeta, ICompletionOptionMeta, IOption, IOptionType, IParseResult, IReporter, IRunParams, IShellType };
230
+ export type { IAction, IActionParams, IArgument, IArgumentKind, ICommand, ICommandConfig, ICommandContext, ICommanderErrorKind, ICompletionCommandConfig, ICompletionMeta, ICompletionOptionMeta, ICompletionPaths, IOption, IOptionType, IParseResult, IReporter, IRunParams, IShellType };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanghechen/commander",
3
- "version": "2.0.1",
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",