@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 +33 -0
- package/lib/cjs/index.cjs +231 -39
- package/lib/esm/index.mjs +211 -39
- package/lib/types/index.d.ts +25 -6
- package/package.json +1 -1
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
|
|
104
|
-
const {
|
|
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 &&
|
|
136
|
+
if (!hasUserHelp && command.#hasHelpFlag(remaining, allOptions)) {
|
|
115
137
|
console.log(command.formatHelp());
|
|
116
138
|
return;
|
|
117
139
|
}
|
|
118
|
-
if (!hasUserVersion &&
|
|
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 =
|
|
181
|
-
|
|
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
|
|
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
|
-
|
|
713
|
+
const shell = selectedShells[0];
|
|
714
|
+
let script;
|
|
715
|
+
switch (shell) {
|
|
584
716
|
case 'bash':
|
|
585
|
-
|
|
717
|
+
script = new BashCompletion(meta, programName).generate();
|
|
586
718
|
break;
|
|
587
719
|
case 'fish':
|
|
588
|
-
|
|
720
|
+
script = new FishCompletion(meta, programName).generate();
|
|
589
721
|
break;
|
|
590
722
|
case 'pwsh':
|
|
591
|
-
|
|
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
|
|
102
|
-
const {
|
|
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 &&
|
|
114
|
+
if (!hasUserHelp && command.#hasHelpFlag(remaining, allOptions)) {
|
|
113
115
|
console.log(command.formatHelp());
|
|
114
116
|
return;
|
|
115
117
|
}
|
|
116
|
-
if (!hasUserVersion &&
|
|
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 =
|
|
179
|
-
|
|
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
|
|
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
|
-
|
|
691
|
+
const shell = selectedShells[0];
|
|
692
|
+
let script;
|
|
693
|
+
switch (shell) {
|
|
582
694
|
case 'bash':
|
|
583
|
-
|
|
695
|
+
script = new BashCompletion(meta, programName).generate();
|
|
584
696
|
break;
|
|
585
697
|
case 'fish':
|
|
586
|
-
|
|
698
|
+
script = new FishCompletion(meta, programName).generate();
|
|
587
699
|
break;
|
|
588
700
|
case 'pwsh':
|
|
589
|
-
|
|
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;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
|
194
|
-
* // mycli completion --
|
|
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
|
|
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 };
|