编程

Bash

产出可正确运行的 Bash 脚本:引用、严格模式、trap 清理与跨平台兼容默认就位。

它能做什么

按 set -euo pipefail 写脚本,trap 兜底,所有展开加引号,命令用数组组装。专门处理实际会踩的坑:文件名带空格、严格模式下静默退出、SIGPIPE 141、cron 中 PATH 被裁、shellcheck SC2086 等。默认 bash 版本下限 4.4,兼容 macOS 3.2,超过 100 行建议改写为 Python。每个子主题(quoting、errors、debugging、portability 等)对应一份参考文档。

什么时候用它

  • 编写或评审 CI 步骤、部署脚本、cron 任务、容器入口
  • 排查文件名带空格就崩、或静默退出的脚本
  • 在 macOS bash 3.2 与 Linux 之间移植脚本
  • 定位退出码异常、SC2086 告警、或 cron/CI runner 下的失败

技能文档

User preferences live in ~/Clawic/data/bash/config.yaml (see Configuration); nothing else is stored on the user's machine. If you have data at an old location (~/bash/ or ~/clawic/bash/), move it to ~/Clawic/data/bash/.

When To Use

  • Writing or reviewing any Bash beyond a one-liner: CI steps, deploy scripts, cron tasks, entrypoints, glue code
  • Debugging scripts that break on spaces in filenames, fail silently, hang, or exit with the wrong code
  • Hardening an existing script: strict mode, cleanup traps, argument parsing, re-runnability, portability
  • Porting a script between macOS and Linux, or down to POSIX sh
  • Deciding whether the task belongs in Bash at all (Core Rule 9)
  • Not for POSIX-sh-only targets (dash, busybox, alpine /bin/sh) — most patterns here are bashisms; portability.md covers the downgrade

Quick Reference

SituationPlay
Breaks on spaces or hostile filenamesQuote every expansion, iterate with find -print0 + while IFS= read -r -d ''quoting.md
set -e missed a failure, cleanup never ranThe five blind spots (conditions, ||/&&, $( ), ! cmd, exit in a subshell) → errors.md
Pipeline "fails" but each command workedExit code 141 = SIGPIPE from an early-exit consumer; PIPESTATUS names the segment → errors.md
Wrong output and you cannot see whyPS4='+ ${BASH_SOURCE##*/}:${LINENO}: ' bash -x scriptdebugging.md
Command built from variables misfiresBuild it as an array (cmd=(rsync -a); cmd+=(--dry-run); "${cmd[@]}"), never as a string → quoting.md
Comparison wrong: [ vs [[, numeric vs lexical[[ 10 < 9 ]] is TRUE (lexical); numbers belong in (( )) or -ltconditionals.md
Runs fine by hand, fails from cronCron has no login shell: minimal PATH, no profile, $HOME as cwd, % means newline → cron.md
Works locally, fails in the CI runnerEach step is a fresh non-interactive shell; strict mode does not carry over → ci.md
Script takes minutes on a large fileCount forks: one external command per line is the cost — batch into awk/sort → performance.md
unbound variable / bad substitution / ambiguous redirectSymptom→cause chains → debugging.md
Must run on macOS stock bash or an old serverVersion Floors below, then GNU-vs-BSD flags → portability.md
Flags, --help, subcommands, usage exit codesgetopts with a silent optstring, then shift $((OPTIND-1))arguments.md
Redirection order, heredocs, one-instance lockingRedirections apply left to right before the command runs → redirection.md
Paths, globs, temp files, deletes that must be safeResolve once with cd … && pwd -P; write temp + mvfiles.md
Parsing CSV/JSON/logs, choosing awk vs sed vs jqPer-line and stateless → one awk pass; never grep JSON → text-processing.md
Background jobs, signals, timeouts, N in parallelpid=$! then wait "$pid"; xargs -P for fan-out → processes.md
String surgery: defaults, trim, replace, basenameBuiltin expansions, no forks → expansion.md
Lists, dictionaries, sets, countersmapfile -t to load, declare -A for maps → arrays.md
Splitting into functions or a sourced librarymain "$@" behind a BASH_SOURCE guard; scope is dynamic → functions.md
Prompts, confirmations, color, progressGate every one of them on [[ -t 1 ]]interactive.md
Calling an API, webhook, or health checkcurl exits 0 on a 500 — capture %{http_code} and branch → http.md
Untrusted input, secrets, temp-file races, sudoKeep values as data, never as syntax → security.md
Adding tests, stubbing commands, lint in CIbash -n, shellcheck, then bats with PATH stubs → testing.md
Anything elseCore Rules below, then reproduce with bash -x on the smallest input that still fails

Each file above is one sub-job and is self-contained: read SKILL.md by default, open exactly one guide when the situation matches.

Core Rules

  1. Open every script with #!/usr/bin/env bash and set -euo pipefail, then learn the -e holes (errors.md) instead of dropping strict mode — the holes are enumerable; silent failures are not.
  2. Quote every expansion: "$var", "$(cmd)", "${arr[@]}". An unquoted expansion is a deliberate act that carries a comment saying why. Word splitting plus globbing is Bash's #1 bug class (shellcheck SC2086).
  3. Build commands as arrays, never as strings. opts="--exclude '*.log'"; rsync $opts src dst passes the quotes as literal characters; opts=(--exclude '*.log'); rsync "${opts[@]}" src dst passes --exclude and *.log as two clean arguments. Conditional flags append: [[ $dry == 1 ]] && opts+=(--dry-run).
  4. Run shellcheck before shipping, blocking at lint_gate severity. Suppress only with the code and a reason on the same line: # shellcheck disable=SC2086 -- flags must split.
  5. Know your floor: macOS /bin/bash is 3.2 forever (GPLv3 freeze). If the script uses any bash >=4.0 feature (Version Floors), state the floor in a header comment and enforce it: ((BASH_VERSINFO[0] >= 4)) || { echo "needs bash 4+" >&2; exit 1; }.
  6. Never parse ls. Iterate with globs or find -print0: filenames may contain newlines, so NUL is the only delimiter a filename cannot contain.
  7. Test the failure path before delivering: swap one command for false, confirm the script stops, the trap fires, and the exit code is nonzero. A cleanup you never saw run is a cleanup you do not have.
  8. Untrusted input never reaches eval, arithmetic, or array subscripts: (( $userinput )) executes commands via arr[$(cmd)] subscripts. Gate with a regex first: [[ $n =~ ^[0-9]+$ ]] || die "not a number: $n".
  9. Past rewrite_threshold lines (default 100, the Google Shell Style Guide cutoff) or once you need nested data structures, rewrite in Python or similar. Bash orchestrates processes; it does not model data.

Script Skeleton

#!/usr/bin/env bash
# Requires bash >= 4.4 (inherit_errexit). Run: script.sh [-n] 
set -euo pipefail
shopt -s inherit_errexit 2>/dev/null || true   # bash >=4.4: $(cmd) failures propagate

die() { printf '%s\n' "$*" >&2; exit 1; }

tmp=$(mktemp) || die "mktemp failed"
trap 'rm -f "$tmp"' EXIT   # single-quoted: expands when it FIRES, not now
                           # fires on error and normal exit; kill -9 bypasses all traps

Quoting

  • "$var", "$(cmd)", "${arr[@]}" — always. $(cmd) strips ALL trailing newlines, not just one.
  • Single quotes are literal; $'...' interprets escapes: $'\t', $'\r', $'\0'.
  • Filenames from variables get -- or ./: rm -- "$f" survives a file named -rf.
  • echo "$var" breaks when var is -n, -e, or has backslashes — printf '%s\n' "$var" never does.
  • Arguments to ssh/su -c/bash -c are re-parsed by the receiving shell — build them with printf '%q ' (quoting.md).
  • ${arr[*]} joins with the first char of IFS into one word; "${arr[@]}" preserves elements. Joining is the only reason to write [*].

Version Floors

FeatureNeeds
printf -v var, += appendbash >=3.1
declare -A, mapfile, ${var^^}/${var,,}, globstar, ;& fallthrough, |&bash >=4.0
[[ -v var ]], shopt -s lastpipe, declare -gbash >=4.2
${arr[-1]}, declare -n namerefs, wait -nbash >=4.3
inherit_errexit, ${var@Q}, mapfile -d, empty "${arr[@]}" safe under set -ubash >=4.4
EPOCHSECONDS/EPOCHREALTIME, SRANDOM (5.1)bash >=5.0

macOS /bin/bash stays at 3.2. #!/usr/bin/env bash finds a Homebrew bash on PATH; #!/bin/bash never will. Check at runtime with BASH_VERSINFO, not by parsing bash --version.

Exit Codes

Formula: a code above 128 means killed by signal code − 128. Codes are mod 256 — exit 256 reports 0, exit -1 reports 255.

CodeMeaningFirst move
1Generic failure — also (( expr )) evaluating to 0Read the last command, then the (( traps below
2Shell syntax or builtin usage errorbash -n script locates it; conventionally also "wrong CLI usage" (arguments.md)
126Found but not executablechmod +x, or the shebang interpreter is not executable
127Command not foundPATH (the cron classic), typo, or a missing shebang interpreter ("bad interpreter")
130SIGINT (128+2)User pressed Ctrl-C — propagate it, do not swallow it
137SIGKILL (128+9)OOM killer or kill -9; no trap ever ran, so cleanup did not happen
141SIGPIPE (128+13)A consumer (head, grep -q) closed the pipe early — usually success misread as failure
143SIGTERM (128+15)Orderly external stop (systemd, CI timeout) — trap it to clean up
124GNU timeout expired (125 = timeout itself failed)Raise the timeout or fix the hang (processes.md)
255ssh transport error, and any exit with a negative or >255 value wrappedDistinguish ssh's own failure from the remote command's

Subshells and State

  • Every pipe segment runs in a subshell: cmd | while read -r x; do ((n++)); done loses n. Fix: done < <(cmd), or shopt -s lastpipe (bash >=4.2, scripts only).
  • ( ) is a subshell, { ...; } is the current shell — exit inside ( ) or $( ) exits only that subshell.
  • Background jobs: cmd & pid=$! then wait "$pid"wait returns the job's exit code, your only way to check it.
  • cd inside ( ) to visit a directory without having to cd back.

Robust Iteration

  • Globs: shopt -s nullglob first — otherwise for f in *.txt in an empty dir runs once with the literal string *.txt.
  • Hostile filenames or recursion: while IFS= read -r -d '' f; do ...; done < <(find . -name '*.log' -print0).
  • Lines of a file: while IFS= read -r line; do ...; done < fileIFS= keeps leading whitespace, -r keeps backslashes. A final line without a trailing newline is still skipped: append || [[ -n $line ]] to the read.
  • Any command inside the loop that reads stdin (ssh, ffmpeg, mysql) eats the rest of the input and the loop ends after one pass — pass ssh -n or redirect < /dev/null.
  • Split a string: IFS=, read -ra fields <<< "$csv". Join: (IFS=,; echo "${arr[*]}") — the subshell keeps the IFS change local.

Output Gates

Before delivering any script, check:

  • Every expansion quoted, or the unquoted one carries a comment saying why
  • Commands with variable flags built as arrays, not concatenated strings
  • shellcheck clean at lint_gate, or each disable names its SC code and reason
  • Failure path exercised: injected false, watched the trap fire and the exit code go nonzero
  • Bash floor stated in a header comment and matching bash_floor if any bash >=4.0 feature is used
  • No eval; no unvalidated input inside (( )) or array subscripts
  • Re-runnable: a run that dies halfway leaves nothing half-written — temp file plus mv, mkdir -p, rm -f
  • Destructive steps gated per destructive_confirm; no secret can appear in set -x output or ps

Configuration

User-dependent variables. Defaults apply until the user states a preference; store them in ~/Clawic/data/bash/config.yaml. Never interview the user — record a preference the moment it is stated.

VariableTypeDefaultEffect
bash_floor3.2 | 4.4 | 5.x4.4Gates which Version Floors features may be used unguarded; 3.2 bans mapfile, declare -A, namerefs and emits the portable fallbacks instead
target_oslinux | macos | bothbothPicks GNU or BSD flag forms in every emitted command (sed -i, date, stat, readlink); both restricts to the intersection (portability.md)
strict_modeset-euo | explicit-checksset-euoChooses the Script Skeleton and which school reviews enforce (Where Experts Disagree)
lint_gateerror | warning | style | nonewarningSeverity at or above which shellcheck findings block delivery (shellcheck -S ); Output Gates use it
rewrite_thresholdnumber (lines)100Length at which Core Rule 9 recommends another language
indent_style2-spaces | 4-spaces | tabs2-spacesFormatting of emitted scripts and the shfmt -i value
destructive_confirmbooltrueEmitted scripts guard deletes, overwrites, and remote pushes behind --yes or a dry-run pass

Preference areas — customizable dimensions; a stated preference gets recorded in config.yaml and applied:

  • Tooling: shellcheck/shfmt/bats availability, GNU coreutils on macOS (gsed, gdate), jq vs python for JSON — affects testing.md gates and every parsing example
  • Conventions: function and variable naming, usage/help layout, log line format, script header content — affects functions.md and arguments.md output
  • Platform: bash floor and OS mix, POSIX-sh-only targets, alpine images where /bin/sh is ash and bash may be absent — affects portability.md guidance
  • Safety posture: whether scripts may sudo, dry-run first, banned constructs (eval, curl | sh, rm -rf on a variable) — affects security.md and destructive workflows
  • Runtime home: where the script actually runs unattended — cron, systemd timer, launchd, CI runner, container entrypoint — affects cron.md and ci.md advice
  • Output: verbosity, color only when the output is a TTY, timestamps, quiet or machine-readable logging — affects interactive.md and logging helpers

Traps

TrapWhy it failsDo instead
local out=$(cmd)local returns 0, masking cmd's failure — set -e never fireslocal out; out=$(cmd)
((count++)) when count is 0expression evaluates to 0 → exit status 1 → set -e kills the scriptcount=$((count+1))
grep -q downstream under pipefailearly exit sends SIGPIPE upstream; producer dies with 141 (128+13) and the pipeline "fails" on successcapture first: out=$(cmd), then grep the variable
rm -rf "$dir/"empty/unset dirrm -rf /rm -rf "${dir:?}/" aborts if empty
trap "rm -rf $tmp" EXIT (double quotes)the body expands NOW, when tmp may still be empty — you registered rm -rfsingle quotes: trap 'rm -rf "$tmp"' EXIT
Checking $? after a log linethe echo overwrote itrc=$? on the very next line
which cmd to test existenceexternal, output format varies (SC2230)command -v cmd >/dev/null
cd "$dir" without a checkwithout -e, everything after runs in the wrong directorycd "$dir" || exit 1 — habit survives scripts that lack -e
sudo cmd > /root/outthe redirection is performed by YOUR shell before sudo runs — permission deniedcmd | sudo tee /root/out >/dev/null
set -euo pipefail in a sourced libraryit mutates the caller's shell and breaks their error handlingset options in executables only; libraries return codes (functions.md)

Where Experts Disagree

  • set -e: the strict-mode school makes it mandatory; the Google Shell Style Guide school argues its exceptions (conditions, ||, command substitution) make it false comfort and prefers explicit || die. Boundary: short glue scripts → strict mode; sourced libraries and functions whose return codes callers inspect → explicit handling. Never mix philosophies in one file.
  • Bash vs POSIX sh: write sh only when the target set actually contains dash/busybox/alpine. "Portable by default" costs arrays, [[ ]], and set -o pipefail for hosts you may never meet.
  • Returning values from functions: print to stdout and capture (composable, costs a fork per call) vs write through a nameref or a documented global (no fork, couples caller and callee). Boundary: hot loops and large payloads → nameref (bash >=4.3); everything else → stdout.
  • Long options: GNU getopt(1) parses them but does not exist usably on macOS (BSD getopt has no long options); a hand-rolled while/case loop is portable and you own the error messages. Boundary: Linux-only tooling → getopt; anything shipped to laptops → hand-rolled (arguments.md).

More Clawic skills, get them at https://clawic.com/skills/bash (install if the user confirms):

  • linux — when the bug is the system, not the script: permissions, cron daemon config, systemd units, OOM
  • regex — when the =~ pattern itself is the hard part
  • github-actions — when the script lives in CI and the failure is workflow wiring, not shell
  • cli-design — when the question is the tool's interface: subcommand layout, flag naming, help text contract

Part of Clawic, the verified skill library. Get this skill: https://clawic.com/skills/bash.

常见问题

macOS /bin/bash 锁死在 3.2,能用吗?
可以。默认 bash_floor 是 4.4,但脚本会强制写入 shebang 注释并在运行时检查版本,需要 bash >=4 的特性会被门禁拦截。把 bash_floor 设为 3.2 时改用兼容写法。
set -e 的盲区怎么破?
不靠放弃严格模式,而是把五个盲区(条件、||/&&、$( )、! cmd、子 shell 里的 exit)逐一列在 errors.md,每个都有对应的修法。
什么时候会拒绝写 Bash?
超过 100 行(Google Shell Style Guide 阈值)或需要嵌套数据结构时,建议改写成 Python。同样不覆盖 dash/busybox 这类纯 POSIX sh 目标,也不覆盖交互式 zsh/fish、PowerShell,以及宿主机 cron/systemd/权限问题。

相关技能

围绕运行语义、异步行为与特性版本下限,为 Node 与浏览器场景调试与编写 JavaScript。

作者 Iván135 次安装6 星标

按症状定位 Linux 主机故障并按层面对主机进行加固。

155 次安装8 星标

定位并修复 YAML 解析错误、隐式类型转换陷阱及各工具链的兼容性问题。

82 次安装2 星标

Execute terminal commands safely and reliably with clear pre-checks, output validation, and recovery steps. Use when users ask to run shell/CLI commands, ins...

115 次安装1 星标

调试、编写和审查 Go 代码,覆盖 goroutine、错误处理、模块与标准库的实践指导。

82 次安装3 星标

定位 Visual Studio Code 编辑器层的故障并修复:设置作用域、调试、格式化、扩展、快捷键、远程。

103 次安装3 星标