この記事の狙い
Bash 製 CLI の最小骨格を提示します。getopts によるオプション解析、--help/--version を備えた Usage、レベル付き log、trap による後始末、エラー時の終了コード設計まで、コピペしてすぐ使える形にまとめます。
前提と対象
Bash 4+(set -Eeuo pipefail 推奨)。長いオプションは前処理で短い別名に畳み込み、getopts で解析します。
TL;DR(“コピペ可”最小スケルトン)
#!/usr/bin/env bash
set -Eeuo pipefail
set -o pipefail
APP_NAME="${APP_NAME:-mycli}"
APP_VERSION="${APP_VERSION:-1.0.0}"
APP_LOG_LEVEL="${APP_LOG_LEVEL:-info}" # info|warn|error
TMPDIR_DEFAULT="${TMPDIR_DEFAULT:-/tmp}"
E_USAGE=2; E_IO=3; E_RUNTIME=4
# ---- Usage / Version ----
usage() {
cat <<'USAGE'
Usage:
mycli [options] <subcommand> [args...]
Subcommands:
greet NAME print greeting to NAME
sum N... add integers
Options:
-o, --output PATH write result to PATH (default: stdout)
-n, --dry-run do not change anything
-v, --verbose verbose logging (APP_LOG_LEVEL=info|warn|error also)
-h, --help show help and exit
-V, --version print version and exit
Environment:
APP_LOG_LEVEL info|warn|error (default: info)
TMPDIR_DEFAULT default temp dir (default: /tmp)
Exit codes:
0 success / 2 usage error / 3 I/O error / 4 runtime error
USAGE
}
short_usage() {
printf 'Usage: %s [options] <subcommand> [args...]\n' "$APP_NAME" >&2
printf "Try '%s --help' for more information.\n" "$APP_NAME" >&2
}
version() { printf '%s %s\n' "$APP_NAME" "$APP_VERSION"; }
# ---- logging (stderr) ----
__log_should(){ # info|warn|error
case "$APP_LOG_LEVEL:$1" in
error:info|error:warn|warn:info) return 1 ;;
esac
}
log(){ __log_should "$1" || return 0
printf '%s %-5s %s\n' "$(date +'%Y-%m-%dT%H:%M:%S%z')" "$1" "${*:2}" >&2; }
log_info(){ log info "$@"; }
log_warn(){ log warn "$@"; }
log_err(){ log error "$@"; }
# ---- cleanup / trap ----
tmpdir=''
cleanup(){ local st=$?
[[ -n "$tmpdir" && -d "$tmpdir" ]] && rm -rf -- "$tmpdir"
if (( st != 0 )); then log_err "exit with status $st"; fi
exit "$st"
}
trap cleanup EXIT INT TERM
make_tmpdir(){ tmpdir="$(mktemp -d "${TMPDIR_DEFAULT%/}/mycli.XXXXXX")" || return $E_IO; }
# ---- normalize long options to short (for getopts) ----
normalize_args() {
local out=() a
for a in "$@"; do
case "$a" in
--help) out+=(-h) ;;
--version) out+=(-V) ;;
--verbose) out+=(-v) ;;
--dry-run) out+=(-n) ;;
--output) out+=(-o) ;; # next arg is value
--output=*) out+=(-o "${a#*=}") ;;
--) out+=(--) ;;
*) out+=("$a") ;;
esac
done
printf '%s\0' "${out[@]}"
}
# ---- subcommands ----
cmd_greet() { # args: NAME
local name="${1-}"; [[ -n "$name" ]] || { log_err "greet requires NAME"; return $E_USAGE; }
(( dry_run )) && { log_info "(dry-run) would greet ${name@Q}"; return 0; }
printf 'hello, %s\n' "$name" >"$out"
}
cmd_sum() { # args: N...
local s=0 x
for x in "$@"; do [[ "$x" =~ ^-?[0-9]+$ ]] || { log_err "not an integer: $x"; return $E_USAGE; }; s=$((s+x)); done
(( dry_run )) && { log_info "(dry-run) would print sum=$s"; return 0; }
printf '%s\n' "$s" >"$out"
}
# ---- main ----
main() {
mapfile -d '' -t argv < <(normalize_args "$@"); set -- "${argv[@]}"
out=/dev/stdout
dry_run=0
verbose=0
while getopts ":o:vnVh" opt; do
case "$opt" in
o) out="$OPTARG" ;;
v) verbose=1; APP_LOG_LEVEL=info ;;
n) dry_run=1 ;;
V) version; return 0 ;;
h) usage; return 0 ;;
\?) short_usage; return $E_USAGE ;;
:) log_err "missing value for -$OPTARG"; short_usage; return $E_USAGE ;;
esac
done
shift $((OPTIND-1))
[[ $# -ge 1 ]] || { short_usage; return $E_USAGE; }
local sub="$1"; shift
make_tmpdir || return $E_IO
log_info "tmpdir=${tmpdir@Q}"
case "$sub" in
greet) cmd_greet "$@" ;;
sum) cmd_sum "$@" ;;
*) log_err "unknown subcommand: $sub"; short_usage; return $E_USAGE ;;
esac
}
main "$@" || exit $?
設計の要点
- 役割の分離を徹底(解析・処理・表示)。正常系は stdout、ログは stderr。
--long→ 短い別名に正規化してからgetoptsで解析。getopt(1)依存は避けると移植性が高い。trap cleanup EXITで一時資源を必ず掃除。INT/TERMも束ねる。- エラー分類は定数(
E_USAGE/E_IO/E_RUNTIME)で可読化し、非0 で失敗を明示。 - 使い方エラー時は短い Usage、
--helpで詳細ヘルプの二段構え。 APP_LOG_LEVELを環境変数で制御できるようにして CI/本番で切替容易に。
拡張ポイント(必要に応じて差し替え)
- サブコマンド委譲(
mycli-<sub>を探して実行)を追加すると大規模化に耐える。 enable_trace()(PS4/BASH_XTRACEFD)を組み込むとトラブルシュートが楽。- 返す値が多い関数は
printf -vやdeclare -n(nameref)で呼び出し元へ書き込む設計も可。
“コピペ可”テストブロック(最小)
#!/usr/bin/env bash
set -Eeuo pipefail
SUT="${SUT:-./mycli}"
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
# 1) --version は NAME VERSION 形式
mapfile -t v < <("$SUT" --version)
[[ "${v[0]}" =~ ^mycli[[:space:]][0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "version NG"; exit 1; }
# 2) --help に Usage を含む
"$SUT" --help | grep -q '^Usage:' || { echo "help NG"; exit 1; }
# 3) 引数不足は usage エラー(2)
set +e; "$SUT" >/dev/null 2>&1; st=$?; set -e
[[ $st -eq 2 ]] || { echo "usage code NG:$st"; exit 1; }
# 4) greet
out="$("$SUT" greet Alice)"
[[ "$out" == "hello, Alice" ]] || { echo "greet NG"; exit 1; }
# 5) sum と --output
"$SUT" sum 7 8 --output "$tmp/out.txt"
[[ "$(cat "$tmp/out.txt")" == "15" ]] || { echo "sum NG"; exit 1; }
echo "PASS"
運用メモ
- ログは行頭に時刻・レベル・本文の順で固定し、人間にも機械にも読みやすく。
- コマンドライン解析の仕様(短い/長いオプション・環境変数・終了コード)は
--helpに必ず明記。 - 破壊的操作は
--dry-runを標準搭載し、最初はログだけで確認できるように。 - 大きくなってきたら
lib/に共通関数(log.sh/usage.sh/trap.sh)を切り出しrequireで読み込む設計に。
参考リンク
- GNU Bash Reference Manual — The
getoptsBuiltin
https://www.gnu.org/software/bash/manual/bash.html#index-getopts - POSIX Utility Conventions(オプション規約)
https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html - ShellCheck(未クォートや未定義アクセスの検知)
https://www.shellcheck.net/
