CLI スケルトン|getopts / usage / log / trap

設計パターン&テンプレ

この記事の狙い

Bash 製 CLI の最小骨格を提示します。getopts によるオプション解析、--help/--version を備えた Usage、レベル付き logtrap による後始末、エラー時の終了コード設計まで、コピペしてすぐ使える形にまとめます。

前提と対象

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 -vdeclare -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 で読み込む設計に。

参考リンク

Bash玄

はじめまして!Bash玄です。

エンジニアとしてシステム運用に携わる中で、手作業の多さに限界を感じ、Bashスクリプトを活用して業務を効率化したのがきっかけで、この道に入りました。「手作業は負け」「スクリプトはシンプルに」をモットーに、誰でも実践できるBashスクリプトの書き方を発信しています。

このサイトでは、Bashの基礎から実践的なスクリプト作成まで、初心者でもわかりやすく解説しています。少しでも「Bashって便利だな」と思ってもらえたら嬉しいです!

# 好きなこと
- シンプルなコードを書くこと
- コマンドラインを快適にカスタマイズすること
- 自動化で時間を生み出すこと

# このサイトを読んでほしい人
- Bashに興味があるけど、何から始めればいいかわからない人
- 定型業務を自動化したい人
- 効率よくターミナルを使いこなしたい人

Bashの世界に一歩踏み出して、一緒に「Bash道」を極めていきましょう!

Bash玄をフォローする