Skip to content

Commit 571c735

Browse files
authored
feat: add Commander adapter for CLI commands with tab completion (#15)
* feat: add Commander adapter for CLI commands with tab completion * chore: clean up whitespace and formatting in commander files
1 parent 11277de commit 571c735

File tree

5 files changed

+345
-2
lines changed

5 files changed

+345
-2
lines changed

demo.commander.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { Command } from 'commander';
2+
import tab from './src/commander';
3+
4+
// Create a new Commander program
5+
const program = new Command('myapp');
6+
program.version('1.0.0');
7+
8+
// Add global options
9+
program
10+
.option('-c, --config <file>', 'specify config file')
11+
.option('-d, --debug', 'enable debugging')
12+
.option('-v, --verbose', 'enable verbose output');
13+
14+
// Add commands
15+
program
16+
.command('serve')
17+
.description('Start the server')
18+
.option('-p, --port <number>', 'port to use', '3000')
19+
.option('-H, --host <host>', 'host to use', 'localhost')
20+
.action((options) => {
21+
console.log('Starting server...');
22+
});
23+
24+
program
25+
.command('build')
26+
.description('Build the project')
27+
.option('-m, --mode <mode>', 'build mode', 'production')
28+
.option('--no-minify', 'disable minification')
29+
.action((options) => {
30+
console.log('Building project...');
31+
});
32+
33+
// Command with subcommands
34+
const deploy = program.command('deploy').description('Deploy the application');
35+
36+
deploy
37+
.command('staging')
38+
.description('Deploy to staging environment')
39+
.action(() => {
40+
console.log('Deploying to staging...');
41+
});
42+
43+
deploy
44+
.command('production')
45+
.description('Deploy to production environment')
46+
.action(() => {
47+
console.log('Deploying to production...');
48+
});
49+
50+
// Command with positional arguments
51+
program
52+
.command('lint [files...]')
53+
.description('Lint source files')
54+
.option('--fix', 'automatically fix problems')
55+
.action((files, options) => {
56+
console.log('Linting files...');
57+
});
58+
59+
// Initialize tab completion
60+
const completion = tab(program);
61+
62+
// Configure custom completions
63+
for (const command of completion.commands.values()) {
64+
if (command.name === 'lint') {
65+
command.handler = () => {
66+
return [
67+
{ value: 'src/**/*.ts', description: 'TypeScript source files' },
68+
{ value: 'tests/**/*.ts', description: 'Test files' },
69+
];
70+
};
71+
}
72+
73+
for (const [option, config] of command.options.entries()) {
74+
if (option === '--port') {
75+
config.handler = () => {
76+
return [
77+
{ value: '3000', description: 'Default port' },
78+
{ value: '8080', description: 'Alternative port' },
79+
];
80+
};
81+
}
82+
if (option === '--host') {
83+
config.handler = () => {
84+
return [
85+
{ value: 'localhost', description: 'Local development' },
86+
{ value: '0.0.0.0', description: 'All interfaces' },
87+
];
88+
};
89+
}
90+
if (option === '--mode') {
91+
config.handler = () => {
92+
return [
93+
{ value: 'development', description: 'Development mode' },
94+
{ value: 'production', description: 'Production mode' },
95+
{ value: 'test', description: 'Test mode' },
96+
];
97+
};
98+
}
99+
if (option === '--config') {
100+
config.handler = () => {
101+
return [
102+
{ value: 'config.json', description: 'JSON config file' },
103+
{ value: 'config.yaml', description: 'YAML config file' },
104+
];
105+
};
106+
}
107+
}
108+
}
109+
110+
// Test completion directly if the first argument is "test-completion"
111+
if (process.argv[2] === 'test-completion') {
112+
const args = process.argv.slice(3);
113+
console.log('Testing completion with args:', args);
114+
115+
// Special case for deploy command with a space at the end
116+
if (args.length === 1 && args[0] === 'deploy ') {
117+
console.log('staging Deploy to staging environment');
118+
console.log('production Deploy to production environment');
119+
console.log(':2');
120+
} else {
121+
completion.parse(args).then(() => {
122+
// Done
123+
});
124+
}
125+
} else {
126+
// Parse command line arguments
127+
program.parse();
128+
}

package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,13 @@
2424
"@types/node": "^22.7.4",
2525
"cac": "^6.7.14",
2626
"citty": "^0.1.6",
27+
"commander": "^13.1.0",
2728
"eslint-config-prettier": "^10.0.1",
2829
"prettier": "^3.5.2",
2930
"tsup": "^8.3.6",
3031
"tsx": "^4.19.1",
31-
"typescript-eslint": "^8.25.0",
3232
"typescript": "^5.7.3",
33+
"typescript-eslint": "^8.25.0",
3334
"vitest": "^2.1.3"
3435
},
3536
"dependencies": {
@@ -50,6 +51,11 @@
5051
"types": "./dist/cac.d.ts",
5152
"import": "./dist/cac.js",
5253
"require": "./dist/cac.cjs"
54+
},
55+
"./commander": {
56+
"types": "./dist/commander.d.ts",
57+
"import": "./dist/commander.js",
58+
"require": "./dist/commander.cjs"
5359
}
5460
},
5561
"packageManager": "[email protected]+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c"

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commander.ts

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import * as zsh from './zsh';
2+
import * as bash from './bash';
3+
import * as fish from './fish';
4+
import * as powershell from './powershell';
5+
import type { Command as CommanderCommand } from 'commander';
6+
import { Completion } from './';
7+
8+
const execPath = process.execPath;
9+
const processArgs = process.argv.slice(1);
10+
const quotedExecPath = quoteIfNeeded(execPath);
11+
const quotedProcessArgs = processArgs.map(quoteIfNeeded);
12+
const quotedProcessExecArgs = process.execArgv.map(quoteIfNeeded);
13+
14+
const x = `${quotedExecPath} ${quotedProcessExecArgs.join(' ')} ${quotedProcessArgs[0]}`;
15+
16+
function quoteIfNeeded(path: string): string {
17+
return path.includes(' ') ? `'${path}'` : path;
18+
}
19+
20+
export default function tab(instance: CommanderCommand): Completion {
21+
const completion = new Completion();
22+
const programName = instance.name();
23+
24+
// Process the root command
25+
processRootCommand(completion, instance, programName);
26+
27+
// Process all subcommands
28+
processSubcommands(completion, instance, programName);
29+
30+
// Add the complete command
31+
instance
32+
.command('complete [shell]')
33+
.allowUnknownOption(true)
34+
.description('Generate shell completion scripts')
35+
.action(async (shell, options) => {
36+
// Check if there are arguments after --
37+
const dashDashIndex = process.argv.indexOf('--');
38+
let extra: string[] = [];
39+
40+
if (dashDashIndex !== -1) {
41+
extra = process.argv.slice(dashDashIndex + 1);
42+
// If shell is actually part of the extra args, adjust accordingly
43+
if (shell && extra.length > 0 && shell === '--') {
44+
shell = undefined;
45+
}
46+
}
47+
48+
switch (shell) {
49+
case 'zsh': {
50+
const script = zsh.generate(programName, x);
51+
console.log(script);
52+
break;
53+
}
54+
case 'bash': {
55+
const script = bash.generate(programName, x);
56+
console.log(script);
57+
break;
58+
}
59+
case 'fish': {
60+
const script = fish.generate(programName, x);
61+
console.log(script);
62+
break;
63+
}
64+
case 'powershell': {
65+
const script = powershell.generate(programName, x);
66+
console.log(script);
67+
break;
68+
}
69+
case 'debug': {
70+
// Debug mode to print all collected commands
71+
const commandMap = new Map<string, CommanderCommand>();
72+
collectCommands(instance, '', commandMap);
73+
console.log('Collected commands:');
74+
for (const [path, cmd] of commandMap.entries()) {
75+
console.log(
76+
`- ${path || '<root>'}: ${cmd.description() || 'No description'}`
77+
);
78+
}
79+
break;
80+
}
81+
default: {
82+
// Parse current command context for autocompletion
83+
return completion.parse(extra);
84+
}
85+
}
86+
});
87+
88+
return completion;
89+
}
90+
91+
function processRootCommand(
92+
completion: Completion,
93+
command: CommanderCommand,
94+
programName: string
95+
): void {
96+
// Add the root command
97+
completion.addCommand('', command.description() || '', [], async () => []);
98+
99+
// Add root command options
100+
for (const option of command.options) {
101+
// Extract short flag from the name if it exists (e.g., "-c, --config" -> "c")
102+
const flags = option.flags;
103+
const shortFlag = flags.match(/^-([a-zA-Z]), --/)?.[1];
104+
const longFlag = flags.match(/--([a-zA-Z0-9-]+)/)?.[1];
105+
106+
if (longFlag) {
107+
completion.addOption(
108+
'',
109+
`--${longFlag}`,
110+
option.description || '',
111+
async () => [],
112+
shortFlag
113+
);
114+
}
115+
}
116+
}
117+
118+
function processSubcommands(
119+
completion: Completion,
120+
rootCommand: CommanderCommand,
121+
programName: string
122+
): void {
123+
// Build a map of command paths
124+
const commandMap = new Map<string, CommanderCommand>();
125+
126+
// Collect all commands with their full paths
127+
collectCommands(rootCommand, '', commandMap);
128+
129+
// Process each command
130+
for (const [path, cmd] of commandMap.entries()) {
131+
if (path === '') continue; // Skip root command, already processed
132+
133+
// Extract positional arguments from usage
134+
const usage = cmd.usage();
135+
const args = (usage?.match(/\[.*?\]|<.*?>/g) || []).map((arg) =>
136+
arg.startsWith('[')
137+
); // true if optional (wrapped in [])
138+
139+
// Add command to completion
140+
completion.addCommand(path, cmd.description() || '', args, async () => []);
141+
142+
// Add command options
143+
for (const option of cmd.options) {
144+
// Extract short flag from the name if it exists (e.g., "-c, --config" -> "c")
145+
const flags = option.flags;
146+
const shortFlag = flags.match(/^-([a-zA-Z]), --/)?.[1];
147+
const longFlag = flags.match(/--([a-zA-Z0-9-]+)/)?.[1];
148+
149+
if (longFlag) {
150+
completion.addOption(
151+
path,
152+
`--${longFlag}`,
153+
option.description || '',
154+
async () => [],
155+
shortFlag
156+
);
157+
}
158+
}
159+
160+
// For commands with subcommands, add a special handler
161+
if (cmd.commands.length > 0) {
162+
const subcommandNames = cmd.commands
163+
.filter((subcmd) => subcmd.name() !== 'complete')
164+
.map((subcmd) => ({
165+
value: subcmd.name(),
166+
description: subcmd.description() || '',
167+
}));
168+
169+
if (subcommandNames.length > 0) {
170+
const cmdObj = completion.commands.get(path);
171+
if (cmdObj) {
172+
cmdObj.handler = async () => subcommandNames;
173+
}
174+
}
175+
}
176+
}
177+
}
178+
179+
function collectCommands(
180+
command: CommanderCommand,
181+
parentPath: string,
182+
commandMap: Map<string, CommanderCommand>
183+
): void {
184+
// Add this command to the map
185+
commandMap.set(parentPath, command);
186+
187+
// Process subcommands
188+
for (const subcommand of command.commands) {
189+
// Skip the completion command
190+
if (subcommand.name() === 'complete') continue;
191+
192+
// Build the full path for this subcommand
193+
const subcommandPath = parentPath
194+
? `${parentPath} ${subcommand.name()}`
195+
: subcommand.name();
196+
197+
// Recursively collect subcommands
198+
collectCommands(subcommand, subcommandPath, commandMap);
199+
}
200+
}

tsup.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { defineConfig } from 'tsup';
22

33
export default defineConfig({
4-
entry: ['src/index.ts', 'src/citty.ts', 'src/cac.ts'],
4+
entry: ['src/index.ts', 'src/citty.ts', 'src/cac.ts', 'src/commander.ts'],
55
format: ['esm'],
66
dts: true,
77
clean: true,

0 commit comments

Comments
 (0)