Skip to content

Tab

Shell autocompletions are largely missing in the JavaScript CLI ecosystem. tab provides a simple API for adding autocompletions to any JavaScript CLI tool.

Additionally, tab supports autocompletions for pnpm, npm, yarn, and bun.

Modern CLI libraries build on tab too. Gunshi includes tab completion natively in its core, and Clerc ships it as an official plugin.

Major CLIs and tooling projects including Cloudflare, Nuxt, Webpack, Prisma, Astro, and Vitest also adopted tab to provide shell completions for their users.

As CLI tooling authors, if we can spare our users a second or two by not checking documentation or writing the -h flag, we’re doing them a huge favor. The unconscious mind loves hitting the [TAB] key and always expects feedback. When nothing happens, it breaks the user’s flow - a frustration apparent across the whole JavaScript CLI tooling ecosystem.

tab solves this complexity by providing autocompletions that work consistently across zsh, bash, fish, and powershell.

Global install is recommended:

Terminal window
npm install -g @bomb.sh/tab

Then enable completions permanently:

Terminal window
# For zsh
echo 'source <(tab pnpm zsh)' >> ~/.zshrc
source ~/.zshrc
# For bash
echo 'source <(tab pnpm bash)' >> ~/.bashrc
source ~/.bashrc

For CLI Library (Adding Completions to Your CLI)

Section titled “For CLI Library (Adding Completions to Your CLI)”
Terminal window
npm install @bomb.sh/tab
# or
pnpm add @bomb.sh/tab
# or
yarn add @bomb.sh/tab
# or
bun add @bomb.sh/tab

Add autocompletions to your CLI tool:

import t from '@bomb.sh/tab';
// Define your CLI structure
const devCmd = t.command('dev', 'Start development server');
devCmd.option('port', 'Specify port', (complete) => {
complete('3000', 'Development port');
complete('8080', 'Production port');
});
// Handle completion requests
if (process.argv[2] === 'complete') {
const shell = process.argv[3];
if (shell === '--') {
const args = process.argv.slice(4);
t.parse(args);
} else if (['zsh', 'bash', 'fish', 'powershell'].includes(shell)) {
t.setup('my-cli', 'my-cli', shell);
} else {
console.error('Usage: my-cli complete <shell>');
process.exit(1);
}
}

The second argument of t.setup() is the command the generated script runs to request completions. Pass your program name, so completions work from any directory. The framework adapters do the same.

Test your completions:

Terminal window
node my-cli.js complete -- dev --port=
3000 Development port
8080 Production port
:4

This is the raw output your shell script reads. See How It Works for the format.

Install for users:

Terminal window
# zsh
my-cli complete zsh > ~/.my-cli-completion.zsh
echo 'source ~/.my-cli-completion.zsh' >> ~/.zshrc
# bash
my-cli complete bash > ~/.my-cli-completion.bash
echo 'source ~/.my-cli-completion.bash' >> ~/.bashrc
# fish
my-cli complete fish > ~/.config/fish/completions/my-cli.fish
# powershell
my-cli complete powershell > ~/.my-cli-completion.ps1
echo '. ~/.my-cli-completion.ps1' >> $PROFILE

The bash script needs bash 4 or later and the bash-completion package. macOS ships bash 3.2, so install both with Homebrew (brew install bash bash-completion@2).

Generated scripts run your CLI by its program name, which the shell resolves through PATH, an alias, or a shell function. During development your CLI usually isn’t on PATH, so define a shell function that runs it from source, then load the completions. The function lasts until you close the terminal. Run these from your project directory:

Terminal window
# zsh or bash
my-cli() { node my-cli.js "$@"; }
source <(my-cli complete zsh) # or bash
# fish
function my-cli; node my-cli.js $argv; end
source (my-cli complete fish | psub)
# powershell
function my-cli { node my-cli.js $args }
my-cli complete powershell | Out-String | Invoke-Expression

The function name must match your program name, so the shell routes completion requests back to it.

In addition to options, tab supports positional arguments with completion handlers:

// Root-level positional arg
t.argument('project', (complete) => {
complete('my-app', 'My application');
});
// Command-level positional args
t.command('copy', 'Copy files')
.argument('source', (complete) => {
complete('src/', 'Source directory');
complete('dist/', 'Distribution directory');
})
.argument('destination', (complete) => {
complete('build/', 'Build output');
complete('release/', 'Release directory');
});
// Variadic arguments (pass true as the third argument)
t.command('lint', 'Lint project').argument(
'files',
(complete) => {
complete('src/', 'Source directory');
complete('tests/', 'Tests directory');
},
true
);

Framework adapters infer positional arguments from your CLI framework definitions. See the Commander.js integration below for adapter-specific examples.

As mentioned earlier, tab provides completions for package managers as well:

Terminal window
# Generate and install completion scripts (requires global install)
tab pnpm zsh > ~/.pnpm-completion.zsh
echo 'source ~/.pnpm-completion.zsh' >> ~/.zshrc
tab npm bash > ~/.npm-completion.bash
echo 'source ~/.npm-completion.bash' >> ~/.bashrc
tab yarn fish > ~/.config/fish/completions/yarn.fish
tab bun powershell > ~/.bun-completion.ps1
echo '. ~/.bun-completion.ps1' >> $PROFILE

Without a global install, use npx:

Terminal window
npx @bomb.sh/tab pnpm zsh > ~/.pnpm-completion.zsh
echo 'source ~/.pnpm-completion.zsh' >> ~/.zshrc

Example in action:

Terminal window
pnpm install --reporter=<TAB>
# Shows: append-only, default, ndjson, silent
yarn add --emoji=<TAB>
# Shows: true, false

Package manager completion does more than complete the package manager’s own flags — it also delegates to CLIs installed as local project dependencies. If a CLI implements tab’s completion protocol (directly or via a framework adapter), it becomes completable through your package manager without being on your PATH and without installing its completion script separately:

Terminal window
# completes my-cli's subcommands and flags
pnpm exec my-cli <TAB>
pnpm dlx my-cli <TAB>
# the bare form works too
pnpm my-cli <TAB>

Under the hood, tab strips the package-manager wrapper (exec, x, run, dlx), detects whether the target CLI supports completion, and forwards the request to it — falling back to running the CLI through the package manager (e.g. pnpm my-cli complete -- …) so locally-installed binaries resolve. The same works for npm exec, yarn, and bun x.

Completion is registered against the package-manager binary (npm, pnpm, yarn, bun). npx and bunx are separate commands with no completion of their own, so npx my-cli <TAB> / bunx my-cli <TAB> won’t complete — use npm exec my-cli / bun x my-cli instead.

tab provides adapters for popular JavaScript CLI frameworks.

import cac from 'cac';
import tab from '@bomb.sh/tab/cac';
const cli = cac('my-cli');
// Define your CLI
cli
.command('dev', 'Start dev server')
.option('--port <port>', 'Specify port')
.option('--host <host>', 'Specify host');
// Initialize tab completions
const completion = await tab(cli);
// Add custom completions for option values
const devCommand = completion.commands.get('dev');
const portOption = devCommand?.options.get('port');
if (portOption) {
portOption.handler = (complete) => {
complete('3000', 'Development port');
complete('8080', 'Production port');
};
}
cli.parse();
import { defineCommand, createMain } from 'citty';
import tab from '@bomb.sh/tab/citty';
const main = defineCommand({
meta: { name: 'my-cli', description: 'My CLI tool' },
subCommands: {
dev: defineCommand({
meta: { name: 'dev', description: 'Start dev server' },
args: {
port: { type: 'string', description: 'Specify port' },
host: { type: 'string', description: 'Specify host' },
},
}),
},
});
// Initialize tab completions with custom handlers
await tab(main, {
subCommands: {
dev: {
options: {
port: (complete) => {
complete('3000', 'Development port');
complete('8080', 'Production port');
},
},
},
},
});
const cli = createMain(main);
cli();

Pass custom handlers as the second argument of tab(). The object mirrors your command tree: subCommands for nested commands, options for option values, and args for positional arguments. The CAC adapter accepts the same object.

With citty, setting handler on an option after tab() returns does not work for options that take a value. The adapter registers them as boolean flags, so my-cli dev --port <TAB> completes nothing.

Every subcommand needs a meta.description. Without one, tab() throws Invalid meta or missing description.

import { Command, Option } from 'commander';
import tab from '@bomb.sh/tab/commander';
const program = new Command('my-cli');
program.version('1.0.0');
// Options with .choices() get completions automatically
program.addOption(
new Option('-l, --logLevel <level>', 'Specify log level').choices([
'info',
'warn',
'error',
'silent',
])
);
program
.command('serve')
.description('Start the server')
.option('-p, --port <number>', 'port to use', '3000')
.option('-H, --host <host>', 'host to use', 'localhost')
.action(() => {
console.log('Starting server...');
});
program
.command('lint [files...]')
.description('Lint source files')
.option('--fix', 'automatically fix problems');
program
.command('copy <source> <destination>')
.description('Copy files');
// Initialize tab completions
const completion = tab(program);
// Custom option completions
const serveCommand = completion.commands.get('serve');
const portOption = serveCommand?.options.get('port');
if (portOption) {
portOption.handler = (complete) => {
complete('3000', 'Default port');
complete('8080', 'Alternative port');
};
}
// Custom positional argument completions
const lintCommand = completion.commands.get('lint');
const filesArg = lintCommand?.arguments.get('files');
if (filesArg) {
filesArg.handler = (complete) => {
complete('main.ts', 'Main file');
complete('index.ts', 'Index file');
};
}
const copyCommand = completion.commands.get('copy');
const sourceArg = copyCommand?.arguments.get('source');
if (sourceArg) {
sourceArg.handler = (complete) => {
complete('src/', 'Source directory');
complete('dist/', 'Distribution directory');
};
}
program.parse();

Options defined with Commander’s .choices() are picked up automatically — no custom handler needed. For positional arguments, the adapter reads argument definitions from your commands; add handlers via arguments.get('name') when you need custom suggestions.

The Commander integration supports customising the command name used to generate shell completion scripts. The default is complete. If you use a custom name like completion, it appears in help as completion <shell>, while runtime suggestions stay on the hidden complete -- [args...] command. Use your custom command when generating shell scripts:

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

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

Terminal window
# Generate shell completion script
my-cli complete zsh
# Parse completion request (called by shell)
my-cli complete -- dev --port ""

Output Format:

3000 Development port
8080 Production port
:4

Each line is a completion value, a tab, and its description. Values never include an --option= prefix. For --port=<TAB>, the zsh and fish scripts add the prefix back. The bash script does not, so in bash only --port <TAB> completes.

The last line is : followed by a directive, a bit flag that tells the shell how to treat the completions. tab always sends :4. A CLI that implements the protocol itself can send any of these values, or add several together. tab exports them as ShellCompDirective:

ValueNameEffect
0ShellCompDirectiveDefaultUse the shell’s default completion, such as file names
1ShellCompDirectiveErrorSomething failed, so the shell shows no completions
2ShellCompDirectiveNoSpaceDo not add a space after the completion
4ShellCompDirectiveNoFileCompDo not fall back to file completion
8ShellCompDirectiveFilterFileExtComplete file names, using the values as allowed extensions
16ShellCompDirectiveFilterDirsComplete directory names only
32ShellCompDirectiveKeepOrderKeep the completions in the order sent, instead of sorting them

fish does not support 8 and 16, and completes any file name instead.

We welcome contributions! tab’s architecture makes it easy to add support for new package managers or CLI frameworks.

tab was inspired by the great Cobra project, which set the standard for CLI tooling in the Go ecosystem.

We want to make it as easy as possible for the JS ecosystem to enjoy great autocompletions.
We at Thundraa would be happy to help any open source CLI utility adopt tab. If you maintain a CLI and would like autocompletions set up for your users, just drop the details in our Adopting tab discussion.
We’ll gladly help and even open a PR to get you started.