Cli Tools

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:

  1. User types mytool dep and presses Tab
  2. Shell finds the completion function registered for mytool
  3. Shell calls the function with the current word (dep) and previous words
  4. Function returns matching options (deploy)
  5. 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 completion subcommand. 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.

References

Powered by Contentful