skills/dowonkang/agents/shell-scripts

shell-scripts

SKILL.md

Shell Scripts

Apply these rules whenever writing or reviewing shell script files.

Default target: maximum portability — scripts must work on GNU/Linux, BSDs (FreeBSD, OpenBSD, NetBSD), macOS, and BusyBox/Alpine without modification, unless the user explicitly restricts the target platform.

Cross-Platform Portability

Scripts must work on all of these without modification unless a target is explicitly narrowed:

Platform Shell Notes
GNU/Linux (glibc) dash or bash as sh GNU coreutils; date, sed, awk are GNU-flavored
Alpine / BusyBox ash Minimal builtins; no GNU extensions
macOS bash 3.2 or zsh as sh BSD coreutils; sed, date, stat differ from GNU
FreeBSD / OpenBSD / NetBSD sh (pdksh/ash variant) BSD coreutils; POSIX-strict

Coreutils gotchas

Many commands have incompatible flags between GNU and BSD. Always use the portable form:

sed

# GNU: sed -i 's/a/b/' file
# BSD: sed -i '' 's/a/b/' file
# Portable: use a temp file
sed 's/a/b/' "$file" > "$file.tmp" && mv "$file.tmp" "$file"

date

# Only use format flags common to both GNU and BSD
date '+%Y-%m-%d'   # safe everywhere
# Avoid: date -d (GNU-only), date -v (BSD-only)

stat

# GNU: stat -c '%s' file
# BSD/macOS: stat -f '%z' file
# Portable alternative:
wc -c < "$file" | tr -d ' '

readlink -f

# GNU: readlink -f resolves full canonical path
# macOS/BSD: readlink -f may not exist
# Portable replacement:
_realpath() {
  ( cd "$(dirname "$1")" && printf '%s/%s\n' "$(pwd -P)" "$(basename "$1")" )
}

grep

# Avoid GNU-only: -P (PCRE), --color in scripts
# Safe flags everywhere: -E (ERE), -F (fixed), -r, -l, -n, -q, -v
grep -E 'pattern' file
grep -F 'literal string' file

xargs

# GNU: xargs -r skips empty stdin
# BSD/macOS: no -r flag
# Portable: filter before piping, or accept a no-op call

mktemp

# Available on GNU/Linux, BSD, macOS, BusyBox
tmpfile="$(mktemp)"
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpfile" "$tmpdir"' EXIT

cp

# Portable flag: -p (preserve mode/timestamps)
cp -p "$src" "$dst"
# Avoid: cp -a, cp --preserve (BSD behavior differs)

awk over sed for non-trivial transforms

awk is more portable than sed for multi-line or complex edits. Avoid gawk-only extensions (gensub, PROCINFO, etc.):

awk -F: '{print $2}' /etc/passwd   # extract field 2

PATH assumptions

  • Never hardcode /usr/local/bin, /opt/homebrew/bin, or distribution-specific prefixes.
  • Use command -v to locate tools at runtime.
  • Homebrew-installed tools may not be in PATH in non-interactive shells; do not assume they shadow system tools.

/bin/sh is not bash

Distro/OS /bin/sh
Debian / Ubuntu dash
Alpine / BusyBox ash
macOS bash (legacy mode, effectively POSIX)
FreeBSD sh (FreeBSD sh)

Write for the lowest common denominator: POSIX sh with ash/dash constraints.

Shebang

Target Shebang
POSIX portable #!/usr/bin/env sh
Bash-specific #!/usr/bin/env bash
Zsh-specific #!/usr/bin/env zsh

Default to #!/usr/bin/env sh unless the user explicitly requires bash/zsh features.

POSIX Portability (sh scripts)

Avoid bashisms unless targeting bash explicitly:

Avoid (bash-only) Use instead (POSIX)
[[ ... ]] [ ... ]
$(( )) arithmetic with let $(( )) alone is fine
local var outside functions local is OK inside functions in most sh implementations; if strict POSIX is needed, avoid it
declare, typeset not available in POSIX sh
source file . file
echo -e printf
&>>, <<< use >> and pipes
${var,,}, ${var^^} tr or awk
read -r -d '' POSIX read is line-oriented

Test across platforms using Docker before shipping:

docker run --rm -v "$PWD:/w" -w /w alpine sh script.sh          # ash (BusyBox)
docker run --rm -v "$PWD:/w" -w /w debian sh script.sh          # dash (Debian)
docker run --rm -v "$PWD:/w" -w /w ubuntu sh script.sh          # dash (Ubuntu)
docker run --rm -v "$PWD:/w" -w /w bash:3.2 sh script.sh        # bash 3.2 (macOS era)
docker run --rm -v "$PWD:/w" -w /w freebsd/freebsd-13 sh script.sh  # FreeBSD sh (if available)

Quoting

Always double-quote variable expansions unless word-splitting or globbing is intentional:

# Good
echo "$name"
cp "$src" "$dst"
[ -f "$path" ]

# Bad — breaks on spaces
echo $name
cp $src $dst

Leave unquoted only when you need glob expansion or multiple-word splitting:

# Intentional glob
for f in $pattern; do ...

Variable Conventions

# Declare and export separately
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
export XDG_CONFIG_HOME

# Default values
name="${NAME:-default}"

# Readonly constants
readonly SCRIPT_DIR
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

# Clean up temp vars at end of script (not exported, not needed downstream)
unset _tmp_var _another_tmp

Conditionals and Tests

# File/directory tests
[ -f "$file" ]       # regular file exists
[ -d "$dir" ]        # directory exists
[ -e "$path" ]       # any file exists
[ ! -d "$dir" ]      # directory does not exist
[ -r "$file" ]       # readable
[ -x "$cmd" ]        # executable

# String tests
[ -z "$var" ]        # empty string
[ -n "$var" ]        # non-empty string
[ "$a" = "$b" ]      # string equality (= not ==)

# Numeric tests
[ "$n" -eq 0 ]
[ "$n" -gt 1 ]

Command Existence Checks

# Portable way to check if a command is available
if command -v curl >/dev/null 2>&1; then
  curl ...
else
  echo "curl not found" >&2
  exit 1
fi

Never use which — it is not POSIX and behaves inconsistently across systems.

Error Handling

# Fail fast
set -eu

# (Optional) propagate errors through pipes
# Note: not in POSIX sh, but available in bash/zsh
set -o pipefail   # bash/zsh only

# Manual error check when set -e is not enough
some_command || { echo "failed" >&2; exit 1; }

# Cleanup on exit
_cleanup() {
  rm -f "$tmpfile"
}
trap _cleanup EXIT

Prefer set -eu at the top of every script unless the script explicitly handles errors inline.

Output Conventions

# User-facing messages → stdout
echo "Done."

# Errors and warnings → stderr
echo "error: file not found: $path" >&2

# Prefer printf for portability and formatting
printf '%s\n' "$value"
printf 'count: %d\n' "$n"

Do not use echo -e or echo -n in POSIX sh — use printf.

Functions

# Name functions with underscores; prefix with _ if private
_print_usage() {
  printf 'Usage: %s [options]\n' "$0"
}

main() {
  _print_usage
}

main "$@"

Calling main "$@" at the bottom is the standard pattern — it makes scripts sourceable without auto-executing.

Here-docs and Multiline Strings

# Indented heredoc (dash/bash; strip leading tabs with <<-)
cat <<-EOF
	line one
	line two
EOF

# Non-indented
cat <<'EOF'
literal $dollar signs not expanded
EOF

Use <<'EOF' when the content should not expand variables.

Formatting

Format all shell scripts with shfmt before committing:

shfmt -i 2 -ci -s script.sh        # 2-space indent, case indent, simplify
shfmt -w -i 2 -ci -s script.sh     # in-place

Recommended shfmt flags:

  • -i 2 — 2-space indentation
  • -ci — indent case branches
  • -s — simplify syntax where possible

Common Patterns

Resolve script directory (works with symlinks):

readonly SCRIPT_DIR
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

Require a minimum number of arguments:

[ "$#" -ge 1 ] || { echo "usage: $0 <arg>" >&2; exit 1; }

Temp file with cleanup:

tmpfile="$(mktemp)"
trap 'rm -f "$tmpfile"' EXIT

Read file line by line:

while IFS= read -r line; do
  printf '%s\n' "$line"
done < "$file"

Iterate over arguments:

for arg in "$@"; do
  printf 'arg: %s\n' "$arg"
done
Weekly Installs
3
First Seen
10 days ago
Installed on
opencode3
github-copilot3
codex3
kimi-cli3
gemini-cli3
cursor3