@guanghechen/commander 2.0.1 → 2.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 ADDED
@@ -0,0 +1,33 @@
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
+ ## 2.1.0 (2025-02-08)
7
+
8
+ ### Features
9
+
10
+ - Add `--write` option to `CompletionCommand` for direct file output
11
+ - Add `help` subcommand support for commands with subcommands
12
+ - Detect `--help`/`--version` before parsing to avoid required argument errors
13
+ - Add `#normalizeArgv()` preprocessing to simplify `--no-*` option handling
14
+ - Add `implements ICommand` for explicit interface implementation
15
+
16
+ ## 2.0.1 (2025-02-07)
17
+
18
+ ### Documentation
19
+
20
+ - Update README.md
21
+
22
+ ### Miscellaneous
23
+
24
+ - Add LICENSE file
25
+ - Clean up build configs and standardize package exports
26
+ - Migrate from lerna to changesets
27
+
28
+ ## 2.0.0 (2025-01-15)
29
+
30
+ ### Features
31
+
32
+ - Initial stable release: A minimal, type-safe command-line interface builder with fluent API
33
+
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;
@@ -45,6 +68,7 @@ class Command {
45
68
  #description;
46
69
  #version;
47
70
  #aliases;
71
+ #helpSubcommandEnabled;
48
72
  #options = [];
49
73
  #arguments = [];
50
74
  #subcommands = [];
@@ -55,6 +79,7 @@ class Command {
55
79
  this.#description = config.description;
56
80
  this.#version = config.version;
57
81
  this.#aliases = config.aliases ?? [];
82
+ this.#helpSubcommandEnabled = config.help ?? false;
58
83
  }
59
84
  get name() {
60
85
  return this.#name;
@@ -93,6 +118,9 @@ class Command {
93
118
  return this;
94
119
  }
95
120
  subcommand(cmd) {
121
+ if (this.#helpSubcommandEnabled && (cmd.#name === 'help' || cmd.#aliases.includes('help'))) {
122
+ throw new CommanderError('ConfigurationError', '"help" is a reserved subcommand name when help subcommand is enabled', this.#getCommandPath());
123
+ }
96
124
  cmd.#parent = this;
97
125
  this.#subcommands.push(cmd);
98
126
  return this;
@@ -100,25 +128,26 @@ class Command {
100
128
  async run(params) {
101
129
  const { argv, envs, reporter } = params;
102
130
  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
- };
131
+ const processedArgv = this.#processHelpSubcommand(argv);
132
+ const { command, remaining } = this.#route(processedArgv);
111
133
  const allOptions = command.#getMergedOptions();
112
134
  const hasUserHelp = allOptions.some(o => o.long === 'help' && !command.#isBuiltinOption(o));
113
135
  const hasUserVersion = allOptions.some(o => o.long === 'version' && !command.#isBuiltinOption(o));
114
- if (!hasUserHelp && opts['help'] === true) {
136
+ if (!hasUserHelp && command.#hasHelpFlag(remaining, allOptions)) {
115
137
  console.log(command.formatHelp());
116
138
  return;
117
139
  }
118
- if (!hasUserVersion && opts['version'] === true) {
140
+ if (!hasUserVersion && command.#hasVersionFlag(remaining, allOptions)) {
119
141
  console.log(command.version ?? 'unknown');
120
142
  return;
121
143
  }
144
+ const { opts, args } = command.parse(remaining);
145
+ const ctx = {
146
+ cmd: command,
147
+ envs,
148
+ reporter: reporter ?? new DefaultReporter(),
149
+ argv,
150
+ };
122
151
  for (const opt of allOptions) {
123
152
  if (opt.apply && opts[opt.long] !== undefined) {
124
153
  opt.apply(opts[opt.long], ctx);
@@ -177,16 +206,8 @@ class Command {
177
206
  opts[opt.long] = result.value;
178
207
  remaining = result.remaining;
179
208
  }
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
- }
209
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(allOptions, true);
210
+ remaining = this.#normalizeArgv(remaining, booleanOptions);
190
211
  let i = 0;
191
212
  while (i < remaining.length) {
192
213
  const token = remaining[i];
@@ -285,9 +306,13 @@ class Command {
285
306
  }
286
307
  lines.push('');
287
308
  }
309
+ const showHelpSubcommand = this.#helpSubcommandEnabled && this.#subcommands.length > 0;
288
310
  if (this.#subcommands.length > 0) {
289
311
  lines.push('Commands:');
290
312
  const cmdLines = [];
313
+ if (showHelpSubcommand) {
314
+ cmdLines.push({ name: 'help', desc: 'Show help for a command' });
315
+ }
291
316
  for (const sub of this.#subcommands) {
292
317
  let name = sub.#name;
293
318
  if (sub.#aliases.length > 0) {
@@ -325,6 +350,21 @@ class Command {
325
350
  subcommands: this.#subcommands.map(sub => sub.getCompletionMeta()),
326
351
  };
327
352
  }
353
+ #processHelpSubcommand(argv) {
354
+ if (!this.#helpSubcommandEnabled || this.#subcommands.length === 0)
355
+ return argv;
356
+ if (argv.length < 1 || argv[0] !== 'help')
357
+ return argv;
358
+ if (argv.length === 1) {
359
+ return ['--help'];
360
+ }
361
+ const subName = argv[1];
362
+ const sub = this.#subcommands.find(c => c.#name === subName || c.#aliases.includes(subName));
363
+ if (sub) {
364
+ return [subName, '--help', ...argv.slice(2)];
365
+ }
366
+ return argv;
367
+ }
328
368
  #route(argv) {
329
369
  let current = this;
330
370
  let idx = 0;
@@ -352,17 +392,6 @@ class Command {
352
392
  else {
353
393
  optName = token.slice(2);
354
394
  }
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
395
  const opt = optionByLong.get(optName);
367
396
  if (!opt) {
368
397
  throw new CommanderError('UnknownOption', `unknown option "--${optName}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
@@ -429,7 +458,7 @@ class Command {
429
458
  }
430
459
  #applyValue(opt, rawValue, opts) {
431
460
  const type = opt.type ?? 'string';
432
- let parsedValue;
461
+ let parsedValue = rawValue;
433
462
  if (opt.coerce) {
434
463
  parsedValue = opt.coerce(rawValue);
435
464
  }
@@ -448,8 +477,6 @@ class Command {
448
477
  parsedValue = num;
449
478
  break;
450
479
  }
451
- default:
452
- parsedValue = rawValue;
453
480
  }
454
481
  }
455
482
  if (type === 'string[]' || type === 'number[]') {
@@ -533,6 +560,99 @@ class Command {
533
560
  #isBuiltinOption(opt) {
534
561
  return opt === BUILTIN_HELP_OPTION || opt === BUILTIN_VERSION_OPTION;
535
562
  }
563
+ #buildOptionMaps(allOptions, excludeResolver = false) {
564
+ const optionByLong = new Map();
565
+ const optionByShort = new Map();
566
+ const booleanOptions = new Set();
567
+ for (const opt of allOptions) {
568
+ if (excludeResolver && opt.resolver)
569
+ continue;
570
+ optionByLong.set(opt.long, opt);
571
+ if (opt.short) {
572
+ optionByShort.set(opt.short, opt);
573
+ }
574
+ if (opt.type === 'boolean') {
575
+ booleanOptions.add(opt.long);
576
+ }
577
+ }
578
+ return { optionByLong, optionByShort, booleanOptions };
579
+ }
580
+ #hasHelpFlag(argv, allOptions) {
581
+ return this.#hasBuiltinFlag(argv, 'help', 'h', allOptions);
582
+ }
583
+ #hasVersionFlag(argv, allOptions) {
584
+ return this.#hasBuiltinFlag(argv, 'version', 'V', allOptions);
585
+ }
586
+ #hasBuiltinFlag(argv, flagLong, flagShort, allOptions) {
587
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(allOptions);
588
+ const normalizedArgv = this.#normalizeArgv(argv, booleanOptions);
589
+ for (let i = 0; i < normalizedArgv.length; i++) {
590
+ const arg = normalizedArgv[i];
591
+ if (arg === '--') {
592
+ break;
593
+ }
594
+ if (arg === `--${flagLong}` || (flagShort && arg === `-${flagShort}`)) {
595
+ return true;
596
+ }
597
+ if (this.#optionConsumesNextValue(arg, optionByLong, optionByShort)) {
598
+ i += 1;
599
+ }
600
+ }
601
+ return false;
602
+ }
603
+ #optionConsumesNextValue(arg, optionByLong, optionByShort) {
604
+ if (arg.startsWith('--')) {
605
+ const eqIdx = arg.indexOf('=');
606
+ if (eqIdx !== -1) {
607
+ return false;
608
+ }
609
+ const optName = arg.slice(2);
610
+ const opt = optionByLong.get(optName);
611
+ if (!opt) {
612
+ return false;
613
+ }
614
+ const type = opt.type ?? 'string';
615
+ return type !== 'boolean';
616
+ }
617
+ if (arg.startsWith('-') && arg.length === 2) {
618
+ const opt = optionByShort.get(arg[1]);
619
+ if (!opt) {
620
+ return false;
621
+ }
622
+ const type = opt.type ?? 'string';
623
+ return type !== 'boolean';
624
+ }
625
+ return false;
626
+ }
627
+ #normalizeArgv(argv, booleanOptions) {
628
+ const result = [];
629
+ let seenDoubleDash = false;
630
+ for (const arg of argv) {
631
+ if (arg === '--') {
632
+ seenDoubleDash = true;
633
+ result.push(arg);
634
+ continue;
635
+ }
636
+ if (!seenDoubleDash && arg.startsWith('--no-')) {
637
+ const eqIdx = arg.indexOf('=');
638
+ if (eqIdx !== -1) {
639
+ const optName = arg.slice(5, eqIdx);
640
+ if (booleanOptions.has(optName)) {
641
+ throw new CommanderError('InvalidBooleanValue', `"--no-${optName}" does not accept a value`, this.#getCommandPath());
642
+ }
643
+ }
644
+ else {
645
+ const optName = arg.slice(5);
646
+ if (booleanOptions.has(optName)) {
647
+ result.push(`--${optName}=false`);
648
+ continue;
649
+ }
650
+ }
651
+ }
652
+ result.push(arg);
653
+ }
654
+ return result;
655
+ }
536
656
  #getCommandPath() {
537
657
  const parts = [];
538
658
  for (let node = this; node; node = node.#parent) {
@@ -544,7 +664,8 @@ class Command {
544
664
 
545
665
  class CompletionCommand extends Command {
546
666
  constructor(root, config) {
547
- const name = config?.name ?? 'completion';
667
+ const name = config.name ?? 'completion';
668
+ const paths = config.paths;
548
669
  super({
549
670
  name,
550
671
  description: 'Generate shell completion script',
@@ -563,6 +684,13 @@ class CompletionCommand extends Command {
563
684
  long: 'pwsh',
564
685
  type: 'boolean',
565
686
  description: 'Generate PowerShell completion script',
687
+ })
688
+ .option({
689
+ long: 'write',
690
+ short: 'w',
691
+ type: 'string',
692
+ description: 'Write to file (default path if no value given)',
693
+ resolver: argv => resolveOptionalStringOption(argv, 'write', 'w'),
566
694
  })
567
695
  .action(({ opts }) => {
568
696
  const meta = root.getCompletionMeta();
@@ -575,25 +703,89 @@ class CompletionCommand extends Command {
575
703
  if (selectedShells.length === 0) {
576
704
  console.error('Please specify a shell: --bash, --fish, or --pwsh');
577
705
  process.exit(1);
706
+ return;
578
707
  }
579
708
  if (selectedShells.length > 1) {
580
709
  console.error('Please specify only one shell option');
581
710
  process.exit(1);
711
+ return;
582
712
  }
583
- switch (selectedShells[0]) {
713
+ const shell = selectedShells[0];
714
+ let script;
715
+ switch (shell) {
584
716
  case 'bash':
585
- console.log(new BashCompletion(meta, programName).generate());
717
+ script = new BashCompletion(meta, programName).generate();
586
718
  break;
587
719
  case 'fish':
588
- console.log(new FishCompletion(meta, programName).generate());
720
+ script = new FishCompletion(meta, programName).generate();
589
721
  break;
590
722
  case 'pwsh':
591
- console.log(new PwshCompletion(meta, programName).generate());
723
+ script = new PwshCompletion(meta, programName).generate();
592
724
  break;
593
725
  }
726
+ const writeOpt = opts['write'];
727
+ if (writeOpt !== undefined) {
728
+ const filePath = typeof writeOpt === 'string' && writeOpt !== '' ? writeOpt : paths[shell];
729
+ const expandedPath = expandHome(filePath);
730
+ const dir = path__namespace.dirname(expandedPath);
731
+ if (!fs__namespace.existsSync(dir)) {
732
+ fs__namespace.mkdirSync(dir, { recursive: true });
733
+ }
734
+ fs__namespace.writeFileSync(expandedPath, script, 'utf-8');
735
+ console.log(`Completion script written to: ${expandedPath}`);
736
+ }
737
+ else {
738
+ console.log(script);
739
+ }
594
740
  });
595
741
  }
596
742
  }
743
+ function expandHome(filepath) {
744
+ if (filepath.startsWith('~/') || filepath === '~') {
745
+ const home = process.env['HOME'] || process.env['USERPROFILE'] || '';
746
+ return filepath.replace(/^~/, home);
747
+ }
748
+ return filepath;
749
+ }
750
+ function resolveOptionalStringOption(argv, longName, shortName) {
751
+ const remaining = [];
752
+ let value;
753
+ for (let i = 0; i < argv.length; i++) {
754
+ const arg = argv[i];
755
+ if (arg.startsWith(`--${longName}=`)) {
756
+ value = arg.slice(`--${longName}=`.length);
757
+ continue;
758
+ }
759
+ if (arg === `--${longName}`) {
760
+ const next = argv[i + 1];
761
+ if (next !== undefined && !next.startsWith('-')) {
762
+ value = next;
763
+ i += 1;
764
+ }
765
+ else {
766
+ value = '';
767
+ }
768
+ continue;
769
+ }
770
+ if (arg.startsWith(`-${shortName}=`)) {
771
+ value = arg.slice(`-${shortName}=`.length);
772
+ continue;
773
+ }
774
+ if (arg === `-${shortName}`) {
775
+ const next = argv[i + 1];
776
+ if (next !== undefined && !next.startsWith('-')) {
777
+ value = next;
778
+ i += 1;
779
+ }
780
+ else {
781
+ value = '';
782
+ }
783
+ continue;
784
+ }
785
+ remaining.push(arg);
786
+ }
787
+ return { value, remaining };
788
+ }
597
789
  class BashCompletion {
598
790
  #meta;
599
791
  #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;
@@ -43,6 +46,7 @@ class Command {
43
46
  #description;
44
47
  #version;
45
48
  #aliases;
49
+ #helpSubcommandEnabled;
46
50
  #options = [];
47
51
  #arguments = [];
48
52
  #subcommands = [];
@@ -53,6 +57,7 @@ class Command {
53
57
  this.#description = config.description;
54
58
  this.#version = config.version;
55
59
  this.#aliases = config.aliases ?? [];
60
+ this.#helpSubcommandEnabled = config.help ?? false;
56
61
  }
57
62
  get name() {
58
63
  return this.#name;
@@ -91,6 +96,9 @@ class Command {
91
96
  return this;
92
97
  }
93
98
  subcommand(cmd) {
99
+ if (this.#helpSubcommandEnabled && (cmd.#name === 'help' || cmd.#aliases.includes('help'))) {
100
+ throw new CommanderError('ConfigurationError', '"help" is a reserved subcommand name when help subcommand is enabled', this.#getCommandPath());
101
+ }
94
102
  cmd.#parent = this;
95
103
  this.#subcommands.push(cmd);
96
104
  return this;
@@ -98,25 +106,26 @@ class Command {
98
106
  async run(params) {
99
107
  const { argv, envs, reporter } = params;
100
108
  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
- };
109
+ const processedArgv = this.#processHelpSubcommand(argv);
110
+ const { command, remaining } = this.#route(processedArgv);
109
111
  const allOptions = command.#getMergedOptions();
110
112
  const hasUserHelp = allOptions.some(o => o.long === 'help' && !command.#isBuiltinOption(o));
111
113
  const hasUserVersion = allOptions.some(o => o.long === 'version' && !command.#isBuiltinOption(o));
112
- if (!hasUserHelp && opts['help'] === true) {
114
+ if (!hasUserHelp && command.#hasHelpFlag(remaining, allOptions)) {
113
115
  console.log(command.formatHelp());
114
116
  return;
115
117
  }
116
- if (!hasUserVersion && opts['version'] === true) {
118
+ if (!hasUserVersion && command.#hasVersionFlag(remaining, allOptions)) {
117
119
  console.log(command.version ?? 'unknown');
118
120
  return;
119
121
  }
122
+ const { opts, args } = command.parse(remaining);
123
+ const ctx = {
124
+ cmd: command,
125
+ envs,
126
+ reporter: reporter ?? new DefaultReporter(),
127
+ argv,
128
+ };
120
129
  for (const opt of allOptions) {
121
130
  if (opt.apply && opts[opt.long] !== undefined) {
122
131
  opt.apply(opts[opt.long], ctx);
@@ -175,16 +184,8 @@ class Command {
175
184
  opts[opt.long] = result.value;
176
185
  remaining = result.remaining;
177
186
  }
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
- }
187
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(allOptions, true);
188
+ remaining = this.#normalizeArgv(remaining, booleanOptions);
188
189
  let i = 0;
189
190
  while (i < remaining.length) {
190
191
  const token = remaining[i];
@@ -283,9 +284,13 @@ class Command {
283
284
  }
284
285
  lines.push('');
285
286
  }
287
+ const showHelpSubcommand = this.#helpSubcommandEnabled && this.#subcommands.length > 0;
286
288
  if (this.#subcommands.length > 0) {
287
289
  lines.push('Commands:');
288
290
  const cmdLines = [];
291
+ if (showHelpSubcommand) {
292
+ cmdLines.push({ name: 'help', desc: 'Show help for a command' });
293
+ }
289
294
  for (const sub of this.#subcommands) {
290
295
  let name = sub.#name;
291
296
  if (sub.#aliases.length > 0) {
@@ -323,6 +328,21 @@ class Command {
323
328
  subcommands: this.#subcommands.map(sub => sub.getCompletionMeta()),
324
329
  };
325
330
  }
331
+ #processHelpSubcommand(argv) {
332
+ if (!this.#helpSubcommandEnabled || this.#subcommands.length === 0)
333
+ return argv;
334
+ if (argv.length < 1 || argv[0] !== 'help')
335
+ return argv;
336
+ if (argv.length === 1) {
337
+ return ['--help'];
338
+ }
339
+ const subName = argv[1];
340
+ const sub = this.#subcommands.find(c => c.#name === subName || c.#aliases.includes(subName));
341
+ if (sub) {
342
+ return [subName, '--help', ...argv.slice(2)];
343
+ }
344
+ return argv;
345
+ }
326
346
  #route(argv) {
327
347
  let current = this;
328
348
  let idx = 0;
@@ -350,17 +370,6 @@ class Command {
350
370
  else {
351
371
  optName = token.slice(2);
352
372
  }
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
373
  const opt = optionByLong.get(optName);
365
374
  if (!opt) {
366
375
  throw new CommanderError('UnknownOption', `unknown option "--${optName}" for command "${this.#getCommandPath()}"`, this.#getCommandPath());
@@ -427,7 +436,7 @@ class Command {
427
436
  }
428
437
  #applyValue(opt, rawValue, opts) {
429
438
  const type = opt.type ?? 'string';
430
- let parsedValue;
439
+ let parsedValue = rawValue;
431
440
  if (opt.coerce) {
432
441
  parsedValue = opt.coerce(rawValue);
433
442
  }
@@ -446,8 +455,6 @@ class Command {
446
455
  parsedValue = num;
447
456
  break;
448
457
  }
449
- default:
450
- parsedValue = rawValue;
451
458
  }
452
459
  }
453
460
  if (type === 'string[]' || type === 'number[]') {
@@ -531,6 +538,99 @@ class Command {
531
538
  #isBuiltinOption(opt) {
532
539
  return opt === BUILTIN_HELP_OPTION || opt === BUILTIN_VERSION_OPTION;
533
540
  }
541
+ #buildOptionMaps(allOptions, excludeResolver = false) {
542
+ const optionByLong = new Map();
543
+ const optionByShort = new Map();
544
+ const booleanOptions = new Set();
545
+ for (const opt of allOptions) {
546
+ if (excludeResolver && opt.resolver)
547
+ continue;
548
+ optionByLong.set(opt.long, opt);
549
+ if (opt.short) {
550
+ optionByShort.set(opt.short, opt);
551
+ }
552
+ if (opt.type === 'boolean') {
553
+ booleanOptions.add(opt.long);
554
+ }
555
+ }
556
+ return { optionByLong, optionByShort, booleanOptions };
557
+ }
558
+ #hasHelpFlag(argv, allOptions) {
559
+ return this.#hasBuiltinFlag(argv, 'help', 'h', allOptions);
560
+ }
561
+ #hasVersionFlag(argv, allOptions) {
562
+ return this.#hasBuiltinFlag(argv, 'version', 'V', allOptions);
563
+ }
564
+ #hasBuiltinFlag(argv, flagLong, flagShort, allOptions) {
565
+ const { optionByLong, optionByShort, booleanOptions } = this.#buildOptionMaps(allOptions);
566
+ const normalizedArgv = this.#normalizeArgv(argv, booleanOptions);
567
+ for (let i = 0; i < normalizedArgv.length; i++) {
568
+ const arg = normalizedArgv[i];
569
+ if (arg === '--') {
570
+ break;
571
+ }
572
+ if (arg === `--${flagLong}` || (flagShort && arg === `-${flagShort}`)) {
573
+ return true;
574
+ }
575
+ if (this.#optionConsumesNextValue(arg, optionByLong, optionByShort)) {
576
+ i += 1;
577
+ }
578
+ }
579
+ return false;
580
+ }
581
+ #optionConsumesNextValue(arg, optionByLong, optionByShort) {
582
+ if (arg.startsWith('--')) {
583
+ const eqIdx = arg.indexOf('=');
584
+ if (eqIdx !== -1) {
585
+ return false;
586
+ }
587
+ const optName = arg.slice(2);
588
+ const opt = optionByLong.get(optName);
589
+ if (!opt) {
590
+ return false;
591
+ }
592
+ const type = opt.type ?? 'string';
593
+ return type !== 'boolean';
594
+ }
595
+ if (arg.startsWith('-') && arg.length === 2) {
596
+ const opt = optionByShort.get(arg[1]);
597
+ if (!opt) {
598
+ return false;
599
+ }
600
+ const type = opt.type ?? 'string';
601
+ return type !== 'boolean';
602
+ }
603
+ return false;
604
+ }
605
+ #normalizeArgv(argv, booleanOptions) {
606
+ const result = [];
607
+ let seenDoubleDash = false;
608
+ for (const arg of argv) {
609
+ if (arg === '--') {
610
+ seenDoubleDash = true;
611
+ result.push(arg);
612
+ continue;
613
+ }
614
+ if (!seenDoubleDash && arg.startsWith('--no-')) {
615
+ const eqIdx = arg.indexOf('=');
616
+ if (eqIdx !== -1) {
617
+ const optName = arg.slice(5, eqIdx);
618
+ if (booleanOptions.has(optName)) {
619
+ throw new CommanderError('InvalidBooleanValue', `"--no-${optName}" does not accept a value`, this.#getCommandPath());
620
+ }
621
+ }
622
+ else {
623
+ const optName = arg.slice(5);
624
+ if (booleanOptions.has(optName)) {
625
+ result.push(`--${optName}=false`);
626
+ continue;
627
+ }
628
+ }
629
+ }
630
+ result.push(arg);
631
+ }
632
+ return result;
633
+ }
534
634
  #getCommandPath() {
535
635
  const parts = [];
536
636
  for (let node = this; node; node = node.#parent) {
@@ -542,7 +642,8 @@ class Command {
542
642
 
543
643
  class CompletionCommand extends Command {
544
644
  constructor(root, config) {
545
- const name = config?.name ?? 'completion';
645
+ const name = config.name ?? 'completion';
646
+ const paths = config.paths;
546
647
  super({
547
648
  name,
548
649
  description: 'Generate shell completion script',
@@ -561,6 +662,13 @@ class CompletionCommand extends Command {
561
662
  long: 'pwsh',
562
663
  type: 'boolean',
563
664
  description: 'Generate PowerShell completion script',
665
+ })
666
+ .option({
667
+ long: 'write',
668
+ short: 'w',
669
+ type: 'string',
670
+ description: 'Write to file (default path if no value given)',
671
+ resolver: argv => resolveOptionalStringOption(argv, 'write', 'w'),
564
672
  })
565
673
  .action(({ opts }) => {
566
674
  const meta = root.getCompletionMeta();
@@ -573,25 +681,89 @@ class CompletionCommand extends Command {
573
681
  if (selectedShells.length === 0) {
574
682
  console.error('Please specify a shell: --bash, --fish, or --pwsh');
575
683
  process.exit(1);
684
+ return;
576
685
  }
577
686
  if (selectedShells.length > 1) {
578
687
  console.error('Please specify only one shell option');
579
688
  process.exit(1);
689
+ return;
580
690
  }
581
- switch (selectedShells[0]) {
691
+ const shell = selectedShells[0];
692
+ let script;
693
+ switch (shell) {
582
694
  case 'bash':
583
- console.log(new BashCompletion(meta, programName).generate());
695
+ script = new BashCompletion(meta, programName).generate();
584
696
  break;
585
697
  case 'fish':
586
- console.log(new FishCompletion(meta, programName).generate());
698
+ script = new FishCompletion(meta, programName).generate();
587
699
  break;
588
700
  case 'pwsh':
589
- console.log(new PwshCompletion(meta, programName).generate());
701
+ script = new PwshCompletion(meta, programName).generate();
590
702
  break;
591
703
  }
704
+ const writeOpt = opts['write'];
705
+ if (writeOpt !== undefined) {
706
+ const filePath = typeof writeOpt === 'string' && writeOpt !== '' ? writeOpt : paths[shell];
707
+ const expandedPath = expandHome(filePath);
708
+ const dir = path.dirname(expandedPath);
709
+ if (!fs.existsSync(dir)) {
710
+ fs.mkdirSync(dir, { recursive: true });
711
+ }
712
+ fs.writeFileSync(expandedPath, script, 'utf-8');
713
+ console.log(`Completion script written to: ${expandedPath}`);
714
+ }
715
+ else {
716
+ console.log(script);
717
+ }
592
718
  });
593
719
  }
594
720
  }
721
+ function expandHome(filepath) {
722
+ if (filepath.startsWith('~/') || filepath === '~') {
723
+ const home = process.env['HOME'] || process.env['USERPROFILE'] || '';
724
+ return filepath.replace(/^~/, home);
725
+ }
726
+ return filepath;
727
+ }
728
+ function resolveOptionalStringOption(argv, longName, shortName) {
729
+ const remaining = [];
730
+ let value;
731
+ for (let i = 0; i < argv.length; i++) {
732
+ const arg = argv[i];
733
+ if (arg.startsWith(`--${longName}=`)) {
734
+ value = arg.slice(`--${longName}=`.length);
735
+ continue;
736
+ }
737
+ if (arg === `--${longName}`) {
738
+ const next = argv[i + 1];
739
+ if (next !== undefined && !next.startsWith('-')) {
740
+ value = next;
741
+ i += 1;
742
+ }
743
+ else {
744
+ value = '';
745
+ }
746
+ continue;
747
+ }
748
+ if (arg.startsWith(`-${shortName}=`)) {
749
+ value = arg.slice(`-${shortName}=`.length);
750
+ continue;
751
+ }
752
+ if (arg === `-${shortName}`) {
753
+ const next = argv[i + 1];
754
+ if (next !== undefined && !next.startsWith('-')) {
755
+ value = next;
756
+ i += 1;
757
+ }
758
+ else {
759
+ value = '';
760
+ }
761
+ continue;
762
+ }
763
+ remaining.push(arg);
764
+ }
765
+ return { value, remaining };
766
+ }
595
767
  class BashCompletion {
596
768
  #meta;
597
769
  #programName;
@@ -65,6 +65,8 @@ interface ICommandConfig {
65
65
  description: string;
66
66
  /** Version (only effective for root command) */
67
67
  version?: string;
68
+ /** Enable built-in "help" subcommand (only effective when command has subcommands) */
69
+ help?: boolean;
68
70
  }
69
71
  /** Forward declaration for Command class */
70
72
  interface ICommand {
@@ -142,10 +144,21 @@ interface ICompletionMeta {
142
144
  options: ICompletionOptionMeta[];
143
145
  subcommands: ICompletionMeta[];
144
146
  }
147
+ /** Shell completion paths configuration */
148
+ interface ICompletionPaths {
149
+ /** Bash completion file path (e.g., ~/.local/share/bash-completion/completions/{name}) */
150
+ bash: string;
151
+ /** Fish completion file path (e.g., ~/.config/fish/completions/{name}.fish) */
152
+ fish: string;
153
+ /** PowerShell completion file path (only ~ expansion supported, not $PROFILE) */
154
+ pwsh: string;
155
+ }
145
156
  /** CompletionCommand configuration */
146
157
  interface ICompletionCommandConfig {
147
158
  /** Subcommand name, defaults to 'completion' */
148
159
  name?: string;
160
+ /** Default completion file paths for each shell (required for --write support) */
161
+ paths: ICompletionPaths;
149
162
  }
150
163
 
151
164
  /**
@@ -154,7 +167,7 @@ interface ICompletionCommandConfig {
154
167
  * @module @guanghechen/commander
155
168
  */
156
169
 
157
- declare class Command {
170
+ declare class Command implements ICommand {
158
171
  #private;
159
172
  constructor(config: ICommandConfig);
160
173
  get name(): string;
@@ -186,16 +199,22 @@ declare class Command {
186
199
  * @example
187
200
  * ```typescript
188
201
  * const root = new Command({ name: 'mycli', description: 'My CLI' })
189
- * root.subcommand(new CompletionCommand(root))
202
+ * root.subcommand(new CompletionCommand(root, {
203
+ * paths: {
204
+ * bash: `~/.local/share/bash-completion/completions/mycli`,
205
+ * fish: `~/.config/fish/completions/mycli.fish`,
206
+ * pwsh: `~/.config/powershell/Microsoft.PowerShell_profile.ps1`,
207
+ * }
208
+ * }))
190
209
  *
191
210
  * // Usage:
192
211
  * // mycli completion --bash > ~/.local/share/bash-completion/completions/mycli
193
- * // mycli completion --fish > ~/.config/fish/completions/mycli.fish
194
- * // mycli completion --pwsh >> $PROFILE
212
+ * // mycli completion --fish --write (writes to default path)
213
+ * // mycli completion --fish --write /custom/path.fish
195
214
  * ```
196
215
  */
197
216
  declare class CompletionCommand extends Command {
198
- constructor(root: Command, config?: ICompletionCommandConfig);
217
+ constructor(root: Command, config: ICompletionCommandConfig);
199
218
  }
200
219
  declare class BashCompletion {
201
220
  #private;
@@ -214,4 +233,4 @@ declare class PwshCompletion {
214
233
  }
215
234
 
216
235
  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 };
236
+ 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": "2.1.0",
4
4
  "description": "A minimal, type-safe command-line interface builder with fluent API",
5
5
  "author": {
6
6
  "name": "guanghechen",