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.
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(orsource <(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
shelforshelf.bashand sources it. The search order, as documented by the project, starts with the user directoryBASH_COMPLETION_USER_DIR(default$XDG_DATA_HOME/bash-completion, or~/.local/share/bash-completion), then thebash-completiondirectories under each entry ofXDG_DATA_DIRS(default/usr/local/share:/usr/share); in each of those thecompletionssub-directory is checked. Package authors can ask bash-completion where to install withpkg-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 onfpathbeforecompinitruns. -
The
#compdeftag:compinitreads the first line of each file infpath, and a file whose first line is#compdef shelfis registered as the completer for the commandshelf. By convention the file is called_shelf. -
compdef: attaches an existing function to a command from the shell, for examplecompdef _pids foo; it is whatsource <(shelf completion zsh)style scripts call at the end. -
_argumentsand_describe: the two helpers that most completion functions are built from._argumentstakes a list of option and argument specifications (-q[query operation],1:message:action,*:message:action,(- *)--help[...]to say that--helpexcludes everything else), and->stateactions return control to the function so that it can complete a later word differently._describeoffersname:descriptionpairs. -
compinitcaches its result in.zcompdumpand checks that the directories and files it loads are not writable by other users, asking (or, with-uand-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 "$@"
Source: Zsh Manual — Completion System
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 |
The usual recipe for tools installed without a package manager. |
Homebrew on macOS |
A |
Homebrew’s own documentation defines the exact rule; check |
Linux packages |
|
Distribution policies differ (some also use a |
Manual, system-wide |
|
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
Source: Register-ArgumentCompleter
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"
]
Source: Nushell — Custom Completions
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 |
|
|
The directory printed by |
zsh |
Function file on |
A directory added to |
|
fish |
File |
|
A |
PowerShell |
Script run by the profile that calls |
Dot-source from |
No standard directory; installers add a profile line or a module |
Nushell |
Script loaded with the |
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.
Source: clap_complete and clap_complete::env
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).
Source: Cobra — Shell completion and Cobra — Completions guide
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.
Source: argcomplete documentation
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 (
$varis one word), an unmatched glob is an error and not left as is (no matches found), andsetoptoptions 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 isset,foror 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:Windowsis the default on Windows (legacy rules plus special handling of a few well-known programs) andStandardis 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 ".
Source: POSIX Shell Command Language — Quoting and about_Parsing
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 |
Use absolute paths to the executable, set |
launchd (macOS) |
A job is a property list with |
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 |
Put the command in |
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
Source: systemd.timer and systemd.exec
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.
Source: Task Scheduler for developers
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 tofpathaftercompinit; in bash asourceline in~/.bash_profilethat 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)"andsource <(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
compinitwarns about, and then ignores, completion directories that other users can write to. Fixing the permissions is safer than disabling the check withcompinit -u. -
Assuming a terminal in a scheduled job. No colour, no prompt, no width, no
$HOMEdocumented by default in systemd system units (setUser=or pass the paths explicitly). -
Treating
cdin 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 -Listfirst.