When tab does nothing
You built a command-line tool. It compiles. It runs. It does the thing you asked it to do. You type the binary name, hit space, and stare at a blinking cursor. You have to remember every flag, every subcommand, and the exact order of arguments. Your users will feel the same friction. Shell completion turns a forgetful interface into a guided one. It takes minutes to wire up if you let the framework do the heavy lifting.
How shell completion actually works
Shell completion is a handshake between your program and the terminal. When a user presses tab, the shell interrupts the input line, runs a small script, and asks your program what valid options exist. Your program replies with a list of strings. The shell prints them and waits for the next keystroke. The whole exchange happens in milliseconds.
Go does not include this in the standard library because completion requires shell-specific syntax. Bash relies on compgen and shell functions. Zsh uses zle widgets and dynamic function loading. Fish uses its own complete command with a declarative syntax. Writing those scripts by hand is tedious and error-prone. The cobra library ships with a built-in generator that inspects your command tree and outputs the correct syntax for each shell automatically. You define the commands in Go. Cobra translates them into shell dialects.
Completion is just a conversation. Your program answers, the shell formats.
A minimal CLI with a command tree
Start with a tiny program that has one subcommand and one flag. The structure matters because the completion script mirrors it exactly. Cobra builds a graph of *cobra.Command objects. Each node can have flags, arguments, and child commands. The generator walks that graph and extracts the metadata.
Here is the simplest Cobra setup that produces meaningful completions:
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
// rootCmd defines the base command and its default behavior
var rootCmd = &cobra.Command{
Use: "mycli",
Short: "A minimal CLI for testing completion",
Run: func(cmd *cobra.Command, args []string) {
// Print a default message when the user runs the binary alone
fmt.Println("CLI initialized")
},
}
// deployCmd handles the deploy subcommand and requires one argument
var deployCmd = &cobra.Command{
Use: "deploy [target]",
Short: "Deploy to a specific environment",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
// Echo the target environment back to the terminal
fmt.Printf("Deploying to %s\n", args[0])
},
}
func init() {
// Register the subcommand so Cobra includes it in the command tree
rootCmd.AddCommand(deployCmd)
}
func main() {
// Execute the root command and exit with a non-zero code on failure
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Cobra does the heavy lifting. You just point it at the right directory.
Walking the command graph
When you run mycli --help, Cobra prints the command tree to standard output. The completion generator does the same thing programmatically. It traverses the *cobra.Command graph, collects flags, subcommands, and valid arguments, then formats them into shell-specific syntax. You do not need to understand compopt or zle -C to make it work. You just need to tell Cobra which shell you want.
The generator reads three things from your command tree:
- The
Usestring, which becomes the trigger word. - The
Shortdescription, which appears as a tooltip or inline hint. - The flag definitions and
ValidArgsFunction, which determine what tab suggests.
If you attach a ValidArgsFunction to a command, Cobra calls it at runtime when the user presses tab. This enables dynamic completion. You can query a database, read a directory, or hit an API to populate the list. The generator wraps that callback in the correct shell boilerplate so you never touch compgen -W or zle -N.
Trust the graph. If it shows up in --help, it shows up in tab.
Generating and installing the scripts
In production, you rarely ask users to run generation commands manually. You either provide a script, or you embed the completions directly into the binary. Let's look at the generation step first. Compile the binary, then run the built-in completion command for each shell.
Here is the standard installation workflow for a compiled CLI:
# Compile the source into a standalone executable
go build -o mycli .
# Generate Bash syntax and redirect to the system completion directory
./mycli completion bash > /etc/bash_completion.d/mycli
# Generate Zsh syntax and place it where the shell auto-discovers plugins
./mycli completion zsh > /usr/local/share/zsh/site-functions/_mycli
# Generate Fish syntax and save it to the user-specific completion path
./mycli completion fish > ~/.config/fish/completions/mycli.fish
Each shell expects completions in a specific location. Bash reads /etc/bash_completion.d/ on startup. Zsh searches directories listed in fpath, and /usr/local/share/zsh/site-functions/ is the standard system-wide location. Fish reads ~/.config/fish/completions/ and merges all .fish files it finds. Drop the script in the wrong place and tab does nothing. You will not get a compiler error. You just get silence.
Path matters. Name matters. Test it before you ship it.
Where things go wrong
The most common failure is a path mismatch. If you generate a Bash script and save it to ~/.bashrc, it will not work. Bash expects standalone scripts in the completion directory, not inline exports. Zsh expects the file to start with an underscore. The Cobra generator outputs _mycli by default, but if you rename it to mycli.zsh, Zsh treats it as a regular script and skips the completion widget. You will see zsh: command not found: _mycli when the shell tries to autoload it.
Another trap is permissions. Writing to /etc/ or /usr/local/ requires elevated privileges. If you run the generation command without sudo, the shell throws a permission denied error and the file stays empty. Run sudo ./mycli completion bash > /etc/bash_completion.d/mycli or generate to your home directory and move it later.
Fish overrides built-in completions aggressively. If you name your script git.fish, it will replace the default Git completions. Always use your CLI's exact binary name. The community convention is to match the Use field exactly. If your binary is mycli, the script is mycli.fish. If your binary is my-cli, the script is my-cli.fish. Hyphens and underscores matter.
Dynamic completion adds another layer of complexity. If you use ValidArgsFunction, the shell calls your binary with a special __complete subcommand behind the scenes. If your binary panics during that call, the shell prints the panic stack trace instead of completions. Always handle missing flags gracefully in your completion callback. Return an empty slice rather than crashing.
The worst completion bug is the one that silently fails. Run mycli --help and mycli completion bash side by side. Verify the output matches.
Choosing your completion strategy
Use generated completion scripts when you distribute a binary through package managers or Docker containers. Use embedded completions when you want a single-file binary that works out of the box without installation steps. Use manual shell scripting when your CLI needs dynamic completion that queries a remote API on every tab press. Use Cobra's built-in generator when you want zero maintenance and standard behavior.
Generate for packages. Embed for portability. Script for dynamic data.