GICT knowledge base
Writing Shell Completion for Your Own Scripts: Bash Tab Completion From Scratch
Published
You have a script called serverctl that deploys, checks, and rolls back services on your VPS. The help text lists four subcommands, but you keep forgetting the exact names. Every time you hit Tab after typing serverctl , nothing happens.
That is the gap this article fills. After the steps below, pressing Tab after serverctl will offer deploy, status, rollback, and logs as suggestions, then adapt based on which subcommand you picked, then complete file paths when you type -f. You will understand exactly how each layer works.
How Bash Actually Invokes Completion
When you press Tab, readline catches it and fires a completion hook. Bash receives that hook and calls the function you registered for that command. That function populates an array called COMPREPLY, and readline displays the results.
Nothing magical happens. Bash calls a function you wrote, you put strings into COMPREPLY, and bash shows them. Once that mental model is solid, the rest follows naturally.
Stage 1: Register Your First Completion in 60 Seconds
Create a dummy script that mimics serverctl:
#!/bin/bash
case "$1" in
deploy) echo "Deploying $2..." ;;
status) echo "Status of $2..." ;;
rollback) echo "Rolling back $2..." ;;
logs) echo "Logs for $2..." ;;
*) echo "Usage: serverctl {deploy|status|rollback|logs} [target]"
esac
Save it as /usr/local/bin/serverctl, make it executable, and register completion for it:
chmod +x /usr/local/bin/serverctl
complete -W "deploy status rollback logs" serverctl
Now open a new bash session (or run bash to get a fresh shell) and type:
serverctl [Tab][Tab]
Bash will offer deploy, status, rollback, and logs. The -W flag passes a word list to bash’s built-in completion generator. It takes about 15 seconds to set up and works immediately.
Stage 2: Option Flags and Filename Completion
The -W approach handles subcommands well, but it does not know about flags. Your script probably accepts -f for a config file or -h for help. Bash has a built-in completion generator called compgen that handles different completion types. The -o filenames option tells bash to complete filenames and directories, which is what you want for a path argument.
Write a completion function and register it:
_serverctl_complete() {
local cur prev words cword
_init_completion || return
case "$prev" in
-f|--config)
compopt -o filenames
COMPREPLY=( $(compgen -o filenames -- "$cur") )
return
;;
-h|--help)
return
;;
esac
COMPREPLY=( $(compgen -W "deploy status rollback logs" -- "$cur") )
}
complete -F _serverctl_complete serverctl
Drop this in your shell or into /etc/bash_completion.d/serverctl (more on that later). Now after you type serverctl -f and press Tab, bash will complete file and directory names. The compopt -o filenames line is what enables that behavior. Without it, bash treats the argument as a plain word.
The Two Lines You Will Copy Into Every Completion Function
Every completion function starts with these two lines:
local cur prev words cword
_init_completion || return
_init_completion is a bash-completion utility function that parses the current command line and sets cur (the word under the cursor), prev (the previous word), words (array of all words), and cword (index of the current word). You need these values to decide what to suggest.
Note that _init_completion comes from the bash-completion package. If you see a “not found” error, add this at the top of your script:
source /usr/share/bash-completion/bash_completion 2>/dev/null || true
The || true keeps the script from failing on systems where the package is not installed.
Stage 3: Subcommand-Aware Completion
Static word lists stop being useful once your script has real arguments. After the subcommand, you need to complete server names, not more subcommands. And the server list should come from your actual config file.
Assume /etc/serverctl/servers.conf contains one server name per line:
web-01
web-02
api-primary
api-secondary
db-master
Update the completion function to read this file:
_serverctl_complete() {
local cur prev words cword
_init_completion || return
case "$prev" in
-f|--config)
compopt -o filenames
COMPREPLY=( $(compgen -o filenames -- "$cur") )
return
;;
deploy|status|rollback|logs)
COMPREPLY=( $(compgen -W "$(cat /etc/serverctl/servers.conf)" -- "$cur") )
return
;;
esac
COMPREPLY=( $(compgen -W "deploy status rollback logs" -- "$cur") )
}
complete -F _serverctl_complete serverctl
Now type serverctl deploy [Tab][Tab] and bash will offer web-01, web-02, api-primary, and so on. The completion adapts based on which subcommand preceded the cursor.
The case "$prev" in branch detects the previous word. When it matches a subcommand, the function loads server names from the config file and uses those as completion candidates instead of the subcommand list.
Stage 4: Quoting, Spaces, and Edge Cases
Try an edge case. What happens when a server name contains a space, like web server 01? Bash completion breaks unless you handle quoting properly.
The fix uses compopt -o filenames plus the -o filenames flag on compgen, which tells bash to apply its filename quoting logic to the results:
compopt -o filenames
COMPREPLY=( $(compgen -o filenames -W "$(cat /etc/serverctl/servers.conf)" -- "$cur") )
Another common issue: completion fires on the -- separator. If your script accepts -- to stop option parsing, add it to the completion logic so filenames complete after it:
case "$cur" in
--)
compopt -o filenames
COMPREPLY=( $(compgen -o filenames -- "") )
return
;;
esac
Stage 5: Distribute the Completion File
Handing users a code snippet is not a proper distribution. Bash reads every file in /etc/bash_completion.d/ when a new shell starts, so dropping your completion script there makes it load automatically for all users:
sudo cp _serverctl_complete.sh /etc/bash_completion.d/serverctl
sudo chmod +x /etc/bash_completion.d/serverctl
The filename inside that directory should match your command name. Now every new bash session will register the completion automatically, and it will survive reboots.
Debugging Completion When It Does Not Work
The most useful debugging command is complete -p, which shows every registered completion and its flags:
$ complete -p serverctl
complete -F _serverctl_complete serverctl
If you see no output for your command, the completion was never registered. Source your completion file and try again.
Another trick is set -x inside the completion function itself. Bash will print the values of cur, prev, and COMPREPLY to stderr, letting you trace what the function actually received:
_serverctl_complete() {
local cur prev words cword
_init_completion || return
set -x
# ... rest of function
}
Remove the set -x line after debugging.
What You Can Build From Here
The five stages above are a ladder. Each stage adds one capability on top of the previous one, and by the top you have a completion function that knows your subcommands, adapts to them, handles path arguments correctly, and installs system-wide.
Git, Docker, kubectl, and every CLI you use daily all work exactly this way. Their completion functions are bash scripts registered with complete -F. Now you can read them, debug them, and extend them. The same patterns apply to zsh as well, with minor differences in the function names.
Ready to automate your server management? InterServer VPS plans start at $6/month with full root access and script-friendly environments. Deploy your own control plane and manage it with tools like this from the command line.
Related guides
How to Use the trap Command in Bash
Learn how to use the trap command in Bash to gracefully handle script interruptions, clean up resources, and improve your error handling practices.
Linux ScriptingHow to Write a Basic Shell Script in Linux
Learn how to write a basic shell script in Linux with step-by-step guidance, practical examples, and common troubleshooting tips. Ideal for beginners.
