Scripts#
A script is the operator’s fastest path from idea to capability. An executable text file, an interpreter on the shebang line, the right mode bits, and the operator has a tool that fits the target, the constraint, and the timeline that no off-the-shelf binary covers.
The interpreter can be bash, python, perl, ruby,
node, lua, or anything else on the box. The mechanics of
setting a script up, marking it executable, parsing its flags, and
handling its arguments are the same regardless of language. This page
covers those mechanics on Linux.
Setting Up#
A script is just a text file. Pick a name, a directory, and an
extension (or none). The directory is what makes the script
discoverable; $PATH directories let you call the script by
name, while ./script runs whatever is in the current directory:
Convention |
Notes |
|---|---|
|
Bash; |
|
Python; |
|
No extension when the script is a CLI tool on |
|
Common per-user script locations |
|
System-wide local scripts |
|
Periodic scripts run by cron / anacron |
Make sure the script directory exists; -p creates parents
silently and never errors if the path already exists:
$ mkdir -p ~/.local/bin
(no output; ~/.local/bin now exists)
Create an empty script file:
$ touch ~/.local/bin/hello
(no output; an empty file is created)
Open the file in your editor ($EDITOR is the convention for
“my preferred editor”):
$ "$EDITOR" ~/.local/bin/hello
(your editor opens; save and exit when done)
Add ~/.local/bin to $PATH so the script is callable by
name; the line is appended to ~/.bashrc and takes effect on
the next shell:
$ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
(no output)
Format#
The first line of an executable script is the shebang
(#!). It tells the kernel which interpreter to launch when
the script is run directly.
Without a shebang, the kernel falls back to running the file through the user’s shell, which works for trivial scripts but breaks the moment the script is invoked from a non-shell context (cron, systemd, or another language calling out to it).
Shebang |
Effect |
|---|---|
|
Run with |
|
Find |
|
Same idea for Python |
|
Pass interpreter flags ( |
|
POSIX shell: minimum dependencies, fewest features |
|
Node.js |
|
Ruby |
#!/usr/bin/env <interp> is the most portable: the kernel
runs env to look up the interpreter on $PATH instead of
hard-coding a path that may differ between systems.
A typical header for a Bash script:
$ #!/usr/bin/env bash
$ set -euo pipefail
$ IFS=$'\n\t'
The same skeleton in Python:
#!/usr/bin/env python3
"""Deploy a service to one or more hosts."""
from __future__ import annotations
import sys
Permissions#
A script needs the execute bit (+x) to run directly.
Without it, the kernel refuses with “Permission denied”; the
script can still be run by passing it to the interpreter
explicitly.
Mode |
Use |
|---|---|
|
Public script; everyone can read and run it |
|
Group-only executable |
|
Private to the owner (secrets, ops scripts) |
|
Run as the file’s owner; avoid unless required |
+x adds the execute bit for owner, group, and other in one
shot:
$ chmod +x ~/.local/bin/hello
(no output; verify with ``ls -l``)
Numeric modes are clearer when you want exact bits; 755 is
the standard “public executable” mode:
$ chmod 755 ~/.local/bin/hello
(no output)
700 is “owner can do everything; nobody else can even read
it”; the right mode for ops scripts that touch credentials:
$ chmod 700 ~/.local/bin/secret
(no output)
Confirm the mode by listing the file in long format:
$ ls -l ~/.local/bin/hello
-rwxr-xr-x 1 operator operator 23 May 1 14:38 /home/operator/.local/bin/hello
For unauthored scripts (a downloaded .sh from the internet),
review the source before adding +x. See
Permissions for the full mode-bit
treatment.
Running#
The kernel uses the shebang line to choose the interpreter when a script is launched directly. The operator has several ways to invoke a script, each one slightly different in how PATH lookup, executable bits, and the calling shell are involved:
Form |
What happens |
|---|---|
|
Run directly; needs |
|
Pass the script to the interpreter; no |
|
Same as above; no extension required |
|
Found via |
|
Run in the current shell; can set its variables |
|
Replace the current shell process with the script |
|
Background, detach from the shell, survive logout |
Direct run, the most common form; ./ makes the path
explicit so the shell does not search $PATH:
$ ./hello
hello world
Pass the script to bash explicitly; useful for debugging or
when the file lacks the execute bit:
$ bash ./hello
hello world
Pass a Python script to the interpreter explicitly:
$ python3 hello.py
hello from python
source (or .) runs the script in the current shell, so
its variable assignments and function definitions persist after
it exits:
$ source ./env.sh
(no output; env vars set by env.sh are now in this shell)
When deploy is on $PATH, you can call it by name like
any other command:
$ deploy --env prod web-01
deploying to web-01 in env=prod
nohup detaches the script from the terminal so it survives
logout; redirect both streams since the terminal is gone:
$ nohup ./long-job.sh > out.log 2>&1 &
[1] 19105
nohup: ignoring input and redirecting stderr to stdout
Arguments#
Positional arguments arrive as $1, $2, … $0 is the
script’s own name. $@ is all arguments as an array; $# is
the count. Always quote "$@" because bare $@ re-splits on
whitespace.
Variable |
Meaning |
|---|---|
|
Script name (or invocation path) |
|
First through ninth positional argument |
|
Tenth onward needs braces |
|
Number of positional arguments |
|
All arguments as separate words (the safe form) |
|
All arguments as one string (rarely correct) |
|
Drop |
|
Use |
|
Exit with the message if |
A skeleton that prints its name, count, every argument, then
demonstrates shift and the ${1:?...} form:
$ #!/usr/bin/env bash
$ echo "running: $0"
$ echo "got $# arg(s)"
$ for arg in "$@"; do
$ echo " - $arg"
$ done
$ while [[ $# -gt 0 ]]; do
$ echo "next: $1"
$ shift
$ done
$ target=${1:?need a target host}
Calling that script with three arguments produces:
running: ./args.sh
got 3 arg(s)
- alpha
- bravo
- charlie
next: alpha
next: bravo
next: charlie
./args.sh: line 12: 1: need a target host
The same idea in Python (sys.argv[0] is the script name,
sys.argv[1:] are the arguments):
#!/usr/bin/env python3
import sys
print(f"running: {sys.argv[0]}")
for arg in sys.argv[1:]:
print(f" - {arg}")
Calling it with the same three arguments:
running: ./args.py
- alpha
- bravo
- charlie
Options#
For anything beyond a couple of positional arguments, parse flags
properly. Bash has two built-in tools, plus the external GNU
getopt; most other languages have a flag-parsing library in
the standard distribution.
Tool |
Use |
|---|---|
|
Bash builtin; short flags only ( |
|
External; long flags too ( |
hand-rolled |
Most readable for small scripts; long + short flags |
|
The standard for Python scripts |
|
The standard for Go |
|
The standard for Rust |
The hand-rolled case form is the common Bash idiom; it
handles short flags, long flags, -- end-of-flags, and
unknown flags:
$ #!/usr/bin/env bash
$ set -euo pipefail
$ env=staging
$ dry_run=false
$ verbose=false
$ while [[ $# -gt 0 ]]; do
$ case $1 in
$ -e|--env)
$ env=$2; shift 2
$ ;;
$ --dry-run)
$ dry_run=true; shift
$ ;;
$ -v|--verbose)
$ verbose=true; shift
$ ;;
$ -h|--help)
$ cat <<EOF
$ Usage: $(basename "$0") [--env ENV] [--dry-run] [-v] HOST
$ EOF
$ exit 0
$ ;;
$ --)
$ shift; break
$ ;;
$ -*)
$ echo "unknown flag: $1" >&2; exit 2
$ ;;
$ *)
$ break
$ ;;
$ esac
$ done
$ host=${1:?need a host}
$ echo "env=$env dry_run=$dry_run verbose=$verbose host=$host"
Calling that script with -e prod --dry-run -v web-01
produces:
env=prod dry_run=true verbose=true host=web-01
The getopts builtin parses short flags only; OPTIND
tracks how many positional words have been consumed so the
final shift skips them:
$ while getopts ':e:vh' opt; do
$ case $opt in
$ e) env=$OPTARG ;;
$ v) verbose=true ;;
$ h) usage; exit 0 ;;
$ :) echo "missing arg for -$OPTARG" >&2; exit 2 ;;
$ *) echo "unknown -$OPTARG" >&2; exit 2 ;;
$ esac
$ done
$ shift $((OPTIND - 1))
Python’s argparse (standard library) auto-generates --help
output, validates types, and is the conventional choice:
#!/usr/bin/env python3
import argparse
p = argparse.ArgumentParser(description="Deploy a service to a host.")
p.add_argument("host", help="target hostname")
p.add_argument("-e", "--env", default="staging", help="environment")
p.add_argument("--dry-run", action="store_true", help="show actions, don't run")
p.add_argument("-v", "--verbose", action="store_true")
args = p.parse_args()
print(args)
Calling that script with -e prod --dry-run -v web-01
produces:
Namespace(host='web-01', env='prod', dry_run=True, verbose=True)
References#
The commands that show up most in script setup, distribution, and
runtime. Each entry links to the per-letter reference page in
/refs/tools/ for the detailed flag listing; this table is
the per-task index.
Command |
Description |
|---|---|
Create directories ( |
|
Create empty files or update timestamps |
|
Change file mode bits ( |
|
List directory contents ( |
|
Print arguments to stdout |
|
The Bourne-Again shell interpreter |
|
Run a script in the current shell (alias |
|
Replace the current process with another |
|
Detach a command from the terminal |
|
GNU long-option parser |
|
Run a command with a modified environment |
See also:
The Terminal: shells, prompts, command anatomy, exit codes
Standard I/O: standard I/O, pipes, redirects
Permissions: chmod, execute bit, ACLs
Processes: jobs,
nohup,execman 1 bash: Bash builtins,getopts, expansion, redirectionman 1 getopt: GNU long-option parserman 1p sh: POSIX shell referenceman 2 execve: the syscall that runs scripts (handles the shebang)