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.

  • Languages
  • $bash-scripting
  • 222 lines
  • 3 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- bash-scripting

Installs to ~/.agents/skills/bash-scripting/ for every project on this machine.

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

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.

~/.agents/skills/bash-scripting/SKILL.md

--- name: bash-scripting description: Writes and hardens Bash scripts that fail loudly and handle any filename - strict mode, correct quoting, arrays, traps for cleanup, mktemp, argument… license: MIT ---

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

  1. Read AGENTS.md and any existing scripts (rg --files -g '*.sh') to match conventions: indentation, logging helpers, where scripts live, how CI runs them.
  2. 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 (no mapfile, no associative arrays, no ${var,,}). Alpine and distroless images may have no Bash at all, only sh.
  3. 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/sh on a script that uses [[.
NeedBash 4+Bash 3.2 / POSIX fallback
Read lines into arraymapfile -t lines < filewhile IFS= read -r l; do lines+=("$l"); done < file (3.2)
Lowercase${v,,}tr '[:upper:]' '[:lower:]'
Map / dictdeclare -A mtwo 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:

  • -e exits on an unchecked failure, -u on unset variables, pipefail makes cmd | grep x fail when cmd fails, -E lets the ERR trap fire inside functions.
  • trap cleanup EXIT runs 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 and case subjects, 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 -rf as a filename is not an option.
  • Guard destructive paths: rm -rf -- "${dir:?}/cache" aborts if dir is empty instead of deleting /cache.
  • printf '%s\n' "$x", not echo "$x": echo mangles -n, -e and backslashes.
  • Read lines with while IFS= read -r line; do ...; done < "$file"; -r keeps 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

SituationWhat happensFix
local out=$(cmd)exit status of local (0) masks cmdlocal out; out=$(cmd)
if f; then / f || true / f && g-e is disabled inside f entirelycheck statuses explicitly inside functions used in conditions
$(cmd) in Bash < 4.4 without inherit_errexitfailure inside substitution ignoredshopt -s inherit_errexit, or assign then test
cmd | while read ...loop runs in a subshell; variables set inside vanishwhile ...; done < <(cmd)
(( count++ )) when count is 0arithmetic result 0 returns status 1, script exitscount=$((count + 1))
grep with no matchstatus 1 kills the scriptgrep ... || 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.$$", then mv -f into place.
  • date, sed -i, readlink -f, stat and grep -P differ between GNU and BSD (macOS). Use sed -i.bak (works on both) or avoid in-place edits; check uname only 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 --yes flag;
  • 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.sh parses.
  • shellcheck -x script.sh is clean. Each # shellcheck disable=SCxxxx has 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 indent AGENTS.md specifies).
  • 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 somewhere without || exit (or -e) runs the rest of the script in the wrong directory; with rm that is how home directories get deleted.
  • [ $x = y ] breaks when x is empty or has spaces; [[ $x == y ]] does not.
  • ~ does not expand inside quotes; use "$HOME/...".
  • set -u plus 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 with file script.sh.
  • sourced files inherit strict mode; scripts that are meant to be sourced into an interactive shell must not set -e globally or exit.
  • Cron runs with a minimal PATH and no profile; set PATH explicitly 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.yamlgenerated from this page

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.py113 lines, 5.0 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem