Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/green-moose-love.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bomb.sh/tab': patch
---

refactor: commander adapter - remove parse/parseAsync and add optional `completionCommandName` config
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,16 @@ if (portOption) {
program.parse();
```

The Commander integration supports customising the command name to generate the shell completion script. The default is `complete`. If you use a custom name
like `completion` then it will be visible in the help as `completion <shell>`, while the runtime suggestions will be hiddden (`complete -- [args...]`).
You'll need to use your custom command when following examples on this page to generate the shell completion script.

```javascript
const completion = tab(program, { completionCommandName: 'completion' });
```

### Custom Integrations

tab uses a standardized completion protocol that any CLI can implement:

```bash
Expand Down
168 changes: 76 additions & 92 deletions src/commander.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import * as zsh from './zsh';
import * as bash from './bash';
import * as fish from './fish';
import * as powershell from './powershell';
import type { Command as CommanderCommand, ParseOptions } from 'commander';
import type { Command as CommanderCommand } from 'commander';
import t, { type RootCommand } from './t';
import { assertDoubleDashes } from './shared';

// rawArgs is available on (just) the Commander root command, but is not included in the TypeScript types.
interface CommandWithRawArgs extends CommanderCommand {
rawArgs: string[];
}

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

export default function tab(instance: CommanderCommand): RootCommand {
export default function tab(
instance: CommanderCommand,
completionConfig?: { completionCommandName?: string }
): RootCommand {
const programName = instance.name();

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

// Add the complete command for normal shell script generation
instance
.command('complete [shell]')
// Make a `completion` command with a required command-argument.
const completionCommandName =
completionConfig?.completionCommandName ?? 'complete';
const completionCommand = instance
.createCommand(completionCommandName)
.description('Generate shell completion scripts')
.action(async (shell) => {
switch (shell) {
case 'zsh': {
const script = zsh.generate(programName, x);
console.log(script);
break;
}
case 'bash': {
const script = bash.generate(programName, x);
console.log(script);
break;
}
case 'fish': {
const script = fish.generate(programName, x);
console.log(script);
break;
}
case 'powershell': {
const script = powershell.generate(programName, x);
console.log(script);
break;
}
case 'debug': {
// Debug mode to print all collected commands
const commandMap = new Map<string, CommanderCommand>();
collectCommands(instance, '', commandMap);
console.log('Collected commands:');
for (const [path, cmd] of commandMap.entries()) {
console.log(
`- ${path || '<root>'}: ${cmd.description() || 'No description'}`
);
}
break;
}
default: {
console.error(`Unknown shell: ${shell}`);
console.error('Supported shells: zsh, bash, fish, powershell');
process.exit(1);
}
}
.addArgument(
instance
.createArgument('<shell>', 'Shell type for completion script')
.choices(['zsh', 'bash', 'fish', 'powershell'])
)
.action((shell) => {
t.setup(programName, x, shell);
});
completionCommand.copyInheritedSettings(instance);

// Make a `complete` command for generating tab-time complete suggestions.
const completeCommand = instance
.createCommand('complete')
.description('Generate completion suggestions')
.usage('-- [args...]')
.argument('[args...]')
.action((args) => {
if (completionCommandName !== 'complete') {
// Check for user trying to generate shell completion script, since not using usual tab overloaded complete `command`.
const rawArgs = (instance as CommandWithRawArgs).rawArgs;
if (args.length === 1 && !rawArgs.includes('--'))
instance.error(
`error: completion requests are called like \`complete -- [args]\`.\n(Did you mean \`${completionCommandName} ${args[0]}\` to generate shell script?)`
);
}

const getCompletionArgs = (argv?: readonly string[]): string[] | null => {
const args = argv || process.argv;
const completeIndex = args.findIndex((arg) => arg === 'complete');
const dashDashIndex = args.findIndex((arg) => arg === '--');

if (
completeIndex !== -1 &&
dashDashIndex !== -1 &&
dashDashIndex > completeIndex
) {
return args.slice(dashDashIndex + 1);
}

return null;
};

const handleCompletion = (extra: string[]): void => {
assertDoubleDashes(programName);
t.parse(extra);
};
t.parse(args);
});
completeCommand.copyInheritedSettings(instance);

const originalParse = instance.parse.bind(instance);
instance.parse = function (argv?: readonly string[], options?: ParseOptions) {
const extra = getCompletionArgs(argv);
if (extra) {
handleCompletion(extra);
return instance;
}
return originalParse(argv, options);
};

const originalParseAsync = instance.parseAsync.bind(instance);
instance.parseAsync = async function (
argv?: readonly string[],
options?: ParseOptions
) {
const extra = getCompletionArgs(argv);
if (extra) {
handleCompletion(extra);
return instance;
}
return originalParseAsync(argv, options);
};
if (completionCommandName !== 'complete') {
// We have indepdendent commands so can hook them up directly.
instance.addCommand(completionCommand);
instance.addCommand(completeCommand, { hidden: true });
} else {
// We need to add a dual-use command, work out calling pattern, and dispatch.
instance
.command('complete')
.description('Generate shell completion scripts')
.argument(
'[shell]',
'shell type (choices: "zsh", "bash", "fish", "powershell")'
)
.allowExcessArguments()
.action((_shell, _options, cmd) => {
// Work out how we are being called, by user or by script as completion handler.
const rawArgs = (instance as CommandWithRawArgs).rawArgs;
const completeIndex = rawArgs.indexOf('complete');
const dashDashIndex = rawArgs.indexOf('--');

if (
completeIndex !== -1 &&
dashDashIndex !== -1 &&
dashDashIndex === completeIndex + 1
) {
// Commander stripped `--`, so put it back for reparse
completeCommand.parse(['--', ...cmd.args], { from: 'user' });
} else {
completionCommand.parse(cmd.args, {
from: 'user',
});
}
});
}

return t;
}
Expand Down
7 changes: 3 additions & 4 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,15 +445,14 @@ describe('commander specific tests', () => {
});

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

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