この記事の狙い
Bash 製 CLI を自己説明型にします。--help と --version、わかりやすい Usage/Options/Examples/Exit codes/Env を常に同じ型で出し、引数エラー時は用法を短く見せる実装を示します。
前提と対象
- Bash 4+(macOS の古い Bash は 3.x のため、できれば新しい Bash を使用)
set -Eeuo pipefail前提getoptsを基本に、長いオプション(--long)も受けたい場合のパターンも提示
TL;DR(“コピペ可”・最小実装)
-h|--helpは詳細ヘルプ、引数エラー時は短い用法+誘導-V|--versionは機械可読(NAME VERSION)- 解析は
getopts、--longは前処理で短い別名に変換
#!/usr/bin/env bash
set -Eeuo pipefail
APP_NAME="${APP_NAME:-mycli}"
APP_VERSION="${APP_VERSION:-1.0.0}"
usage() {
cat <<'USAGE'
Usage:
mycli [options] <subcommand> [args...]
Subcommands:
greet print greeting
sum add integers
Options:
-o, --output PATH write result to PATH (default: stdout)
-v, --verbose verbose logging
-n, --dry-run do not change anything
-h, --help show help and exit
-V, --version print version and exit
Environment:
MYCLI_LOG_LEVEL override log level (info|debug)
Examples:
mycli greet --verbose Alice
mycli sum 10 20 -o result.txt
Exit codes:
0 success / 2 usage error / 3 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() { printf '%s\n' "$*" >&2; }
die() { log "ERROR: $*"; exit "${2:-3}"; }
# --- long options → short 別名化(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) ;; # 引数付きの --output は次の値をそのまま消費
--output=*) out+=(-o "${a#*=}") ;; # --output=FILE も許可
--) out+=(--) ;; # 以降はそのまま
*) out+=("$a") ;;
esac
done
printf '%s\0' "${out[@]}"
}
main() {
# 正式引数へ正規化を流し込む
mapfile -d '' -t argv < <(normalize_args "$@")
set -- "${argv[@]}"
local verbose=0 dry_run=0 out=/dev/stdout
# オプション解析(短い別名になっている想定)
while getopts ":o:vnVh" opt; do
case "$opt" in
o) out="$OPTARG" ;;
v) verbose=1 ;;
n) dry_run=1 ;;
V) version; exit 0 ;;
h) usage; exit 0 ;;
\?) short_usage; exit 2 ;;
:) log "missing value for -$OPTARG"; short_usage; exit 2 ;;
esac
done
shift $((OPTIND - 1))
# サブコマンド必須
local sub="${1-}"; [[ -n "$sub" ]] || { short_usage; exit 2; }
shift
case "$sub" in
greet)
local name="${1-}"; [[ -n "$name" ]] || { log "greet requires NAME"; short_usage; exit 2; }
(( verbose )) && log "greeting to $name"
(( dry_run )) && log "(dry-run) would greet $name" && exit 0
printf 'hello, %s\n' "$name" >"$out"
;;
sum)
local t=0 x
for x in "$@"; do [[ "$x" =~ ^-?[0-9]+$ ]] || die "not an integer: $x" 2; t=$(( t + x )); done
(( verbose )) && log "sum=$t"
printf '%s\n' "$t" >"$out"
;;
*)
log "unknown subcommand: $sub"; short_usage; exit 2 ;;
esac
}
main "$@"
実行例:
./mycli --help
./mycli -V
./mycli greet Alice -v
./mycli sum 10 20 --output=out.txt
設計の要点(原則)
- 短い用法(短文)と詳細ヘルプ(章立て)を分ける
- エラー時は「短い用法+
--helpへ誘導」。--helpは章立ての長文。
- エラー時は「短い用法+
- 出力を役割で分離
- stdout: 正常系の結果(他コマンドに渡る)
- stderr: ヘルプ・ログ・警告・用法エラー
- 終了コードの規約化
0: 成功 /2: 用法エラー /3: 実行時エラー(ネットワークや I/O など)
- バージョン出力は機械可読
NAME VERSION一行。スクリプトからの自動取得が容易。
ステップ実装(分解解説)
段階1:Usage の章立てを固定する
固定の順番で書くと迷いません:
- Usage(1行)
- Subcommands(あれば)
- Options
- Environment
- Examples
- Exit codes
段階2:getopts × 長いオプション
- Bash の
getoptsは短いオプション専用。 - そこで、本編のように**
--longを前処理で短い別名に変換**してからgetoptsへ渡すと、実装が単純になります。 --opt=VALUEも-o VALUEに畳み込むと UX が上がります。
段階3:ヘルプは定数文字列でよい
- CLI の Help はプログラムで組み立てないのが原則。
- 動的変化は環境変数・デフォルト値だけに留め、本文は整形済みのヒアドキュメントに。
段階4:出力先の統一
- 結果は
> "$out"のように最初に開いたファイルへ。 - ログは
log()を通して stderr へ。
段階5:エラーは理由→用法の順
- まず何が悪かったかを1行で伝え、そのあと短い用法で救済します。
失敗しやすい点(アンチパターン)
echoでヘルプを出す →-n解釈やエスケープで崩れる →cat <<'EOF'を使う- ヘルプを stdout に出す → パイプ先に混入 → ヘルプは stderr(ただし
--helpは慣習上 stdout も可、現場で統一) - 終了コードが常に 0 → CI/スクリプトから判別不能 → 用法エラーは 2 を徹底
- 長いオプションを
getoptsで直接扱おうとする → できない → 前処理で畳み込む
“コピペ可”テストブロック(記事末テンプレ)
#!/usr/bin/env bash
set -Eeuo pipefail
SUT="${SUT:-./mycli}"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
pass(){ printf 'PASS %s\n' "$1"; }
fail(){ printf 'FAIL %s\n' "$1"; exit 1; }
# 1) --version は機械可読
mapfile -t v < <("$SUT" -V)
[[ "${v[0]}" =~ ^mycli[[:space:]][0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "version"
# 2) --help は Usage を含む
"$SUT" --help | grep -q '^Usage:' || fail "help"
# 3) 引数不足は用法エラー(2)で短い用法を出す
set +e; "$SUT" 2>"$TMP/err"; st=$?; set -e
[[ $st -eq 2 ]] || fail "usage exit"
grep -q '^Usage:' "$TMP/err" || fail "short usage"
# 4) 動作確認: greet
out="$("$SUT" greet Alice)"
[[ "$out" == "hello, Alice" ]] || fail "greet"
# 5) 動作確認: sum + 出力ファイル
"$SUT" sum 7 8 --output="$TMP/out.txt"
[[ "$(cat "$TMP/out.txt")" == "15" ]] || fail "sum file"
echo "ALL GREEN"
運用設計(実務)
- 例は実行可能な最小の 2〜3 本に絞る(CI のドキュメントテストにも使える)
- 環境変数の一覧をヘルプに常設し、デフォルトも併記
- ヘルプの更新を CI で検知(
grepで必須文言・バージョンの存在を確認する簡単なチェックで十分) - サブコマンドが増えるなら**
bin/mycli-<sub>を自動委譲**する設計も有効(git方式)
# サブコマンド委譲(上級・任意)
delegate() {
local sub="$1"; shift
local cmd="${BASH_SOURCE[0]%/*}/mycli-$sub"
if command -v "$cmd" >/dev/null 2>&1; then exec "$cmd" "$@"; fi
return 127
}
互換性と移植性
getoptsは POSIX。短いオプションはどこでも動く- 長いオプションは本稿の前処理方式が安全(
getopt(1)は環境差で壊れやすい) - macOS/BSD と GNU で
readlink挙動が違うため、ヘルプ本文にパス依存の例を書かないのが無難
セキュリティと安全設計
- すべての変数展開はダブルクォート
- 出力ファイルは先に変数へ束縛してから一箇所で開く(
> "$out")。リダイレクトの散在を避ける - エラーメッセージは人間に読める 1 文+短い用法で復旧を促す
パフォーマンスの勘所(短く)
- ヘルプ生成は定数文字列なのでゼロコストに近い
- 解析の前処理(長→短変換)は引数個数に線形で十分軽量
