Shells, Completions & Shell Integration

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A command-line tool is not run by the operating system directly; it is run by a shell, and the shell decides how the command line is split, what Tab does, and whether the tool can influence the session it was started from. Four things follow from that and are covered here: how each of the common shells implements completion and where a tool has to put its completion script, how a tool can complete values it only knows at run time, how the same command line is quoted in different shells, and how a tool changes the state of the parent shell (which a child process cannot do by itself). The last section covers the opposite case, where no interactive shell is involved at all: cron, launchd, systemd timers and Task Scheduler start a tool in a minimal environment.

The design rules are not repeated here. That a CLI should ship completion scripts, generate them from the command tree and print them from a completion subcommand is in Self-Describing Help, and the packaging side is in Packaging & Distribution and Documenting CLIs. The language-specific generators (Typer and Click, picocli, yargs, Cobra, clap, swift-argument-parser, System.CommandLine) are introduced on the framework pages of that section and in the language pages of this one, for example Command-Line Programs in Go, Command-Line Programs in Rust and Command-Line Tools in Swift. This page explains what those generators produce and how the shells consume it. Terminals, TTYs and the way a shell is attached to one are in Terminals, TTYs & Pseudo-Terminals; the Windows command-line splitting rules are in Windows Console & Processes.

The page was written in October 2026 against the GNU Bash manual and the bash-completion project, the current zsh and fish documentation, PowerShell 7.6, Nushell (current book), clap_complete 4.6, Cobra 1.10 and argcomplete 3.x. The running example is the Bookshelf shelf tool of this section: shelf list, shelf add, shelf loan <id>, shelf return <id>, shelf repl and shelf tui, with the global options --format table|json|csv and --color auto|always|never, and a hidden helper subcommand that prints the ids of the books.

Which snippets were run. The page was written on a macOS machine that has Apple’s bash 3.2.57 and zsh, and no fish, PowerShell or Nushell. The bash completion function was exercised on that bash 3.2 by setting COMP_WORDS and COMP_CWORD by hand and calling it, and the output shown is the real output; it was not driven by a live Tab key press, and it was not run under bash 5 or with the bash-completion package loaded. The shelf init shell-function pattern was run in bash and in zsh with the real results shown. The zsh completion file, the fish, PowerShell and Nushell scripts, the Rust, Go and Python snippets, the cron, launchd, systemd and Task Scheduler files and every Windows command were not run while this page was written. They follow the documentation linked after each example and should be tested in the target shell before being shipped.

How a shell completes a command line

Completion is a feature of the shell, not of the program being typed. When the user presses Tab, the shell has the text of the line so far and nothing else; it does not start shelf to ask what it accepts unless a completion script tells it to. A completion script is therefore a small program written in the shell’s own language, loaded into the interactive shell, which receives the words typed so far and hands back candidates. There are two ways to produce the candidate list:

  • Static (ahead-of-time) completion. The script contains the subcommands, flags and fixed value lists, usually generated from the tool’s command tree. It is fast and works without the tool installed in the same version, but it cannot know the ids of the books in the user’s library.

  • Dynamic completion. The script calls the tool, with a protocol of its own, and the tool prints candidates. It can answer with values that exist only at run time, at the cost of starting a process on every Tab press.

sequenceDiagram participant U as User participant S as Shell line editor participant C as Completion script participant T as shelf U->>S: shelf loan 978- then Tab S->>C: words typed so far and the cursor position C->>C: subcommand and flags from the static table opt value known only at run time C->>T: ask for candidates, for example shelf __complete-ids T-->>C: candidate ids on stdout end C-->>S: candidate list S-->>U: inserts the common prefix, or shows the list

Because the script runs inside the user’s interactive shell, it must be quick (the user is waiting), must not print anything to the terminal except candidates, and must not assume a particular working directory or an installed helper. A dynamic helper that is slow, or that writes a log line to stdout, breaks the prompt of every user who installed the script.

bash

Bash implements programmable completion with the builtins complete, compgen and compopt. complete registers a specification for a command name, compgen generates candidates (it is the usual building block inside a completion function) and compopt changes options of the completion in progress. The most common specification is complete -F function command, which runs a shell function when Tab is pressed on that command’s line. The function communicates through variables: COMP_WORDS (the words on the line), COMP_CWORD (the index of the word under the cursor), COMP_LINE and COMP_POINT (the raw line and the cursor offset) and the result array COMPREPLY, which the function fills with the candidates. Bash also passes the command name, the word being completed and the previous word as $1, $2 and $3. complete -W "words" offers a fixed word list without a function, and complete -C command runs an external command and uses its output as candidates.

# shelf.bash -- completion for the shelf tool
_shelf() {
    local cur prev
    cur=${COMP_WORDS[COMP_CWORD]}
    prev=${COMP_WORDS[COMP_CWORD-1]}
    local commands="list add loan return repl tui completion"
    local global_opts="--help --version --format --color"

    if [ "$COMP_CWORD" -eq 1 ]; then
        COMPREPLY=( $(compgen -W "$commands $global_opts" -- "$cur") )
        return 0
    fi

    case "$prev" in
        --format) COMPREPLY=( $(compgen -W "table json csv" -- "$cur") ); return 0 ;;
        --color)  COMPREPLY=( $(compgen -W "auto always never" -- "$cur") ); return 0 ;;
        loan|return)
            COMPREPLY=( $(compgen -W "$(shelf __complete-ids 2>/dev/null)" -- "$cur") )
            return 0 ;;
    esac
    COMPREPLY=( $(compgen -W "$global_opts" -- "$cur") )
}
complete -F _shelf shelf

Calling the function with a simulated line on bash 3.2.57 (the helper subcommand was a stub that prints two ISBNs) printed:

shelf l -> [list] [loan]
shelf --format j -> [json]
shelf loan 978-0 -> [978-0-13-468599-1]
shelf  -> [list] [add] [loan] [return] [repl] [tui] [completion] [--help] [--version] [--format] [--color]

The plain $(compgen ...) form splits its output on whitespace, so it breaks for candidates that contain spaces (a book title, for example). The bash-completion project provides helper functions such as _init_completion and _filedir that handle word breaking and quoting; they are available only when that package is loaded, so a script meant to work everywhere either avoids them or tests for them first. complete -o nospace stops bash adding a space after a candidate (for --format= style values), complete -o filenames marks candidates as file names so that directories get a slash and special characters are quoted, and complete -o default falls back to file completion when the function returns nothing.

Where bash looks for the script

A plain bash loads nothing by itself: a completion specification exists only after something has run complete. There are two situations.

  • Sourced explicitly. The user adds source /path/to/shelf.bash (or source <(shelf completion bash)) to ~/.bashrc. This works everywhere, including macOS, at the price of running the script at every shell start.

  • Loaded on demand by bash-completion. The bash-completion package installs a framework that, the first time the user presses Tab on an unknown command, searches a list of directories for a file called shelf or shelf.bash and sources it. The search order, as documented by the project, starts with the user directory BASH_COMPLETION_USER_DIR (default $XDG_DATA_HOME/bash-completion, or ~/.local/share/bash-completion), then the bash-completion directories under each entry of XDG_DATA_DIRS (default /usr/local/share:/usr/share); in each of those the completions sub-directory is checked. Package authors can ask bash-completion where to install with pkg-config --variable=completionsdir bash-completion, and the project recommends the file name <command>.bash.

On Linux distributions bash-completion is normally installed and enabled for interactive shells, so a package that puts the script in the directory printed by that pkg-config query is enough. On macOS the system bash is version 3.2, which is older than the bash 4.2 that the bash-completion project’s sourcing example assumes; Homebrew users install a newer bash and bash-completion and source $HOMEBREW_PREFIX/etc/profile.d/bash_completion.sh from ~/.bashrc. On Windows, bash exists only inside Git for Windows or WSL, where the Linux rules apply.

zsh

zsh is the default login shell of macOS since Catalina and its completion system, compsys, is the most capable of the four. It is not active until the user’s ~/.zshrc runs autoload -U compinit && compinit, a fact that explains many reports of "my completion script does nothing".

The pieces are:

  • fpath: the array of directories that zsh searches for autoloadable functions. Completion functions are ordinary function files in a directory that is on fpath before compinit runs.

  • The #compdef tag: compinit reads the first line of each file in fpath, and a file whose first line is #compdef shelf is registered as the completer for the command shelf. By convention the file is called _shelf.

  • compdef: attaches an existing function to a command from the shell, for example compdef _pids foo; it is what source <(shelf completion zsh) style scripts call at the end.

  • _arguments and _describe: the two helpers that most completion functions are built from. _arguments takes a list of option and argument specifications (-q[query operation], 1:message:action, *:message:action, (- *)--help[...] to say that --help excludes everything else), and ->state actions return control to the function so that it can complete a later word differently. _describe offers name:description pairs.

  • compinit caches its result in .zcompdump and checks that the directories and files it loads are not writable by other users, asking (or, with -u and -i, not asking) when they are. The check prints "insecure directories" warnings, which is a frequent first-run problem with Homebrew prefixes.

#compdef shelf
# _shelf -- completion for the shelf tool (install as a file named _shelf on $fpath)
_shelf() {
    local curcontext="$curcontext" state line
    typeset -A opt_args

    _arguments -C \
        '--format=[output format]:format:(table json csv)' \
        '--color=[colour policy]:when:(auto always never)' \
        '(- *)--help[show help]' \
        '(- *)--version[show version]' \
        '1: :->command' \
        '*:: :->args'

    case $state in
        command)
            local -a commands
            commands=(
                'list:list the books'
                'add:add a book'
                'loan:lend a book'
                'return:take a book back'
                'repl:interactive prompt'
                'tui:full-screen catalogue'
                'completion:print a completion script'
            )
            _describe -t commands 'shelf command' commands
            ;;
        args)
            case $line[1] in
                loan|return)
                    local -a ids
                    ids=( ${(f)"$(shelf __complete-ids 2>/dev/null)"} )
                    _describe -t ids 'book id' ids
                    ;;
            esac
            ;;
    esac
}

_shelf "$@"

The script ends with _shelf "$@" because the same file can be used two ways: autoloaded from fpath as a function body, or run when sourced. Where to install it:

Situation Directory Notes

Per user

A directory the user adds to fpath in ~/.zshrc before compinit, for example ~/.zsh/completions

The usual recipe for tools installed without a package manager.

Homebrew on macOS

A site-functions directory under the Homebrew prefix

Homebrew’s own documentation defines the exact rule; check print -l $fpath.

Linux packages

/usr/share/zsh/site-functions on most distributions

Distribution policies differ (some also use a vendor-completions directory); compinit must be enabled.

Manual, system-wide

/usr/local/share/zsh/site-functions

Used by Cobra’s documented recipe for a local install.

The exact system directories differ between distributions and zsh builds. The user can print the real list with print -l $fpath, and the tool’s installation guide should say which one it used.

fish

fish does not need a separate completion framework to be switched on, and it loads completions lazily by file name: the first time the user completes shelf, fish looks for shelf.fish in the directories of $fish_complete_path. In the order documented, those are ~/.config/fish/completions (user), /etc/fish/completions (administrator), ~/.local/share/fish/vendor_completions.d and /usr/share/fish/vendor_completions.d (software vendors), the completions bundled with fish and ~/.cache/fish/generated_completions (generated from man pages). The documentation notes that the paths can vary with the build, and suggests ~/.config/fish/completions for the user’s own scripts. A package should install into a vendor directory.

The language is a series of complete commands, one per candidate set, each with a condition:

# ~/.config/fish/completions/shelf.fish
complete -c shelf -f
complete -c shelf -n "__fish_use_subcommand" -a "list add loan return repl tui completion"
complete -c shelf -n "__fish_seen_subcommand_from loan return" -a "(shelf __complete-ids 2>/dev/null)"
complete -c shelf -l format -x -a "table json csv" -d 'Output format'
complete -c shelf -l color  -x -a "auto always never" -d 'Colour policy'
complete -c shelf -s h -l help -d 'Show help'

-c names the command, -s a short and -l a long option, -a the candidates (a space-separated string, or a command substitution evaluated at completion time), -n a condition that must succeed for the entry to apply, -d the description shown beside a candidate, -f turns file completion off for the command (or for one option) and -r says that the option requires a parameter. -x is the shorthand for exclusive: it combines -r and -f. __fish_seen_subcommand_from is documented helper function; __fish_use_subcommand is a function shipped with fish that is true while no subcommand has been typed, and it is used throughout fish’s own bundled completions, but the completion documentation page does not describe it.

The fish completion is the one most often generated in a form that is eval-able: shelf completion fish | source in config.fish works, as well as saving to the completions directory. Because fish loads on demand, saving the file is better, since the directory costs nothing at shell start.

PowerShell

PowerShell completes its own cmdlets from metadata, so a native program such as shelf.exe gets only file names unless the user registers a native argument completer. Register-ArgumentCompleter -Native -CommandName shelf -ScriptBlock { ... } does that. For a native completer the script block receives three positional parameters: the word being completed ($wordToComplete), the abstract syntax tree of the current line ($commandAst) and the cursor position ($cursorPosition). It writes candidates to the pipeline, either plain strings or System.Management.Automation.CompletionResult objects, which carry the text to insert, the text to show in the list, a result type and a tooltip. The documentation specifies that the script block has to unroll its values through the pipeline, because returning an array would be treated as one candidate. Calling Register-ArgumentCompleter with -CommandName and no -ParameterName behaves as -Native; for PowerShell commands, always give -ParameterName.

# shelf-completion.ps1
$shelfCompleter = {
    param($wordToComplete, $commandAst, $cursorPosition)

    $words = $commandAst.CommandElements | ForEach-Object { $_.ToString() }
    $previous = if ($words.Count -gt 1) { $words[-1] } else { '' }

    $candidates = if ($wordToComplete -eq '' -and $previous -in '--format') {
        'table', 'json', 'csv'
    } elseif ($words.Count -le 2) {
        'list', 'add', 'loan', 'return', 'repl', 'tui', 'completion'
    } else {
        shelf __complete-ids 2>$null
    }

    $candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
        [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
    }
}
Register-ArgumentCompleter -Native -CommandName shelf -ScriptBlock $shelfCompleter

The registration only lasts for the session, so it is dot-sourced from the user’s profile (the file that the $PROFILE variable names). The same script works on Windows PowerShell 5.1 and on PowerShell 7 on every platform. Two Windows-specific obstacles are worth documenting in the install instructions: a profile does not run if the execution policy forbids scripts, and Cobra’s and clap’s guides both tell Windows users to set the policy to RemoteSigned for the current user (Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser); and $PROFILE differs between Windows PowerShell and PowerShell 7 (they are different files), so a script installed for one shell does not complete in the other.

PowerShell 7.6 (since 7.6-preview.5) added -NativeFallback, a single cover-all completer for native commands that have no completer of their own, which is how a module can offer Unix-style completion for many tools at once; a tool author does not use it.

cmd.exe has no programmable completion. Tab completes file names only, so a Windows tool’s completion story is PowerShell, plus bash or zsh for users of Git for Windows or WSL.

Nushell

Nushell separates the completer from the command definition. A completer is an ordinary Nushell command that returns a list of values, or a list of records with a value and optional description, or a record with completions and options (filter algorithm, case sensitivity, sort order). It is attached to an argument with the shape@completer annotation, and a non-Nushell program is described to Nushell with an extern definition, which lists its subcommands, flags and argument types so that Nushell can complete them and type-check them:

# shelf.nu
def "nu-complete shelf ids" [] {
    ^shelf __complete-ids | lines
}

def "nu-complete shelf formats" [] { [table json csv] }

export extern "shelf list" [
    --format: string@"nu-complete shelf formats"   # output format
    --color: string                                # auto, always or never
]

export extern "shelf loan" [
    id: string@"nu-complete shelf ids"             # the book to lend
]

export extern "shelf return" [
    id: string@"nu-complete shelf ids"
]

Tools that cannot be described one by one use the external completer, a closure in $env.config.completions.external.completer that Nushell calls for external commands which have no extern or custom completer. The Nushell documentation shows it delegating to Carapace (a separate project that offers completions for many tools in many shells), and commandline complete lets a script author test a completer without pressing Tab. A tool that wants to support Nushell either ships a .nu file like the one above, or lets the user generate it from a completion subcommand; which frameworks emit Nushell is a matter for each framework’s documentation.

Summary of the installation locations

Shell Loaded how User install System or package install

bash

complete run by a sourced file, or on demand by bash-completion

~/.local/share/bash-completion/completions/shelf (bash-completion) or source from ~/.bashrc

The directory printed by pkg-config --variable=completionsdir bash-completion

zsh

Function file on fpath with a #compdef first line, after compinit

A directory added to fpath before compinit

site-functions (under the Homebrew prefix on macOS, commonly /usr/share/zsh/site-functions on Linux)

fish

File shelf.fish found on $fish_complete_path, loaded on first completion

~/.config/fish/completions/shelf.fish

A vendor_completions.d directory

PowerShell

Script run by the profile that calls Register-ArgumentCompleter

Dot-source from $PROFILE

No standard directory; installers add a profile line or a module

Nushell

Script loaded with the use command or sourced from the configuration

A file in the user’s Nushell configuration directory

No standard directory

cmd.exe

Not supported

Not applicable

Not applicable

On Windows, the most robust distribution of PowerShell completion is a PowerShell module, or a completion subcommand that prints the script (the cross-platform approach in the AI section), because neither winget nor MSI has a standard place for a completion file. Linux packages (deb, rpm) and Homebrew formulas do have such places and should install the static scripts there; the packaging pages of this section describe how to place files in those packages (Native Packages & Installers).

Dynamic completion

Static scripts cannot complete shelf loan <id> with the real ids. Three families of dynamic completion exist, and they share one idea: the shell script is a thin trampoline that calls the tool in a special mode.

clap_complete dynamic mode (Rust)

clap_complete’s stable path is ahead-of-time: a Shell value selects bash, zsh, fish, PowerShell or Elvish, and generate prints the script for a clap::Command. Its dynamic mode is behind the crate feature unstable-dynamic (the name says what the maintainers promise about its stability) and uses the COMPLETE environment variable. The program asks CompleteEnv to handle the request before anything else in main; when COMPLETE is not set, the call returns and the program starts normally.

use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::CompleteEnv;
use clap_complete::engine::ArgValueCompleter;

#[derive(Parser)]
#[command(name = "shelf")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    List,
    Loan {
        /// Id of the book to lend
        #[arg(add = ArgValueCompleter::new(complete_ids))]
        id: String,
    },
}

fn complete_ids(current: &std::ffi::OsStr) -> Vec<clap_complete::CompletionCandidate> {
    let prefix = current.to_string_lossy();
    ["978-0-13-468599-1", "978-1-4919-3999-5"]
        .iter()
        .filter(|id| id.starts_with(prefix.as_ref()))
        .map(|id| clap_complete::CompletionCandidate::new(*id))
        .collect()
}

fn main() {
    CompleteEnv::with_factory(Cli::command).complete();
    let _cli = Cli::parse();
}

The user registers it by evaluating the program’s own output on shell start, for example source <(COMPLETE=bash shelf) in ~/.bashrc, source <(COMPLETE=zsh shelf) in ~/.zshrc, COMPLETE=fish shelf | source in the fish completions file, or $env:COMPLETE = "powershell"; shelf | Out-String | Invoke-Expression; Remove-Item Env:\COMPLETE in the PowerShell profile. The clap_complete documentation says that the generated code calls the program through an interface that may change between versions, so the script must be regenerated at shell start and not saved to a file. The ArgValueCompleter name and the CompletionCandidate type are listed in the crate documentation; the exact method signatures above were not compiled and should be checked against the crate version in use.

Cobra’s __complete (Go)

Cobra generates a completion command that prints a script for bash, zsh, fish and PowerShell. Each script is static only in shape: on every Tab press it runs the program’s hidden __complete subcommand with the words typed so far, and Cobra answers with the candidates, one per line, followed by a line of the form :4 holding a directive. The application supplies candidates with ValidArgs (a fixed noun list) or ValidArgsFunction for runtime values, and RegisterFlagCompletionFunc for flag values. A command uses either ValidArgs or ValidArgsFunction, not both.

package main

import (
	"github.com/spf13/cobra"
)

func newLoanCmd() *cobra.Command {
	cmd := &cobra.Command{
		Use:   "loan ID",
		Short: "Lend a book",
		Args:  cobra.ExactArgs(1),
		RunE:  func(cmd *cobra.Command, args []string) error { return nil },
		ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
			if len(args) != 0 {
				return nil, cobra.ShellCompDirectiveNoFileComp
			}
			ids := []cobra.Completion{"978-0-13-468599-1", "978-1-4919-3999-5"}
			return ids, cobra.ShellCompDirectiveNoFileComp
		},
	}
	cmd.Flags().String("format", "table", "output format")
	_ = cmd.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
		return []cobra.Completion{"table", "json", "csv"}, cobra.ShellCompDirectiveNoFileComp
	})
	return cmd
}

The directives are combined with bitwise OR: ShellCompDirectiveDefault (let the shell do its normal thing), ShellCompDirectiveError, ShellCompDirectiveNoSpace, ShellCompDirectiveNoFileComp, ShellCompDirectiveFilterFileExt, ShellCompDirectiveFilterDirs and ShellCompDirectiveKeepOrder. A developer tests a completer without any shell by calling the hidden command directly: shelf __complete loan "" prints the candidates, then the directive. Setting the environment variable BASH_COMP_DEBUG_FILE makes cobra.CompDebugln write debug lines to that file; the documentation warns never to print to stdout in completion code, because the shell treats stdout as the candidates. The generated command offers --no-descriptions to drop the description text for shells that show it. The Cobra examples above were not compiled; the cobra.Completion type used in the signatures is the one in the Cobra completions guide and may differ in older Cobra versions (the older signature returns []string).

argcomplete (Python)

argcomplete works with argparse (Typer and Click have their own mechanism). The program marks itself by putting the comment PYTHON_ARGCOMPLETE_OK near the top of the script, and calls argcomplete.autocomplete(parser) after the parser is built and before parse_args(). When the shell is completing, the bash or zsh hook sets internal environment variables that autocomplete recognises; it prints candidates and exits, and otherwise it returns and the program runs normally.

#!/usr/bin/env python3
# PYTHON_ARGCOMPLETE_OK
import argparse
import argcomplete


def book_ids(prefix, parsed_args, **kwargs):
    return [i for i in ("978-0-13-468599-1", "978-1-4919-3999-5") if i.startswith(prefix)]


parser = argparse.ArgumentParser(prog="shelf")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("list")
loan = sub.add_parser("loan")
loan.add_argument("id").completer = book_ids
parser.add_argument("--format", choices=["table", "json", "csv"])

argcomplete.autocomplete(parser)
args = parser.parse_args()

Registration is per command, with eval "$(register-python-argcomplete shelf)"; the quotes are significant. The alternative is global activation with activate-global-python-argcomplete, which looks for the marker in the first 1024 bytes of an executable and also covers python script.py and python -m module, and needs bash 4.2 or later (macOS ships bash 3.2). The documentation presents bash and zsh on Linux and macOS as the supported shells and points to a contrib directory for fish, tcsh, xonsh and PowerShell. Code that runs before autocomplete() runs on every Tab press, so heavy imports go after it. Set _ARC_DEBUG for debug output. The Python example was not run, because Python 3.9 is the only interpreter on the writing machine and argcomplete requires a newer one.

Other frameworks have their own variants (swift-argument-parser and picocli’s AutoComplete generator, for example), each documented on its framework’s own pages. The general trade-off between the two families is shown in the following table.

Static (generated script) Dynamic (tool called on Tab)

Speed

No process started

One process start per Tab press; a slow start is felt at the prompt

Run-time values

No

Yes (ids, branches, hosts, profiles)

Version coupling

Script must be regenerated after upgrades

The tool and the answer are always the same version; the trampoline script may still change

Failure mode

Stale candidates

Slow or noisy prompt, a broken helper breaks completion

Typical use

Subcommands, flags and fixed value lists, shipped in packages

Values that exist only in the user’s data

Quoting differs between shells

The same line reaches a program as different argv arrays depending on the shell, and a completion function must return candidates in the form that the shell then parses correctly. The rules that matter for a CLI author:

  • POSIX shells (bash, zsh in its default mode, dash, ksh). A word is split on unquoted whitespace; single quotes are completely literal; double quotes still expand $, backticks and \. An unquoted * or ? is expanded into file names, and an unquoted ~ is expanded to the home directory. In bash, printf '%q' prints a string quoted for re-input.

  • zsh. Follows the POSIX rules but differs in important details: an unquoted variable is not word-split ($var is one word), an unmatched glob is an error and not left as is (no matches found), and setopt options can change either behaviour.

  • fish. Not POSIX at all. There is no $(...) (command substitution is (...)), variables are lists, single quotes accept only \' and \\ as escapes, and unmatched globs are an error unless the command is set, for or one other builtin.

  • PowerShell. Single quotes are literal and double quotes expand $ and the backtick (not the backslash); --% stops parsing. Arguments are passed to a native command according to $PSNativeCommandArgumentPassing: Windows is the default on Windows (legacy rules plus special handling of a few well-known programs) and Standard is the default elsewhere; see Windows Console & Processes for the details.

  • cmd.exe. Double quotes group words, ^ escapes metacharacters outside quotes, and %VAR% is expanded even inside double quotes.

  • Nushell. Uses its own syntax; external commands receive their arguments as strings after Nushell parses them, and a ^ prefix forces an external command.

To see the difference for a title that contains an apostrophe, a double quote and a dollar sign, bash prints the same text in two forms:

$ printf '%s\n' "it's a \"book\" & \$HOME"
it's a "book" & $HOME
$ printf '%q\n' "it's a \"book\" & \$HOME"
it\'s\ a\ \"book\"\ \&\ \$HOME

The second line is what a bash completion function must hand back for the shell to read it as one word. This is the problem that compopt -o filenames, _filedir and zsh’s _describe solve for the author. A candidate list produced by a tool should be raw and unquoted, and quoting should be done by the shell script or framework.

Two consequences for the tool itself. A CLI that accepts a free-text argument (shelf add "The Hobbit") receives exactly one word in a POSIX shell, but receives the quotes as part of the string in a cmd.exe-launched legacy program if the program parses GetCommandLine itself instead of using the runtime’s argv. And anything a script builds from untrusted text, such as a title read from a file, must pass through the target shell’s quoting rules, not through a hand-made replace of ".

Shell functions that change the parent shell

A child process cannot change the working directory, the environment variables or the aliases of the process that started it. A tool that is meant to do exactly that (z, direnv, nvm, shelf cd fiction) is therefore implemented as two pieces: the real program, which only prints, and a shell function with the same name, defined in the user’s interactive shell, which runs the program and acts on its output. The user installs the function by evaluating the program’s own output:

# ~/.bashrc
eval "$(shelf init bash)"

# ~/.zshrc
eval "$(shelf init zsh)"

# ~/.config/fish/config.fish
shelf init fish | source

The init subcommand is a normal program that prints a shell-specific snippet and exits with status 2 for a shell it does not know. The snippet for bash and zsh is the same:

shelf() {
  if [ "$1" = "cd" ]; then
    local dir
    dir=$(command shelf path "$2") || return
    builtin cd -- "$dir"
  else
    command shelf "$@"
  fi
}

command shelf bypasses the function and runs the executable, builtin cd is the real cd and -- stops an id that begins with a dash from being read as an option. The test run on the writing machine, with a stub path subcommand, printed the new directory in both bash and zsh after shelf cd fiction:

$ bash -c 'eval "$(shelf init bash)"; shelf cd fiction; pwd'
.../sh/lib/fiction
$ zsh -c 'eval "$(shelf init zsh)"; shelf cd fiction; pwd'
.../sh/lib/fiction
$ shelf init nu; echo "exit=$?"
shelf: unsupported shell: nu
exit=2

The same idea in fish and PowerShell uses their own syntax (function shelf ... end and function shelf { ... }), and Set-Location replaces cd in PowerShell. For a prompt that shows the state of the tool, the snippet also registers a hook (PROMPT_COMMAND in bash, precmd in zsh, fish_prompt in fish); the hook runs on every prompt and must therefore be much faster than a Tab completion. No Windows cmd.exe equivalent exists except doskey macros and batch files that are run with call.

The design caveats come from eval: the output of shelf init is executed with the user’s authority in every new shell, so the program must be a trusted binary found through a trusted PATH, the snippet must not be built from user data, and it must be quick, since it adds to the start-up time of every interactive shell.

Running without an interactive shell

Scheduled and supervised jobs do not read ~/.bashrc, do not load the completion scripts, do not have the user’s PATH, PWD, aliases or terminal, and often run as a different user. Every behaviour of the tool that depended on the interactive shell then has to be spelled out.

Scheduler Environment and context What to do

cron (Linux, macOS, BSD)

The crontab(5) page documents SHELL set to /bin/sh, LOGNAME and HOME from the account’s password entry; the default PATH is not described by that page (it is short on most systems). An unescaped % in the command line is turned into a newline and the rest is sent as standard input. Output is mailed to the owner unless MAILTO says otherwise.

Use absolute paths to the executable, set PATH and MAILTO at the top of the crontab (name = value lines, no comment on the same line), escape every % as \%, redirect stdout and stderr, and never rely on $TERM.

launchd (macOS)

A job is a property list with Label and ProgramArguments (the program and its arguments, not a shell string), scheduled with StartInterval (seconds) or StartCalendarInterval (like cron, omitted keys are wildcards). RunAtLoad, EnvironmentVariables, WorkingDirectory, StandardOutPath and StandardErrorPath are set in the plist. Agents run in the user’s session from ~/Library/LaunchAgents; daemons run as root from /Library/LaunchDaemons.

Set the environment and working directory in the plist and not in code, as Apple’s documentation advises; the file must be owned by the right user and not group- or world-writable.

systemd timer (Linux)

A .timer unit activates the .service unit of the same name (or Unit=). OnCalendar= is wall-clock, OnBootSec= is monotonic, Persistent=true runs a missed activation after downtime (only for OnCalendar=), and RandomizedDelaySec= spreads the load. The service has no login shell environment; its working directory is / for system services and the user’s home directory for user instances.

Put the command in ExecStart= with an absolute path, and set variables with Environment= or EnvironmentFile=. Test with systemctl start shelf-sync.service before enabling the timer.

Task Scheduler (Windows)

Starts tasks on triggers: a time, a schedule, boot, logon, idle or an event. A task runs in the context chosen for it (an interactive user, or a stored account with no desktop).

Use absolute paths, give the task an explicit working directory, and choose an account that has access to the files; test as that account.

A tool that is going to be scheduled should, independently of the scheduler: take all of its configuration from flags or an explicit config file path, write nothing to a terminal it does not have (see TTY, Colour & Interactivity), exit with a meaningful status (Streams & Exit Codes), and not prompt. A daemon that needs the full service lifecycle, not a periodic run, is covered in Long-Running Processes & Services.

A systemd timer for a nightly shelf sync, as a pair of units:

# /etc/systemd/system/shelf-sync.service
[Unit]
Description=Synchronise the Bookshelf catalogue

[Service]
Type=oneshot
ExecStart=/usr/local/bin/shelf sync --format json
Environment=SHELF_CONFIG=/etc/shelf/config.toml

# /etc/systemd/system/shelf-sync.timer
[Unit]
Description=Run shelf sync every night

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=10min

[Install]
WantedBy=timers.target

The equivalent crontab entry, with its environment declared and the percent sign escaped:

SHELL=/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=""
30 2 * * * /usr/local/bin/shelf sync --format json >>/var/log/shelf-sync.log 2>&1

Source: crontab(5)

And a launchd agent for the same job on macOS:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.example.shelf.sync</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/shelf</string>
        <string>sync</string>
        <string>--format</string>
        <string>json</string>
    </array>
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>2</integer>
        <key>Minute</key>
        <integer>30</integer>
    </dict>
    <key>StandardOutPath</key>
    <string>/tmp/shelf-sync.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/shelf-sync.log</string>
</dict>
</plist>

Source: Creating Launch Daemons and Agents (an archived document, but the only Apple page that describes these keys; RunAtLoad and EnvironmentVariables are described in the launchd.plist(5) man page)

On Windows, schtasks.exe or the Register-ScheduledTask cmdlet creates the task from a command line or a script; neither was run here. The Task Scheduler overview documents the triggers and the two API generations, but it does not describe the environment of a running task, so the table above only states what that page supports.

Pitfalls

  • A completion script that prints. Any stray echo, deprecation warning or update notice in the completion path ends up in the candidate list or on the prompt. Send diagnostics to stderr, or to a debug file, and keep the helper silent by default.

  • A slow helper. A Tab press that waits two seconds for a network call teaches users to uninstall the script. Cache, time-limit or complete from local data only.

  • Completion that depends on a version. A saved static script keeps offering a flag that the new version removed. Regenerate on upgrade, or ship the script in the same package as the binary. clap_complete’s dynamic mode documentation recommends regenerating at shell start for the same reason.

  • Wrong directory or missing compinit. In zsh the file is invisible when its directory is added to fpath after compinit; in bash a source line in ~/.bash_profile that is not read by non-login shells is another common cause. On macOS, shells opened by Terminal.app are login shells.

  • Loading everything at start-up. eval "$(tool init zsh)" and source <(tool completion bash) run the tool in every new shell. For a tool that is slow to start (a JVM, a .NET runtime without AOT), save the output to a file and refresh it when the tool is upgraded.

  • Insecure directories. zsh’s compinit warns about, and then ignores, completion directories that other users can write to. Fixing the permissions is safer than disabling the check with compinit -u.

  • Assuming a terminal in a scheduled job. No colour, no prompt, no width, no $HOME documented by default in systemd system units (set User= or pass the paths explicitly).

  • Treating cd in a script as a program feature. A tool that must change the parent’s directory needs the shell function described above, and in a non-interactive script the function is not loaded, so the tool should also work without it (for example by printing the path).

  • Windows execution policy. A PowerShell profile that sources a script does nothing when scripts are blocked, and nothing says why. Check Get-ExecutionPolicy -List first.