← Back to DevBytes

Fish Scripting: Completion System Complete Guide

Fish Shell Completion System: A Complete Guide

Fish shell's completion system is one of its most celebrated features. Unlike traditional shells that rely on complex, often inscrutable completion scripts, Fish provides a clean, declarative syntax for defining how commands, arguments, and options should be completed. This guide covers everything you need to know to write robust, user-friendly completions for your own scripts and commands.

What Is the Fish Completion System?

The completion system is the mechanism that suggests possible arguments, options, file paths, and other values when a user presses Tab after typing part of a command. Fish goes far beyond simple filename completion β€” it can complete command-specific options, subcommands, environment variables, process IDs, git branches, and virtually anything your script or program might accept. Completions in Fish are defined using the complete builtin, which registers completion rules in a way that is both human-readable and highly maintainable.

Why the Completion System Matters

A well-designed completion system transforms the command-line experience. Here is why investing time in writing completions pays off:

How the Completion System Works

When you press Tab, Fish looks up all registered completions for the command currently being typed. It evaluates conditions, checks the token being completed against the cursor position, and filters the candidate list based on what the user has already typed. Completions are defined globally using the complete command and stored persistently. Fish also ships with a rich set of built-in completions for common Unix commands.

The core building blocks of a completion definition are:

Your First Custom Completion

Let's start with a simple example. Suppose you have a script called greet that accepts --name and --language options:

#!/usr/bin/env fish
# greet.fish - A friendly greeter script

function greet
    argparse 'n/name=' 'l/language=' -- $argv
    set -l name $_flag_name
    set -l lang $_flag_language
    test -z "$name" && set name "World"
    test -z "$lang" && set lang "English"
    
    switch $lang
        case English
            echo "Hello, $name!"
        case Spanish
            echo "Β‘Hola, $name!"
        case French
            echo "Bonjour, $name!"
        case '*'
            echo "Unknown language: $lang"
    end
end

Now we write completions for greet. Place the following in a file called greet.fish inside your completions directory (typically ~/.config/fish/completions/):

# Completions for greet

# Complete the --name option: suggest no specific value, just indicate it takes a string
complete -c greet -l name -d "Name of the person to greet" -x

# Complete the --language option with a fixed list of languages
complete -c greet -l language -d "Language to use for greeting" -x -a "English Spanish French"

# Short flags: -n for --name, -l for --language
complete -c greet -s n -l name -d "Name of the person to greet" -x
complete -c greet -s l -l language -d "Language to use for greeting" -x -a "English Spanish French"

# Also complete --help
complete -c greet -l help -d "Show help message"

Here is what each flag to complete means:

Completing Subcommands

Many modern CLI tools (like git, docker, or kubectl) use a subcommand pattern. Fish handles this elegantly with the --condition flag and by using the __fish_seen_subcommand_from helper. Let's build a completion for a hypothetical todo command with subcommands:

#!/usr/bin/env fish
# todo.fish - A task manager

function todo
    set -l subcommand (string tolower "$argv[1]")
    test -z "$subcommand" && set subcommand "list"
    
    switch $subcommand
        case list
            echo "Listing all tasks..."
        case add
            test -z "$argv[2]" && echo "Usage: todo add <task description>" && return 1
            echo "Added task: $argv[2]"
        case done
            test -z "$argv[2]" && echo "Usage: todo done <task-id>" && return 1
            echo "Marked task $argv[2] as done"
        case '*'
            echo "Unknown subcommand: $subcommand"
            return 1
    end
end

The completion file for todo would look like this:

# Completions for todo

# First, complete subcommands when no subcommand has been entered yet
complete -c todo -n "not __fish_seen_subcommand_from list add done" \
    -a "list" -d "List all tasks"

complete -c todo -n "not __fish_seen_subcommand_from list add done" \
    -a "add" -d "Add a new task"

complete -c todo -n "not __fish_seen_subcommand_from list add done" \
    -a "done" -d "Mark a task as completed"

# Complete the --help flag for all subcommands
complete -c todo -l help -d "Show help"

# For the 'add' subcommand, suggest nothing specific (free-form text)
# but prevent file completion since we want a task description string
complete -c todo -n "__fish_seen_subcommand_from add" -x -r \
    -d "Task description (free text)"

# For the 'done' subcommand, we might complete task IDs
# This would typically come from a dynamic source β€” see next section
complete -c todo -n "__fish_seen_subcommand_from done" -x -r \
    -d "Task ID to mark as done"

The -n or --condition flag accepts a fish command that returns true (zero exit status) or false. The helper __fish_seen_subcommand_from checks whether the user has already typed one of the listed subcommands. Negating it with not means "only activate this completion when no subcommand has been entered yet."

Dynamic Completions

Static lists are fine for fixed options, but many commands need completions that change based on the system state. Fish supports dynamic argument completion through several mechanisms:

Using Functions as Argument Sources

You can use the -a flag with parentheses to call a function that generates candidates dynamically:

# A function that returns a list of available task IDs
function __todo_list_ids
    # Simulate fetching task IDs from a data store
    echo "1\tFirst task"
    echo "2\tSecond task"
    echo "3\tThird task"
end

# Use the function for dynamic completion
complete -c todo -n "__fish_seen_subcommand_from done" \
    -a "(__todo_list_ids)" -d "Task ID to mark as done"

Fish calls __todo_list_ids each time completion is triggered and uses its output as candidates. Each line can contain a value and a description separated by a tab character.

Using External Commands

You can also pipe the output of external commands directly into completions:

# Complete Docker container IDs dynamically
complete -c mydockerctl -n "__fish_seen_subcommand_from stop restart" \
    -a "(docker ps -q --format '{{.ID}}\t{{.Names}}')" -d "Container ID"

Completing File Paths with Filters

Fish excels at file path completion. You can restrict file completions to specific types or patterns:

# Complete only .tar.gz files for an archive extractor command
complete -c untar -a "*.tar.gz" -d "Tarball to extract"

# Complete only directories (useful for --output-dir style options)
complete -c build -l output-dir -a "(find . -type d -not -path '*/.*' | string replace './' '')" \
    -d "Output directory"

For simple directory-only completion, Fish provides a built-in helper:

complete -c mycmd -l config-dir -x -a "(__fish_complete_directories)" \
    -d "Configuration directory"

Chaining Multiple Completion Rules

Complex commands often need multiple completion rules that fire at different stages. Fish evaluates all matching rules and combines their candidates. Here is a complete example for a deploy command:

# deploy.fish completions

# Stage 1: Complete subcommands
complete -c deploy -n "not __fish_seen_subcommand_from staging production rollback status" \
    -a "staging" -d "Deploy to staging environment"
complete -c deploy -n "not __fish_seen_subcommand_from staging production rollback status" \
    -a "production" -d "Deploy to production environment"
complete -c deploy -n "not __fish_seen_subcommand_from staging production rollback status" \
    -a "rollback" -d "Rollback the last deployment"
complete -c deploy -n "not __fish_seen_subcommand_from staging production rollback status" \
    -a "status" -d "Show deployment status"

# Stage 2: Common options available for deployment subcommands
complete -c deploy -n "__fish_seen_subcommand_from staging production" \
    -l branch -x -d "Git branch to deploy" -a "(__fish_git_branches)"
complete -c deploy -n "__fish_seen_subcommand_from staging production" \
    -l force -d "Force deployment even if checks fail"
complete -c deploy -n "__fish_seen_subcommand_from staging production" \
    -l dry-run -d "Simulate deployment without executing"

# Stage 3: Rollback-specific options
complete -c deploy -n "__fish_seen_subcommand_from rollback" \
    -l to -x -d "Revision to roll back to" -a "(__fish_print_deploy_revisions)"

# Stage 4: Global flags available everywhere
complete -c deploy -l verbose -d "Enable verbose output"
complete -c deploy -l help -d "Show help message"

# Helper function for deploy revisions
function __fish_print_deploy_revisions
    # This would query the deployment system for recent revision IDs
    deploy --list-revisions 2>/dev/null | string split ' ' | head -n 20
end

Using __fish_use_subcommand for Complex Hierarchies

For commands with deeply nested subcommands, you can use __fish_use_subcommand which returns true if none of the given subcommands have been seen yet β€” a cleaner pattern for deep trees:

# A cloud CLI with nested commands
# cloud compute instances list|start|stop
# cloud storage buckets list|create|delete

# Top-level
complete -c cloud -n "__fish_use_subcommand" -a "compute" -d "Compute resources"
complete -c cloud -n "__fish_use_subcommand" -a "storage" -d "Storage resources"

# compute subcommands
complete -c cloud -n "__fish_seen_subcommand_from compute; and __fish_use_subcommand" \
    -a "instances" -d "Virtual machine instances"
complete -c cloud -n "__fish_seen_subcommand_from compute; and __fish_use_subcommand" \
    -a "images" -d "Machine images"

# instances subcommands
complete -c cloud -n "__fish_seen_subcommand_from compute instances; and __fish_use_subcommand" \
    -a "list" -d "List instances"
complete -c cloud -n "__fish_seen_subcommand_from compute instances; and __fish_use_subcommand" \
    -a "start" -d "Start an instance"
complete -c cloud -n "__fish_seen_subcommand_from compute instances; and __fish_use_subcommand" \
    -a "stop" -d "Stop an instance"

# start takes an instance ID
complete -c cloud -n "__fish_seen_subcommand_from compute instances start" \
    -x -a "(cloud compute instances list --ids 2>/dev/null)" \
    -d "Instance ID to start"

Advanced Conditional Logic

The -n condition flag accepts arbitrary fish commands. This gives you enormous flexibility:

# Only offer --force if the user hasn't already specified --dry-run
complete -c deploy -n "__fish_seen_subcommand_from staging production; and not __fish_contains_opt -s dry-run" \
    -l force -d "Force deployment"

# Offer environment-specific options based on config files
complete -c deploy -n "test -f deploy/staging.yml" \
    -l config -d "Custom config file" -a "deploy/staging.yml deploy/production.yml"

# Complete based on the day of the week
complete -c backup -n "test (date +%u) -eq 1" \
    -l full -d "Perform full backup (Monday only)"

Fish provides several built-in helper functions for common conditions:

Completing Environment Variables

Fish can complete environment variables inside commands. To enable variable completion for your command's arguments:

# Allow environment variable expansion in arguments
complete -c mycmd -l env-var -x -a "(__fish_print_environment_variables)" \
    -d "Environment variable name"

Completing Process IDs

For commands like kill or custom process managers, Fish provides PID completion:

complete -c mydaemon -n "__fish_seen_subcommand_from stop restart" \
    -x -a "(pidof mydaemon 2>/dev/null)" -d "Process ID"

Descriptions That Enrich the Experience

Every completion candidate can carry a description. Descriptions appear in the pager when multiple candidates match. Use them generously:

# Rich descriptions help users understand options instantly
complete -c git -n "__fish_use_subcommand" \
    -a "commit" -d "Record changes to the repository"
complete -c git -n "__fish_use_subcommand" \
    -a "push" -d "Update remote refs along with associated objects"
complete -c git -n "__fish_use_subcommand" \
    -a "fetch" -d "Download objects and refs from another repository"

# For options, describe what they do and their valid values
complete -c build -l optimization-level -x \
    -a "0\tNo optimization" \
    -a "1\tBasic optimization" \
    -a "2\tFull optimization" \
    -a "3\tAggressive optimization" \
    -d "Compiler optimization level"

Note the use of \t (tab) to separate the value from its description within individual -a arguments. This is an alternative to the two-line tab-separated format used in function output.

Best Practices for Fish Completions

Debugging Completions

Fish provides built-in debugging aids for completion development:

# Enable debug output for completions
fish --debug-level=3 --debug-categories=complete 2>&1 | grep -i completion

# List all completions registered for a command
complete -c mycmd

# Erase a completion rule (useful while iterating)
complete -c mycmd -e

# Erase all completions for a command
complete -c mycmd -e
# Then re-source your completion file to start fresh
source ~/.config/fish/completions/mycmd.fish

Real-World Example: A Complete Completion File

Below is a full, production-quality completion file for a hypothetical dbctl database management command. It demonstrates multiple techniques together:

# ~/.config/fish/completions/dbctl.fish
# Completions for dbctl β€” a database cluster manager

# --- Helper Functions ---

function __dbctl_list_clusters
    # Fetch cluster names from the config file or API
    cat ~/.dbctl/clusters.json 2>/dev/null | jq -r '.clusters[].name' 2>/dev/null
end

function __dbctl_list_databases
    set -l cluster (__fish_seen_subcommand_from --print -- cluster)
    if test -n "$cluster"
        dbctl --cluster="$cluster" list-databases --short 2>/dev/null
    else
        dbctl list-databases --short 2>/dev/null
    end
end

function __dbctl_list_backups
    dbctl list-backups --short 2>/dev/null | string split ' ' | head -n 50
end

# --- Subcommand Completion ---

# Top-level subcommands
set -l subcommands cluster database backup migrate status help

for sub in $subcommands
    complete -c dbctl -n "__fish_use_subcommand" -a "$sub" \
        -d (string match -r "^$sub" "cluster\tManage clusters" \
                                   "database\tManage databases" \
                                   "backup\tManage backups" \
                                   "migrate\tRun migrations" \
                                   "status\tShow cluster status" \
                                   "help\tShow help" | string replace -r '.*\t' '')
end

# --- Global Options (available before subcommand) ---

complete -c dbctl -l config -x -a "(__fish_complete_path)" \
    -d "Path to configuration file"
complete -c dbctl -l verbose -d "Enable verbose logging"
complete -c dbctl -l help -d "Show help message"

# --- Cluster Subcommand ---

complete -c dbctl -n "__fish_seen_subcommand_from cluster; and __fish_use_subcommand" \
    -a "create" -d "Create a new cluster"
complete -c dbctl -n "__fish_seen_subcommand_from cluster; and __fish_use_subcommand" \
    -a "delete" -d "Delete an existing cluster"
complete -c dbctl -n "__fish_seen_subcommand_from cluster; and __fish_use_subcommand" \
    -a "list" -d "List all clusters"
complete -c dbctl -n "__fish_seen_subcommand_from cluster; and __fish_use_subcommand" \
    -a "info" -d "Show cluster details"

# cluster create options
complete -c dbctl -n "__fish_seen_subcommand_from cluster create" \
    -l name -x -d "Name for the new cluster"
complete -c dbctl -n "__fish_seen_subcommand_from cluster create" \
    -l size -x -a "small medium large" -d "Cluster size tier"
complete -c dbctl -n "__fish_seen_subcommand_from cluster create" \
    -l region -x -a "(dbctl list-regions --short 2>/dev/null)" -d "Deployment region"

# cluster delete / info: complete cluster names dynamically
complete -c dbctl -n "__fish_seen_subcommand_from cluster delete info" \
    -x -a "(__dbctl_list_clusters)" -d "Cluster name"

# --- Database Subcommand ---

complete -c dbctl -n "__fish_seen_subcommand_from database; and __fish_use_subcommand" \
    -a "create" -d "Create a database"
complete -c dbctl -n "__fish_seen_subcommand_from database; and __fish_use_subcommand" \
    -a "drop" -d "Drop a database"
complete -c dbctl -n "__fish_seen_subcommand_from database; and __fish_use_subcommand" \
    -a "list" -d "List databases"

# database create/drop: require cluster context and database name
complete -c dbctl -n "__fish_seen_subcommand_from database create drop" \
    -l cluster -x -a "(__dbctl_list_clusters)" -d "Target cluster"

complete -c dbctl -n "__fish_seen_subcommand_from database create" \
    -x -r -d "New database name"
complete -c dbctl -n "__fish_seen_subcommand_from database drop" \
    -x -a "(__dbctl_list_databases)" -d "Database to drop"

# --- Backup Subcommand ---

complete -c dbctl -n "__fish_seen_subcommand_from backup; and __fish_use_subcommand" \
    -a "create" -d "Create a new backup"
complete -c dbctl -n "__fish_seen_subcommand_from backup; and __fish_use_subcommand" \
    -a "restore" -d "Restore from a backup"
complete -c dbctl -n "__fish_seen_subcommand_from backup; and __fish_use_subcommand" \
    -a "list" -d "List backups"

complete -c dbctl -n "__fish_seen_subcommand_from backup create" \
    -l cluster -x -a "(__dbctl_list_clusters)" -d "Cluster to back up"
complete -c dbctl -n "__fish_seen_subcommand_from backup restore" \
    -x -a "(__dbctl_list_backups)" -d "Backup ID to restore"

# --- Status Subcommand ---

complete -c dbctl -n "__fish_seen_subcommand_from status" \
    -l watch -d "Watch status continuously"
complete -c dbctl -n "__fish_seen_subcommand_from status" \
    -l json -d "Output in JSON format"

# --- Help Subcommand ---

complete -c dbctl -n "__fish_seen_subcommand_from help" \
    -a "cluster database backup migrate status" -d "Topic to get help on"

Loading and Distributing Completions

Fish loads completion files automatically when a command is first typed. You don't need to manually source them β€” just place the file in the completions directory and Fish will find it. If you want to make completions available immediately after writing them:

# Source the completion file directly
source ~/.config/fish/completions/mycommand.fish

# Or restart the fish session
exec fish

For project-specific completions, you can add a custom completions path:

# In your config.fish
set -g fish_complete_path ~/.config/fish/completions ~/myproject/completions $fish_complete_path

Common Pitfalls and How to Avoid Them

Conclusion

Fish's completion system is a thoughtfully designed framework that turns the tedious task of writing shell completions into an enjoyable, declarative process. By mastering the complete builtin, understanding condition-based scoping, and leveraging dynamic argument sources, you can create command-line interfaces that feel polished, discoverable, and genuinely delightful to use. The investment in writing good completions pays dividends every time a user β€” or your future self β€” presses Tab and instantly finds the right option, subcommand, or value without breaking flow. Start with simple static completions, gradually add dynamic data sources and conditions, and before long you will have a completion suite that rivals the best built-in Fish completions.

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles