Shell Auto-Completion for Custom CLIs
How to add tab-completion to Node.js CLI tools for Bash, Zsh, Fish, and PowerShell with static and dynamic completions.
Shell Auto-Completion for Custom CLIs
Tab completion separates tools developers tolerate from tools they love. When you press Tab and your CLI fills in the command, flag, or file path, it feels native. When nothing happens, you have to stop, check --help, and type the whole thing out. That friction adds up across thousands of invocations.
I add completion support to every CLI tool I build seriously. It takes an afternoon to set up and pays for itself immediately. This guide covers building completions from scratch for Bash, Zsh, Fish, and PowerShell.
Prerequisites
- Node.js installed (v14+)
- Access to Bash, Zsh, or Fish shell
- A CLI tool to add completions to
- Understanding of how shell completion works at a high level
How Shell Completion Works
When you press Tab in a shell, it calls a completion function registered for the current command. That function receives the current command line and cursor position, then returns a list of possible completions.
The flow is:
- User types
mytool depand presses Tab - Shell finds the completion function registered for
mytool - Shell calls the function with the current word (
dep) and previous words - Function returns matching options (
deploy) - Shell fills in or shows candidates
Each shell has its own completion system, but they all follow this pattern.
Bash Completions
Bash uses the complete builtin and completion functions written in shell script.
Basic Static Completion
# mytool-completion.bash
_mytool_completions() {
local cur prev commands
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
# Top-level commands
commands="deploy build test init config login logout status version help"
case "${prev}" in
mytool)
COMPREPLY=($(compgen -W "${commands}" -- "${cur}"))
return 0
;;
deploy)
COMPREPLY=($(compgen -W "staging production dev --dry-run --force --verbose" -- "${cur}"))
return 0
;;
build)
COMPREPLY=($(compgen -W "--target --output --minify --sourcemap --watch" -- "${cur}"))
return 0
;;
config)
COMPREPLY=($(compgen -W "show set get init --global" -- "${cur}"))
return 0
;;
--target)
COMPREPLY=($(compgen -W "node18 node20 browser" -- "${cur}"))
return 0
;;
--output)
COMPREPLY=($(compgen -W "json table yaml csv" -- "${cur}"))
return 0
;;
esac
# Default: complete with flags
if [[ "${cur}" == -* ]]; then
local flags="--help --version --verbose --quiet --no-color --config"
COMPREPLY=($(compgen -W "${flags}" -- "${cur}"))
return 0
fi
# Fall back to file completion
COMPREPLY=($(compgen -f -- "${cur}"))
}
complete -F _mytool_completions mytool
Install this completion:
# System-wide
sudo cp mytool-completion.bash /etc/bash_completion.d/mytool
# User-level
echo 'source /path/to/mytool-completion.bash' >> ~/.bashrc
Dynamic Completions via Node.js
For completions that depend on runtime data (project names, remote resources), call your Node.js tool:
_mytool_completions() {
local cur="${COMP_WORDS[COMP_CWORD]}"
# Ask the Node.js CLI for completions
local completions
completions=$(mytool --completions "${COMP_WORDS[@]}" 2>/dev/null)
COMPREPLY=($(compgen -W "${completions}" -- "${cur}"))
}
complete -F _mytool_completions mytool
In your Node.js CLI, handle the --completions flag:
function handleCompletions(argv) {
// argv: ["mytool", "--completions", "mytool", "deploy", "sta"]
var words = argv.slice(3); // Remove "mytool", "--completions", "mytool"
var current = words[words.length - 1] || "";
var previous = words[words.length - 2] || "";
var completions = [];
if (words.length <= 1) {
// Complete commands
completions = ["deploy", "build", "test", "init", "config", "login", "status"];
} else {
var command = words[0];
switch (command) {
case "deploy":
if (current.indexOf("-") === 0) {
completions = ["--dry-run", "--force", "--verbose", "--target"];
} else {
// Dynamic: list available targets from config
completions = getDeployTargets();
}
break;
case "config":
if (words.length === 2) {
completions = ["show", "set", "get", "init"];
} else if (words[1] === "set" || words[1] === "get") {
completions = getConfigKeys();
}
break;
case "build":
completions = ["--target", "--output", "--minify", "--watch"];
break;
}
}
// Filter by current prefix
var filtered = completions.filter(function(c) {
return c.indexOf(current) === 0;
});
console.log(filtered.join("\n"));
process.exit(0);
}
function getDeployTargets() {
var fs = require("fs");
var path = require("path");
try {
var config = JSON.parse(
fs.readFileSync(path.join(process.cwd(), ".mytoolrc"), "utf8")
);
return Object.keys(config.targets || {});
} catch (e) {
return ["staging", "production", "dev"];
}
}
function getConfigKeys() {
return ["provider", "region", "output", "verbose", "deploy.strategy", "deploy.maxSurge"];
}
// In your main CLI entry point
if (process.argv.indexOf("--completions") !== -1) {
handleCompletions(process.argv);
}
Zsh Completions
Zsh has a more powerful completion system than Bash. It supports descriptions, grouping, and rich formatting.
# _mytool - Zsh completion function
#compdef mytool
_mytool() {
local -a commands
commands=(
'deploy:Deploy application to a target environment'
'build:Build the project'
'test:Run test suite'
'init:Initialize a new project'
'config:Manage configuration'
'login:Authenticate with the service'
'logout:Clear authentication'
'status:Show deployment status'
)
_arguments -C \
'(-h --help)'{-h,--help}'[Show help]' \
'(-V --version)'{-V,--version}'[Show version]' \
'(-v --verbose)'{-v,--verbose}'[Verbose output]' \
'--no-color[Disable colored output]' \
'--config[Config file path]:config file:_files -g "*.json *.yaml *.yml"' \
'1:command:->command' \
'*::arg:->args'
case $state in
command)
_describe -t commands 'mytool command' commands
;;
args)
case $words[1] in
deploy)
_mytool_deploy
;;
build)
_mytool_build
;;
config)
_mytool_config
;;
esac
;;
esac
}
_mytool_deploy() {
local -a targets
targets=(
'staging:Deploy to staging environment'
'production:Deploy to production environment'
'dev:Deploy to development environment'
)
_arguments \
'--dry-run[Preview changes without deploying]' \
'--force[Skip confirmation prompts]' \
'(-v --verbose)'{-v,--verbose}'[Show detailed output]' \
'--rollback[Rollback to previous version]' \
'--tag[Deploy specific version tag]:tag:' \
'1:target:->target'
case $state in
target)
_describe -t targets 'deploy target' targets
;;
esac
}
_mytool_build() {
_arguments \
'--target[Build target]:target:(node18 node20 browser)' \
'--output[Output format]:format:(json table yaml csv)' \
'--minify[Minify output]' \
'--sourcemap[Generate source maps]' \
'--watch[Watch for changes]' \
'--outdir[Output directory]:directory:_directories'
}
_mytool_config() {
local -a subcommands
subcommands=(
'show:Display current configuration'
'set:Set a configuration value'
'get:Get a configuration value'
'init:Create a new config file'
)
_arguments \
'--global[Use global config]' \
'1:subcommand:->subcmd' \
'*::value:->value'
case $state in
subcmd)
_describe -t subcommands 'config subcommand' subcommands
;;
value)
case $words[1] in
set|get)
local -a keys
keys=(provider region output verbose deploy.strategy deploy.maxSurge)
_describe -t keys 'config key' keys
;;
esac
;;
esac
}
_mytool "$@"
Install for Zsh:
# Create completion directory if needed
mkdir -p ~/.zsh/completions
# Copy the completion file (must start with _)
cp _mytool ~/.zsh/completions/_mytool
# Add to fpath in ~/.zshrc
fpath=(~/.zsh/completions $fpath)
# Rebuild completion cache
rm -f ~/.zcompdump
autoload -Uz compinit && compinit
Fish Completions
Fish shell has the cleanest completion syntax. Each completion is a single complete command:
# mytool.fish - Fish completions
# Disable file completions by default
complete -c mytool -f
# Commands
complete -c mytool -n __fish_use_subcommand -a deploy -d "Deploy application"
complete -c mytool -n __fish_use_subcommand -a build -d "Build the project"
complete -c mytool -n __fish_use_subcommand -a test -d "Run test suite"
complete -c mytool -n __fish_use_subcommand -a init -d "Initialize project"
complete -c mytool -n __fish_use_subcommand -a config -d "Manage configuration"
complete -c mytool -n __fish_use_subcommand -a login -d "Authenticate"
complete -c mytool -n __fish_use_subcommand -a status -d "Show status"
# Global flags
complete -c mytool -l help -s h -d "Show help"
complete -c mytool -l version -s V -d "Show version"
complete -c mytool -l verbose -s v -d "Verbose output"
complete -c mytool -l no-color -d "Disable colors"
complete -c mytool -l config -rF -d "Config file path"
# Deploy subcommand
complete -c mytool -n "__fish_seen_subcommand_from deploy" -a "staging" -d "Staging environment"
complete -c mytool -n "__fish_seen_subcommand_from deploy" -a "production" -d "Production environment"
complete -c mytool -n "__fish_seen_subcommand_from deploy" -a "dev" -d "Development environment"
complete -c mytool -n "__fish_seen_subcommand_from deploy" -l dry-run -d "Preview without deploying"
complete -c mytool -n "__fish_seen_subcommand_from deploy" -l force -d "Skip confirmations"
complete -c mytool -n "__fish_seen_subcommand_from deploy" -l rollback -d "Rollback to previous"
complete -c mytool -n "__fish_seen_subcommand_from deploy" -l tag -r -d "Version tag"
# Build subcommand
complete -c mytool -n "__fish_seen_subcommand_from build" -l target -ra "node18 node20 browser" -d "Build target"
complete -c mytool -n "__fish_seen_subcommand_from build" -l output -ra "json table yaml csv" -d "Output format"
complete -c mytool -n "__fish_seen_subcommand_from build" -l minify -d "Minify output"
complete -c mytool -n "__fish_seen_subcommand_from build" -l sourcemap -d "Generate source maps"
complete -c mytool -n "__fish_seen_subcommand_from build" -l watch -d "Watch mode"
# Config subcommand
complete -c mytool -n "__fish_seen_subcommand_from config" -a "show" -d "Display config"
complete -c mytool -n "__fish_seen_subcommand_from config" -a "set" -d "Set a value"
complete -c mytool -n "__fish_seen_subcommand_from config" -a "get" -d "Get a value"
complete -c mytool -n "__fish_seen_subcommand_from config" -a "init" -d "Create config"
complete -c mytool -n "__fish_seen_subcommand_from config" -l global -d "Use global config"
# Dynamic completions for config keys
complete -c mytool -n "__fish_seen_subcommand_from config; and __fish_seen_subcommand_from set get" \
-a "(mytool --completions config keys 2>/dev/null)" -d "Config key"
Install for Fish:
# Fish auto-loads from this directory
cp mytool.fish ~/.config/fish/completions/mytool.fish
PowerShell Completions
PowerShell uses Register-ArgumentCompleter for custom completions:
# mytool.ps1 - PowerShell completions
Register-ArgumentCompleter -Native -CommandName mytool -ScriptBlock {
param(
[string]$wordToComplete,
[string]$commandAst,
[int]$cursorPosition
)
$words = $commandAst -split '\s+'
$commands = @{
'deploy' = 'Deploy application to a target environment'
'build' = 'Build the project'
'test' = 'Run test suite'
'init' = 'Initialize a new project'
'config' = 'Manage configuration'
'login' = 'Authenticate with the service'
'status' = 'Show deployment status'
}
$globalFlags = @{
'--help' = 'Show help'
'--version' = 'Show version'
'--verbose' = 'Verbose output'
'--no-color' = 'Disable colored output'
'--config' = 'Config file path'
}
# Completing command
if ($words.Count -le 2) {
$commands.GetEnumerator() | Where-Object { $_.Key -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_.Key, $_.Key, 'ParameterValue', $_.Value
)
}
$globalFlags.GetEnumerator() | Where-Object { $_.Key -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_.Key, $_.Key, 'ParameterName', $_.Value
)
}
return
}
$command = $words[1]
switch ($command) {
'deploy' {
$targets = @{
'staging' = 'Staging environment'
'production' = 'Production environment'
'dev' = 'Development environment'
}
$flags = @{
'--dry-run' = 'Preview without deploying'
'--force' = 'Skip confirmations'
'--verbose' = 'Detailed output'
'--rollback' = 'Rollback to previous'
}
if ($wordToComplete -like '-*') {
$flags.GetEnumerator() | Where-Object { $_.Key -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_.Key, $_.Key, 'ParameterName', $_.Value
)
}
} else {
$targets.GetEnumerator() | Where-Object { $_.Key -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_.Key, $_.Key, 'ParameterValue', $_.Value
)
}
}
}
'build' {
@{
'--target' = 'Build target'
'--output' = 'Output format'
'--minify' = 'Minify output'
'--sourcemap' = 'Source maps'
'--watch' = 'Watch mode'
}.GetEnumerator() | Where-Object { $_.Key -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_.Key, $_.Key, 'ParameterName', $_.Value
)
}
}
}
}
Install for PowerShell:
# Add to your PowerShell profile
echo '. /path/to/mytool.ps1' >> $PROFILE
Generating Completions from Your CLI
Instead of maintaining separate completion scripts, generate them from your CLI's command definition:
var fs = require("fs");
var COMMANDS = {
deploy: {
description: "Deploy application to a target environment",
args: {
target: {
values: ["staging", "production", "dev"],
descriptions: {
staging: "Staging environment",
production: "Production environment",
dev: "Development environment"
}
}
},
flags: {
"--dry-run": "Preview changes without deploying",
"--force": "Skip confirmation prompts",
"--verbose": "Show detailed output",
"--tag": { description: "Version tag", takesValue: true }
}
},
build: {
description: "Build the project",
flags: {
"--target": { description: "Build target", values: ["node18", "node20", "browser"] },
"--output": { description: "Output format", values: ["json", "table", "yaml"] },
"--minify": "Minify output",
"--watch": "Watch for changes"
}
},
config: {
description: "Manage configuration",
subcommands: {
show: "Display current configuration",
set: "Set a configuration value",
get: "Get a configuration value",
init: "Create a new config file"
},
flags: {
"--global": "Use global config"
}
}
};
var GLOBAL_FLAGS = {
"--help": "Show help",
"--version": "Show version",
"--verbose": "Verbose output",
"--no-color": "Disable colored output"
};
function generateBash() {
var out = [];
out.push("# Auto-generated bash completions for mytool");
out.push("_mytool_completions() {");
out.push(' local cur="${COMP_WORDS[COMP_CWORD]}"');
out.push(' local prev="${COMP_WORDS[COMP_CWORD-1]}"');
out.push("");
// Top-level
var cmds = Object.keys(COMMANDS).join(" ");
out.push(" case \"${prev}\" in");
out.push(" mytool)");
out.push(' COMPREPLY=($(compgen -W "' + cmds + '" -- "${cur}"))');
out.push(" return 0");
out.push(" ;;");
// Each command
var cmdNames = Object.keys(COMMANDS);
for (var i = 0; i < cmdNames.length; i++) {
var name = cmdNames[i];
var cmd = COMMANDS[name];
var words = [];
if (cmd.subcommands) {
words = words.concat(Object.keys(cmd.subcommands));
}
if (cmd.args && cmd.args.target) {
words = words.concat(cmd.args.target.values);
}
if (cmd.flags) {
words = words.concat(Object.keys(cmd.flags));
}
out.push(" " + name + ")");
out.push(' COMPREPLY=($(compgen -W "' + words.join(" ") + '" -- "${cur}"))');
out.push(" return 0");
out.push(" ;;");
}
out.push(" esac");
out.push("");
// Flag completion
var globalFlags = Object.keys(GLOBAL_FLAGS).join(" ");
out.push(' if [[ "${cur}" == -* ]]; then');
out.push(' COMPREPLY=($(compgen -W "' + globalFlags + '" -- "${cur}"))');
out.push(" return 0");
out.push(" fi");
out.push("}");
out.push("");
out.push("complete -F _mytool_completions mytool");
return out.join("\n");
}
function generateFish() {
var out = [];
out.push("# Auto-generated Fish completions for mytool");
out.push("complete -c mytool -f");
out.push("");
// Commands
var cmdNames = Object.keys(COMMANDS);
for (var i = 0; i < cmdNames.length; i++) {
var name = cmdNames[i];
var desc = COMMANDS[name].description;
out.push('complete -c mytool -n __fish_use_subcommand -a "' + name + '" -d "' + desc + '"');
}
out.push("");
// Global flags
var flagNames = Object.keys(GLOBAL_FLAGS);
for (var f = 0; f < flagNames.length; f++) {
var flag = flagNames[f].replace(/^--/, "");
out.push('complete -c mytool -l "' + flag + '" -d "' + GLOBAL_FLAGS[flagNames[f]] + '"');
}
out.push("");
// Per-command completions
for (var j = 0; j < cmdNames.length; j++) {
var cmdName = cmdNames[j];
var cmd = COMMANDS[cmdName];
if (cmd.args && cmd.args.target) {
var vals = cmd.args.target.values;
for (var v = 0; v < vals.length; v++) {
var valDesc = (cmd.args.target.descriptions && cmd.args.target.descriptions[vals[v]]) || "";
out.push('complete -c mytool -n "__fish_seen_subcommand_from ' + cmdName + '" -a "' + vals[v] + '" -d "' + valDesc + '"');
}
}
if (cmd.flags) {
var cmdFlags = Object.keys(cmd.flags);
for (var cf = 0; cf < cmdFlags.length; cf++) {
var cflag = cmdFlags[cf].replace(/^--/, "");
var cdesc = typeof cmd.flags[cmdFlags[cf]] === "string"
? cmd.flags[cmdFlags[cf]]
: cmd.flags[cmdFlags[cf]].description;
out.push('complete -c mytool -n "__fish_seen_subcommand_from ' + cmdName + '" -l "' + cflag + '" -d "' + cdesc + '"');
}
}
if (cmd.subcommands) {
var subs = Object.keys(cmd.subcommands);
for (var s = 0; s < subs.length; s++) {
out.push('complete -c mytool -n "__fish_seen_subcommand_from ' + cmdName + '" -a "' + subs[s] + '" -d "' + cmd.subcommands[subs[s]] + '"');
}
}
}
return out.join("\n");
}
// CLI handler
function completionCommand(shell) {
switch (shell) {
case "bash":
console.log(generateBash());
break;
case "fish":
console.log(generateFish());
break;
default:
console.log("Supported shells: bash, zsh, fish, powershell");
console.log("");
console.log("Usage:");
console.log(" mytool completion bash > /etc/bash_completion.d/mytool");
console.log(" mytool completion fish > ~/.config/fish/completions/mytool.fish");
break;
}
}
Users install with:
# Bash
mytool completion bash > /etc/bash_completion.d/mytool
# Fish
mytool completion fish > ~/.config/fish/completions/mytool.fish
# Zsh
mytool completion zsh > ~/.zsh/completions/_mytool
# Or eval for temporary use
eval "$(mytool completion bash)"
Complete Working Example: CLI with Built-In Completions
#!/usr/bin/env node
var fs = require("fs");
var path = require("path");
// ---- Command registry ----
var registry = {
commands: {},
globalFlags: {},
command: function(name, desc, config) {
this.commands[name] = { description: desc, flags: {}, args: [], subcommands: {} };
if (config) config(this.commands[name]);
return this;
},
flag: function(name, desc) {
this.globalFlags[name] = desc;
return this;
}
};
// Define commands
registry
.flag("--help", "Show help")
.flag("--version", "Show version")
.flag("--verbose", "Verbose output")
.command("deploy", "Deploy to an environment", function(cmd) {
cmd.args = [
{ name: "target", values: ["staging", "production", "dev"] }
];
cmd.flags = {
"--dry-run": "Preview changes",
"--force": "Skip confirmations",
"--tag": "Version tag"
};
})
.command("build", "Build the project", function(cmd) {
cmd.flags = {
"--target": "Build target (node18, node20)",
"--output": "Output format",
"--watch": "Watch mode"
};
})
.command("config", "Manage configuration", function(cmd) {
cmd.subcommands = {
show: "Display config",
set: "Set a value",
get: "Get a value"
};
})
.command("completion", "Generate shell completions", function(cmd) {
cmd.args = [{ name: "shell", values: ["bash", "zsh", "fish", "powershell"] }];
});
// ---- Completion generators ----
function genBashCompletion() {
var cmds = Object.keys(registry.commands);
var flags = Object.keys(registry.globalFlags);
var lines = [
"_mytool() {",
' local cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}"',
" case \"${prev}\" in",
" mytool)",
' COMPREPLY=($(compgen -W "' + cmds.join(" ") + '" -- "${cur}"))',
" return ;;"
];
for (var i = 0; i < cmds.length; i++) {
var cmd = registry.commands[cmds[i]];
var words = Object.keys(cmd.flags || {})
.concat(Object.keys(cmd.subcommands || {}));
if (cmd.args) {
for (var a = 0; a < cmd.args.length; a++) {
words = words.concat(cmd.args[a].values || []);
}
}
if (words.length) {
lines.push(" " + cmds[i] + ")");
lines.push(' COMPREPLY=($(compgen -W "' + words.join(" ") + '" -- "${cur}"))');
lines.push(" return ;;");
}
}
lines.push(" esac");
lines.push(' [[ "${cur}" == -* ]] && COMPREPLY=($(compgen -W "' + flags.join(" ") + '" -- "${cur}"))');
lines.push("}");
lines.push("complete -F _mytool mytool");
return lines.join("\n");
}
function genFishCompletion() {
var lines = ["complete -c mytool -f"];
var cmds = Object.keys(registry.commands);
for (var i = 0; i < cmds.length; i++) {
lines.push('complete -c mytool -n __fish_use_subcommand -a ' + cmds[i] +
' -d "' + registry.commands[cmds[i]].description + '"');
}
var flags = Object.keys(registry.globalFlags);
for (var f = 0; f < flags.length; f++) {
lines.push('complete -c mytool -l ' + flags[f].replace(/^--/, "") +
' -d "' + registry.globalFlags[flags[f]] + '"');
}
for (var j = 0; j < cmds.length; j++) {
var cmd = registry.commands[cmds[j]];
var cmdFlags = Object.keys(cmd.flags || {});
for (var cf = 0; cf < cmdFlags.length; cf++) {
lines.push('complete -c mytool -n "__fish_seen_subcommand_from ' + cmds[j] +
'" -l ' + cmdFlags[cf].replace(/^--/, "") +
' -d "' + cmd.flags[cmdFlags[cf]] + '"');
}
}
return lines.join("\n");
}
// ---- Internal completion handler ----
function handleInternalCompletion(argv) {
var words = argv.slice(3);
var current = words[words.length - 1] || "";
var results = [];
if (words.length <= 1) {
results = Object.keys(registry.commands);
} else {
var cmd = registry.commands[words[0]];
if (cmd) {
results = Object.keys(cmd.flags || {})
.concat(Object.keys(cmd.subcommands || {}));
if (cmd.args) {
for (var i = 0; i < cmd.args.length; i++) {
results = results.concat(cmd.args[i].values || []);
}
}
}
}
var filtered = results.filter(function(r) {
return r.indexOf(current) === 0;
});
console.log(filtered.join("\n"));
process.exit(0);
}
// ---- Main ----
// Handle --completions for dynamic shell completion
if (process.argv.indexOf("--completions") !== -1) {
handleInternalCompletion(process.argv);
}
var args = process.argv.slice(2);
var command = args[0];
if (command === "completion") {
var shell = args[1];
switch (shell) {
case "bash": console.log(genBashCompletion()); break;
case "fish": console.log(genFishCompletion()); break;
default:
console.log("Generate completions for your shell:\n");
console.log(" Bash: mytool completion bash >> ~/.bashrc");
console.log(" Fish: mytool completion fish > ~/.config/fish/completions/mytool.fish");
console.log(" Zsh: mytool completion zsh > ~/.zsh/completions/_mytool");
}
process.exit(0);
}
// Normal command handling
if (!command || command === "--help") {
console.log("\nmytool - Example CLI with completions\n");
var cmds = Object.keys(registry.commands);
for (var c = 0; c < cmds.length; c++) {
console.log(" " + cmds[c].padEnd(15) + registry.commands[cmds[c]].description);
}
console.log("\nRun 'mytool completion' to set up tab completion\n");
process.exit(0);
}
console.log("Running: " + command + " " + args.slice(1).join(" "));
Common Issues and Troubleshooting
Completions not loading after install
Bash caches completions. After installing, you need to either source your profile or start a new shell:
source ~/.bashrc
# or
exec bash
Zsh completion "not found" error
The completion file must start with #compdef mytool and be named _mytool (with underscore prefix):
_mytool:2: command not found: _arguments
Fix: Ensure compinit is called in your .zshrc and the file is in $fpath. Delete ~/.zcompdump and restart.
Dynamic completions are slow
If your CLI takes 500ms+ to start, tab completion feels laggy:
Fix: Make the --completions code path as fast as possible. Skip loading unnecessary modules. Cache results to a file. Consider generating static completions instead of dynamic ones.
Completions clobbered by other tools
Two tools registering completions for the same name will conflict:
Fix: Use unique command names. If you provide an alias (dt for deploytool), register completions for both names.
Best Practices
- Generate completions from your command definitions. A single source of truth prevents drift between your help text and completions.
- Support all major shells. Bash and Zsh cover most developers. Fish is growing. PowerShell covers Windows.
- Provide a
completionsubcommand. Make it easy for users to install:eval "$(mytool completion bash)". - Keep dynamic completions fast. If your CLI has a slow startup, consider caching or static generation.
- Include descriptions in completions. Zsh and Fish display descriptions next to options. Bash does not, but include them anyway for forward compatibility.
- Test completions manually. Type your command and press Tab in each shell to verify behavior before releasing.
- Document installation in your README. Most users do not know how to install shell completions. Provide copy-paste commands for each shell.