Skip to content

Commit 04153b8

Browse files
shadowspawnAmir
andauthored
refactor: complete command for Commander (#125)
* Rework `complete` command to allow separate complete and completion commands, and remove need to monkeypatch parse+parseAsync * Consistently capitalise descriptions in Commander framework * Fix usage for hidden stand-alone complete command * add changeset * Pass all `complete` args into delegated command so it can warn about bogus extra aguments after shell * Add custom completion name to README, and possible usage error to complete. * Tweak error text * Tidy comment --------- Co-authored-by: Amir <amir@Amirs-MacBook-Air.local>
1 parent 47542e9 commit 04153b8

4 files changed

Lines changed: 94 additions & 96 deletions

File tree

.changeset/green-moose-love.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@bomb.sh/tab': patch
3+
---
4+
5+
refactor: commander adapter - remove parse/parseAsync and add optional `completionCommandName` config

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,16 @@ if (portOption) {
261261
program.parse();
262262
```
263263

264+
The Commander integration supports customising the command name to generate the shell completion script. The default is `complete`. If you use a custom name
265+
like `completion` then it will be visible in the help as `completion <shell>`, while the runtime suggestions will be hiddden (`complete -- [args...]`).
266+
You'll need to use your custom command when following examples on this page to generate the shell completion script.
267+
268+
```javascript
269+
const completion = tab(program, { completionCommandName: 'completion' });
270+
```
271+
272+
### Custom Integrations
273+
264274
tab uses a standardized completion protocol that any CLI can implement:
265275

266276
```bash

src/commander.ts

Lines changed: 76 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
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, ParseOptions } from 'commander';
1+
import type { Command as CommanderCommand } from 'commander';
62
import t, { type RootCommand } from './t';
7-
import { assertDoubleDashes } from './shared';
3+
4+
// rawArgs is available on (just) the Commander root command, but is not included in the TypeScript types.
5+
interface CommandWithRawArgs extends CommanderCommand {
6+
rawArgs: string[];
7+
}
88

99
const execPath = process.execPath;
1010
const processArgs = process.argv.slice(1);
@@ -18,7 +18,10 @@ function quoteIfNeeded(path: string): string {
1818
return path.includes(' ') ? `'${path}'` : path;
1919
}
2020

21-
export default function tab(instance: CommanderCommand): RootCommand {
21+
export default function tab(
22+
instance: CommanderCommand,
23+
completionConfig?: { completionCommandName?: string }
24+
): RootCommand {
2225
const programName = instance.name();
2326

2427
// Process the root command
@@ -27,95 +30,76 @@ export default function tab(instance: CommanderCommand): RootCommand {
2730
// Process all subcommands
2831
processSubcommands(instance);
2932

30-
// Add the complete command for normal shell script generation
31-
instance
32-
.command('complete [shell]')
33+
// Make a `completion` command with a required command-argument.
34+
const completionCommandName =
35+
completionConfig?.completionCommandName ?? 'complete';
36+
const completionCommand = instance
37+
.createCommand(completionCommandName)
3338
.description('Generate shell completion scripts')
34-
.action(async (shell) => {
35-
switch (shell) {
36-
case 'zsh': {
37-
const script = zsh.generate(programName, x);
38-
console.log(script);
39-
break;
40-
}
41-
case 'bash': {
42-
const script = bash.generate(programName, x);
43-
console.log(script);
44-
break;
45-
}
46-
case 'fish': {
47-
const script = fish.generate(programName, x);
48-
console.log(script);
49-
break;
50-
}
51-
case 'powershell': {
52-
const script = powershell.generate(programName, x);
53-
console.log(script);
54-
break;
55-
}
56-
case 'debug': {
57-
// Debug mode to print all collected commands
58-
const commandMap = new Map<string, CommanderCommand>();
59-
collectCommands(instance, '', commandMap);
60-
console.log('Collected commands:');
61-
for (const [path, cmd] of commandMap.entries()) {
62-
console.log(
63-
`- ${path || '<root>'}: ${cmd.description() || 'No description'}`
64-
);
65-
}
66-
break;
67-
}
68-
default: {
69-
console.error(`Unknown shell: ${shell}`);
70-
console.error('Supported shells: zsh, bash, fish, powershell');
71-
process.exit(1);
72-
}
73-
}
39+
.addArgument(
40+
instance
41+
.createArgument('<shell>', 'Shell type for completion script')
42+
.choices(['zsh', 'bash', 'fish', 'powershell'])
43+
)
44+
.action((shell) => {
45+
t.setup(programName, x, shell);
7446
});
47+
completionCommand.copyInheritedSettings(instance);
48+
49+
// Make a `complete` command for generating tab-time complete suggestions.
50+
const completeCommand = instance
51+
.createCommand('complete')
52+
.description('Generate completion suggestions')
53+
.usage('-- [args...]')
54+
.argument('[args...]')
55+
.action((args) => {
56+
if (completionCommandName !== 'complete') {
57+
// Check for user trying to generate shell completion script, since not using usual tab overloaded complete `command`.
58+
const rawArgs = (instance as CommandWithRawArgs).rawArgs;
59+
if (args.length === 1 && !rawArgs.includes('--'))
60+
instance.error(
61+
`error: completion requests are called like \`complete -- [args]\`.\n(Did you mean \`${completionCommandName} ${args[0]}\` to generate shell script?)`
62+
);
63+
}
7564

76-
const getCompletionArgs = (argv?: readonly string[]): string[] | null => {
77-
const args = argv || process.argv;
78-
const completeIndex = args.findIndex((arg) => arg === 'complete');
79-
const dashDashIndex = args.findIndex((arg) => arg === '--');
80-
81-
if (
82-
completeIndex !== -1 &&
83-
dashDashIndex !== -1 &&
84-
dashDashIndex > completeIndex
85-
) {
86-
return args.slice(dashDashIndex + 1);
87-
}
88-
89-
return null;
90-
};
91-
92-
const handleCompletion = (extra: string[]): void => {
93-
assertDoubleDashes(programName);
94-
t.parse(extra);
95-
};
65+
t.parse(args);
66+
});
67+
completeCommand.copyInheritedSettings(instance);
9668

97-
const originalParse = instance.parse.bind(instance);
98-
instance.parse = function (argv?: readonly string[], options?: ParseOptions) {
99-
const extra = getCompletionArgs(argv);
100-
if (extra) {
101-
handleCompletion(extra);
102-
return instance;
103-
}
104-
return originalParse(argv, options);
105-
};
106-
107-
const originalParseAsync = instance.parseAsync.bind(instance);
108-
instance.parseAsync = async function (
109-
argv?: readonly string[],
110-
options?: ParseOptions
111-
) {
112-
const extra = getCompletionArgs(argv);
113-
if (extra) {
114-
handleCompletion(extra);
115-
return instance;
116-
}
117-
return originalParseAsync(argv, options);
118-
};
69+
if (completionCommandName !== 'complete') {
70+
// We have indepdendent commands so can hook them up directly.
71+
instance.addCommand(completionCommand);
72+
instance.addCommand(completeCommand, { hidden: true });
73+
} else {
74+
// We need to add a dual-use command, work out calling pattern, and dispatch.
75+
instance
76+
.command('complete')
77+
.description('Generate shell completion scripts')
78+
.argument(
79+
'[shell]',
80+
'shell type (choices: "zsh", "bash", "fish", "powershell")'
81+
)
82+
.allowExcessArguments()
83+
.action((_shell, _options, cmd) => {
84+
// Work out how we are being called, by user or by script as completion handler.
85+
const rawArgs = (instance as CommandWithRawArgs).rawArgs;
86+
const completeIndex = rawArgs.indexOf('complete');
87+
const dashDashIndex = rawArgs.indexOf('--');
88+
89+
if (
90+
completeIndex !== -1 &&
91+
dashDashIndex !== -1 &&
92+
dashDashIndex === completeIndex + 1
93+
) {
94+
// Commander stripped `--`, so put it back for reparse
95+
completeCommand.parse(['--', ...cmd.args], { from: 'user' });
96+
} else {
97+
completionCommand.parse(cmd.args, {
98+
from: 'user',
99+
});
100+
}
101+
});
102+
}
119103

120104
return t;
121105
}

tests/cli.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -445,15 +445,14 @@ describe('commander specific tests', () => {
445445
});
446446

447447
it('should handle subcommands', async () => {
448-
// First, we need to check if deploy is recognized as a command
448+
// Check subcommands of root command.
449449
const command1 = `pnpm tsx examples/demo.commander.ts complete -- deploy`;
450450
const output1 = await runCommand(command1);
451451
expect(output1).toContain('deploy');
452452
expect(output1).toContain('Deploy the application');
453453

454-
// Then we need to check if the deploy command has subcommands
455-
// We can check this by running the deploy command with --help
456-
const command2 = `pnpm tsx examples/demo.commander.ts deploy --help`;
454+
// Check subcommands of subcommand.
455+
const command2 = `pnpm tsx examples/demo.commander.ts complete -- deploy ""`;
457456
const output2 = await runCommand(command2);
458457
expect(output2).toContain('staging');
459458
expect(output2).toContain('production');

0 commit comments

Comments
 (0)