Bash Scripting skill for Codex
Shell scripts that stop on errors and survive spaces. Codex writes shell scripts from a hardened skeleton: strict mode, a main function, argument parsing, trap-based cleanup and stderr logging. It knows the cases where set -e silently does nothing, quotes every expansion, iterates files with NUL delimiters, and checks macOS Bash 3.2 compatibility. Work is proven with ShellCheck, shfmt, bats tests and hostile filenames, with an offline hazard scanner as fallback.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- bash-scripting Installs to ~/.agents/skills/bash-scripting/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install bash-scripting The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project bash-scripting Installs to .agents/skills/bash-scripting/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/bash-scripting.zipunzip -o bash-scripting.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) bash-scripting PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $bash-scripting, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes and hardens Bash scripts that fail loudly and handle any filename - strict mode, correct quoting, arrays, traps for cleanup, mktemp, argument parsing, portable shebangs - then proves them with shellcheck, shfmt and bats tests. Use when writing a new .sh script, fixing a script that breaks on spaces or silently continues after errors, reviewing a deploy or CI shell step, or porting a script between Linux and the macOS Bash 3.2.
Use it when
- Writing a new .sh script or a multi-line CI shell step
- A script continues after errors or breaks on unusual filenames
- Porting a script between GNU/Linux and macOS
- Reviewing shell code before it runs against real servers
Use something else when
- The automation needs data structures, JSON or HTTP logic: use python-automation-scripts
- You are writing Windows or cross-platform admin scripts in pwsh: use powershell-scripting
- You are organizing shell config and machine setup: use dotfiles-management
How to check the result
bash -n and shellcheck -x pass, and the script behaves correctly when run with a path containing spaces and a leading dash, an empty directory and a missing dependency.
The mistake it prevents
Trusting set -e inside functions called from if or ||, or writing local x=$(cmd), both of which silently swallow the failure.
A good script stops at the first unexpected failure, survives filenames with spaces,
newlines and leading dashes, cleans up after itself on every exit path, and passes
shellcheck with no unexplained suppressions. If the logic needs data structures,
JSON parsing or more than ~200 lines, write it in Python instead and say so.
Before writing
- Read
AGENTS.mdand any existing scripts (rg --files -g '*.sh') to match conventions: indentation, logging helpers, where scripts live, how CI runs them. - Decide the target shell. Check where it runs: CI images and Linux servers have Bash
5.x; stock macOS ships Bash 3.2 at
/bin/bash(nomapfile, no associative arrays, no${var,,}). Alpine and distroless images may have no Bash at all, onlysh. - If the script must run under
/bin/sh, write POSIX sh and drop every Bash-only feature in the table below. Do not put#!/bin/shon a script that uses[[.
| Need | Bash 4+ | Bash 3.2 / POSIX fallback |
|---|---|---|
| Read lines into array | mapfile -t lines < file | while IFS= read -r l; do lines+=("$l"); done < file (3.2) |
| Lowercase | ${v,,} | tr '[:upper:]' '[:lower:]' |
| Map / dict | declare -A m | two parallel arrays, or switch language |
| Test | [[ $a == b* ]] | case $a in b*) ...;; esac (POSIX) |
Script skeleton
Start every new script from this shape:
#!/usr/bin/env bash
# Rotates application logs older than N days into an archive directory.
set -Eeuo pipefail
shopt -s inherit_errexit 2>/dev/null || true # Bash 4.4+: $(...) inherits -e
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
usage() {
cat <<'EOF'
Usage: rotate-logs.sh [-n DAYS] [--dry-run] LOG_DIR
-n DAYS age threshold in days (default 14)
--dry-run print what would move, change nothing
EOF
}
tmpdir=""
cleanup() { if [[ -n $tmpdir ]]; then rm -rf -- "${tmpdir:?}"; fi; }
trap cleanup EXIT
trap 'log "failed at line $LINENO: $BASH_COMMAND"' ERR
main() {
local days=14 dry_run=0
while (($#)); do
case $1 in
-n) days=${2:?-n needs a value}; shift 2 ;;
--dry-run) dry_run=1; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) die "unknown option: $1" ;;
*) break ;;
esac
done
(($# == 1)) || { usage >&2; exit 2; }
local dir=$1
[[ -d $dir ]] || die "not a directory: $dir"
tmpdir=$(mktemp -d)
# ... work ...
}
main "$@"
Why each piece matters:
-eexits on an unchecked failure,-uon unset variables,pipefailmakescmd | grep xfail whencmdfails,-Elets theERRtrap fire inside functions.trap cleanup EXITruns on success, failure and Ctrl-C; temp files never leak.- Wrapping in
main "$@"stops a half-downloaded script (curl | bash) from running. - Logs go to stderr so stdout stays machine-readable.
Quoting and expansion rules
- Quote every expansion:
"$var","$(cmd)","${arr[@]}". The exceptions are the right side of[[ ]]assignments andcasesubjects, and even there quoting is harmless. - Use arrays for argument lists, never strings:
args=(--archive --compress --exclude '*.tmp')
[[ $verbose == 1 ]] && args+=(--verbose)
rsync "${args[@]}" -- "$src/" "$dest/"
- Put
--before user-controlled paths so-rfas a filename is not an option. - Guard destructive paths:
rm -rf -- "${dir:?}/cache"aborts ifdiris empty instead of deleting/cache. printf '%s\n' "$x", notecho "$x": echo mangles-n,-eand backslashes.- Read lines with
while IFS= read -r line; do ...; done < "$file";-rkeeps backslashes,IFS=keeps leading spaces. - Iterate files with globs or NUL delimiters, never
for f in $(ls)or$(find ...):
shopt -s nullglob
for f in "$dir"/*.log; do gzip -- "$f"; done
find "$dir" -type f -name '*.log' -mtime +"$days" -print0 |
while IFS= read -r -d '' f; do mv -- "$f" "$archive/"; done
Where set -e does not protect you
| Situation | What happens | Fix |
|---|---|---|
local out=$(cmd) | exit status of local (0) masks cmd | local out; out=$(cmd) |
if f; then / f || true / f && g | -e is disabled inside f entirely | check statuses explicitly inside functions used in conditions |
$(cmd) in Bash < 4.4 without inherit_errexit | failure inside substitution ignored | shopt -s inherit_errexit, or assign then test |
cmd | while read ... | loop runs in a subshell; variables set inside vanish | while ...; done < <(cmd) |
(( count++ )) when count is 0 | arithmetic result 0 returns status 1, script exits | count=$((count + 1)) |
grep with no match | status 1 kills the script | grep ... || true, or test with if grep -q |
Check external command status deliberately when the failure is expected:
if ! out=$(curl -fsS --max-time 10 "$url"); then
die "health check failed for $url"
fi
Common operations done right
command -v jq >/dev/null || die "jq is required" # dependency check
: "${API_TOKEN:?API_TOKEN must be set}" # required env var
lock=/tmp/myjob.lock; exec 9>"$lock"; flock -n 9 || die "already running" # Linux flock
retry() { local n=0; until "$@"; do ((++n >= 5)) && return 1; sleep $((2 ** n)); done; }
retry curl -fsS -o "$tmpdir/pkg.tgz" "$url"
- Use
mktemp/mktemp -d; never fixed names like/tmp/out.txt. - Write files atomically: write to
"$target.tmp.$$", thenmv -finto place. date,sed -i,readlink -f,statandgrep -Pdiffer between GNU and BSD (macOS). Usesed -i.bak(works on both) or avoid in-place edits; checkunameonly when unavoidable.- Heredocs: quote the delimiter (
<<'EOF') unless you want expansion inside.
Destructive and outward-facing steps
Scripts that delete data, deploy, push, or call production APIs must:
- default to a dry run or require an explicit
--yesflag; - print exactly what will change before changing it;
- never be executed by you against real environments without the user confirming the target and the command in chat first.
confirm() {
[[ ${ASSUME_YES:-0} == 1 ]] && return 0
read -r -p "$1 [y/N] " reply </dev/tty
[[ $reply == [yY] ]]
}
confirm "Delete ${#stale[@]} branches on origin?" || die "aborted"
Verification loop
-
bash -n script.shparses. -
shellcheck -x script.shis clean. Each# shellcheck disable=SCxxxxhas a one-line reason on the line above. ShellCheck may need installing (brew install shellcheck,apt-get install shellcheck); that needs network and approval. -
shfmt -i 2 -ci -d .shows no diff (or match the indentAGENTS.mdspecifies). - If ShellCheck is unavailable offline, run the bundled hazard scan:
python3 scripts/bash_hazards.py path/to/script.sh(stdlib only; exits 1 on findings). It is a fallback, not a substitute. - Run the script with a filename containing a space, a leading dash and a newline, with an empty input dir, and with a missing dependency. Each must behave.
- For scripts with logic, add bats-core tests:
# test/rotate.bats
setup() { TMP=$(mktemp -d); touch "$TMP/a b.log"; }
teardown() { rm -rf -- "$TMP"; }
@test "dry run moves nothing" {
run ./rotate-logs.sh --dry-run -n 0 "$TMP"
[ "$status" -eq 0 ]
[ -e "$TMP/a b.log" ]
}
@test "rejects missing directory" {
run ./rotate-logs.sh /nonexistent
[ "$status" -ne 0 ]
}
Run with bats test/. On failure, rerun with bash -x script.sh to trace, fix the
cause, and add a test for the case.
Gotchas
cd somewherewithout|| exit(or-e) runs the rest of the script in the wrong directory; withrmthat is how home directories get deleted.[ $x = y ]breaks whenxis empty or has spaces;[[ $x == y ]]does not.~does not expand inside quotes; use"$HOME/...".set -uplus an empty array:"${arr[@]}"errors on Bash < 4.4. Use${arr[@]+"${arr[@]}"}when targeting macOS Bash 3.2.- CRLF line endings produce
$'\r': command not found; check withfile script.sh. sourced files inherit strict mode; scripts that are meant to be sourced into an interactive shell must notset -eglobally orexit.- Cron runs with a minimal
PATHand no profile; setPATHexplicitly at the top.
Output
When delivering a script, report: target shell and minimum version, what it changes, the dry-run command, the ShellCheck result, and which edge cases you exercised.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Bash Scripting"
short_description: "Shell scripts that stop on errors and survive spaces"
default_prompt: "$bash-scripting Write a Bash script that backs up these directories to S3 with a dry-run flag" scripts/bash_hazards.py
#!/usr/bin/env python3
"""Offline hazard scan for Bash scripts (a fallback when shellcheck is unavailable).
Usage: python3 bash_hazards.py SCRIPT [SCRIPT ...]
Prints one line per finding as file:line: CODE message, and exits 1 if any were found,
2 on usage errors, 0 when clean. Heuristic only: it never replaces shellcheck.
"""
import re
import sys
RISKY_CMDS = {"rm", "mv", "cp", "cd", "chmod", "chown", "mkdir", "touch", "ln",
"rsync", "tar", "cat", "source", ".", "test", "["}
HEREDOC = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
def unquoted_expansions(line):
"""Return True if the line has a $var or ${...} outside quotes (ignoring $( and $(( and $? $# $$)."""
in_single = in_double = False
i = 0
while i < len(line):
c = line[i]
if c == "\\" and not in_single:
i += 2
continue
if c == "'" and not in_double:
in_single = not in_single
elif c == '"' and not in_single:
in_double = not in_double
elif c == "#" and not in_single and not in_double and (i == 0 or line[i - 1].isspace()):
return False
elif c == "$" and not in_single and not in_double and i + 1 < len(line):
nxt = line[i + 1]
if nxt.isalpha() or nxt == "_" or nxt == "{" or nxt == "@" or nxt == "*":
return True
i += 1
return False
def scan(path):
findings = []
try:
with open(path, "rb") as fh:
raw = fh.read()
except OSError as exc:
return [(0, "E000", f"cannot read: {exc}")]
if b"\r\n" in raw:
findings.append((1, "H001", "CRLF line endings; Bash will fail with $'\\r' errors"))
lines = raw.decode("utf-8", errors="replace").splitlines()
if not lines or not lines[0].startswith("#!"):
findings.append((1, "H002", "missing shebang; use #!/usr/bin/env bash"))
elif "sh" in lines[0] and "bash" not in lines[0] and any("[[" in l for l in lines):
findings.append((1, "H003", "#!/bin/sh script uses [[ ]], a Bash-only construct"))
text = "\n".join(lines)
has_errexit = re.search(r"^\s*set\s+-[A-Za-z]*e", text, re.M) or "set -o errexit" in text
has_pipefail = "pipefail" in text
if not has_errexit:
findings.append((1, "H004", "no `set -e` (or -Eeuo pipefail); failures will be ignored"))
if not has_pipefail:
findings.append((1, "H005", "no `set -o pipefail`; failures on the left of a pipe are ignored"))
heredoc_end = None
for n, line in enumerate(lines, 1):
if heredoc_end is not None:
if line.strip() == heredoc_end:
heredoc_end = None
continue
m = HEREDOC.search(line)
if m:
heredoc_end = m.group(2)
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if re.search(r"\bfor\s+\w+\s+in\s+\$\((ls|find)\b", stripped) or re.search(r"\bfor\s+\w+\s+in\s+`(ls|find)\b", stripped):
findings.append((n, "H010", "looping over $(ls/find) splits on spaces; use a glob or find -print0"))
if re.search(r"\blocal\s+\w+=\$\(", stripped) or re.search(r"\b(export|readonly|declare)\s+\w+=\$\(", stripped):
findings.append((n, "H011", "declaration masks the command's exit status; declare first, assign on the next line"))
if "`" in stripped and not stripped.startswith("echo '"):
findings.append((n, "H012", "backticks; use $(...)"))
if re.search(r"\bread\b(?![^;|&]*-[a-zA-Z]*r)", stripped) and re.match(r"^(while\s+)?(IFS=\S*\s+)?read\b", stripped):
findings.append((n, "H013", "read without -r mangles backslashes"))
if re.search(r"\becho\s+-e\b", stripped):
findings.append((n, "H014", "echo -e is not portable; use printf"))
if re.search(r"\brm\s+-[a-zA-Z]*r[a-zA-Z]*\b", stripped) and "$" in stripped and ":?" not in stripped:
findings.append((n, "H015", "recursive rm on a variable path without ${var:?} guard"))
if re.search(r"\(\(\s*\w+\s*(\+\+|--)\s*\)\)", stripped) and has_errexit:
findings.append((n, "H016", "((x++)) returns status 1 when x was 0 and exits under set -e; use x=$((x+1))"))
if re.match(r"^cd\s", stripped) and not has_errexit and "||" not in stripped:
findings.append((n, "H017", "cd without `|| exit` while errexit is off"))
first = re.split(r"[\s;|&]+", stripped, maxsplit=1)[0]
if first in RISKY_CMDS and unquoted_expansions(stripped):
findings.append((n, "H020", f"unquoted expansion in `{first}` arguments; quote it"))
return findings
def main(argv):
if len(argv) < 2:
print(__doc__.strip(), file=sys.stderr)
return 2
total = 0
for path in argv[1:]:
for line_no, code, msg in scan(path):
print(f"{path}:{line_no}: {code} {msg}")
total += 1
print(f"{total} finding(s) in {len(argv) - 1} file(s)", file=sys.stderr)
return 1 if total else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.