初心者がAIの書いたシェルスクリプト(.command)を読んで学んだ、壊れにくくする6つの実践テクニック

こんにちは!miho(@hirune05)と申します。普段はローカルLLMを用いた対話システムについて研究しながら、SREやPfEといったインフラ領域のエンジニアを目指している情報系の学生です。

先日、高性能AIモデルである Claude Fable 5 に、「USBに挿してダブルクリックするだけで動くローカルLLMチャット環境」をまるごと作ってもらいました。中身は Ollama(ローカルLLM)、VOICEVOX ENGINE(音声合成)、Flask アプリを組み合わせた構成です。

中でも、AIが書いたシェルスクリプト(start_mac.command)には、「USBをどのMacに挿しても、何度実行しても壊れにくい」ための工夫が詰まっていて、一行ずつ読み解いていくと、想像以上に多くの学びがありました。

そこで今回は、AI が生成したシェルスクリプト(start_mac.command)を題材に、

  • .command とは(.sh との違い)
  • 動くスクリプトを「壊れにくく」する6つの工夫

を、整理して共有したいと思います。

.command とは?

.command は macOS 専用の拡張子で、中身は .sh と同じシェルスクリプトです。違うのは、Finderでダブルクリックすると Terminal.app が開き、そのまま実行される点だけです。

一方、.sh はダブルクリックしても通常はエディタで開かれるため、ターミナルから実行する必要があります。

そのため .command は、ターミナルに慣れていない人でもクリック操作で実行できるようにしたいときによく使われます。今回のスクリプトが「USBに挿してダブルクリックするだけで起動」できたのも、この仕組みのおかげでした。

システム全体の流れ

今回読み解く start_mac.command は全文で130行ほどありますが、やっていることは素直で、上から順にこのような流れになっていました。

場所解決(スクリプトの居場所を基点にする)
   ↓
変数・ログの準備(ポート番号を決め、ログの追記先を作る)
   ↓
後片付けの予約(終了時に子プロセスを掃除する trap を仕込む)
   ↓
ポート衝突チェック(使用中なら、その場で終了)
   ↓
サービス起動(Ollama → VOICEVOX → アプリ を裏で起動し、応答を待つ)
   ↓
ブラウザで開く(あとはアプリが落ちるまで待機)

そして、この流れのあちこちに「壊れにくくする」ための工夫が散りばめられていました。先に一覧で見ておくと、こんな感じです。

# 工夫(何のため) 使っている書き方
① 失敗を握りつぶさず、その場で止める set -euo pipefail
② スクリプトの置き場所に依存しない絶対パスの取得 $(cd "$(dirname "$0")" && pwd)
③ 環境差を吸収し、失敗の記録を残す3つの小技 ${PORT:-既定値}
mkdir -p
追記の >>
④ バックグラウンド起動と起動完了の待機 & + $! + 待機関数
⑤ 終了時に、子プロセスをまとめて片付ける trap で後片付け関数を予約
⑥ 別環境で壊れる書き方を機械に見つけさせる shellcheck

ここからは、この6つを一つずつ、私が「なるほど!」となったポイントと一緒に掘り下げていきます。

壊れにくくするための6つの工夫

ここからが本題です。スクリプトを一行ずつ検証していくと、「ただ動く」だけでなく「壊れにくくする」ための工夫が、6つ見えてきました。順に紹介します。

工夫① 失敗を握りつぶさず、その場で止める set -euo pipefail

set -euo pipefailはスクリプトの動作モードを厳しくする設定です。これにより、Ollama の起動などで失敗したとき、それを無視して次(VOICEVOX やアプリの起動)へ進んでしまうのを防げます。

set -euo pipefail

中身は、3つのオプションの組み合わせです。どれも「問題が起きたら、見逃さず気づけるようにする」ためのもの。

  • -e … コマンドが失敗したら、その場でスクリプトを止める
  • -u … 未定義の変数を使ったらエラーにする($OLLAMA_PROT のようなタイポを検知)
  • -o pipefail … パイプ(A | B)の途中で失敗しても見逃さない

set -euo pipefail の有無で、挙動がどう変わるかを以下のプログラムを用いて比較してみました。

#!/bin/bash
set -euo pipefail     # <-これの有無で挙動が変わる
echo "hello"
cat /no/such/file     # わざと失敗
echo "★ここまで来た★"

【結果】:

set -euo pipefail 有りなら、cat が失敗した段階で止まりました。

一方 set -euo pipefail 無しだと、エラーを無視して最後まで走ってしまいました。

# 有り → 失敗した段階で停止(終了コード1)
hello
cat: /no/such/file: No such file or directory

# 無し → 無視して最後まで走る(終了コード0=成功扱い)
hello
cat: /no/such/file: No such file or directory
★ここまで来た★

※ ちなみに今回の start_mac.command では set -u だけでした。-e はサービス起動系だと「想定内の失敗」でも落ちてしまうことがあるので、あえて外すこともあるそうです。

工夫② スクリプトの置き場所に依存しない絶対パス取得 $(cd "$(dirname "$0")" && pwd)

$(cd "$(dirname "$0")" && pwd) を使うと、スクリプト自身が置かれている場所を絶対パスで取得することができます。

USBは挿すPCによってマウント先が変わります(/Volumes/USB だったり /Volumes/NO NAME だったり)。

そのため、現在の作業ディレクトリを表す ./ を基準にしてしまうと、起動する場所によってはファイルを見つけられず、スクリプトが動かなくなってしまいます。

そこで、まずスクリプト自身の場所を取得し、そこへ移動しています。

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
  • $0 … 呼ばれたときのパス。単体では相対だったり絶対だったりで不安定
  • dirname "$0" … ファイル名を取り除いて、フォルダ部分だけにする
  • cd ... && pwd … そこへ移動して現在地を出力し、絶対パスに正規化する
  • $( ... ) … コマンドの出力を、文字列として埋め込む記法

こうして取得した SCRIPT_DIR を使えば、

"$SCRIPT_DIR/bin/ollama"

のように書くだけで、USBをどのMacに挿しても、スクリプトから見た正しい場所を参照できます。

工夫③ 環境差を吸収し、失敗の記録を残す3つの小技 ${VAR:-default}・mkdir -p・>>

これは、PCごとの違いを吸収しつつ、失敗の記録を残すための3つの小技です。それぞれ何をしていて、何がうれしいのかを、表にまとめました。

書き方 意味 ねらい
${APP_PORT:-5001} APP_PORT が設定済みならその値、未設定なら5001を使う ポートを環境ごとに変えられる(APP_PORT=8080 ./start.command で上書き可)
mkdir -p 途中の親フォルダも作り、フォルダが既にあってもエラーにしない 2回目以降の起動でも、フォルダが在ることでこけない
>>(追記) >(上書き)ではなく、末尾に書き足す 失敗ログを消さず「証拠」として残せる

工夫④ バックグラウンド起動と起動完了の待機 &・$!・待機関数

&・$!・待機関数 は3つのサービス(Ollama・VOICEVOX・アプリ)を裏で動かしつつ、それぞれが応答するまで待ってから次へ進むための仕組みを作っています。サービスは起動すると動き続けるので、ただ順番に呼ぶだけでは最初の1つで止まってしまう。それを避けるのが、&・$!・待機関数の3点セットです。

start_mac.command では、次のように使われていました(Ollama の起動部分を抜き出したものです)。

# まず「待機関数」を用意する(サービスが応答するまで最大 tries 回リトライ)
wait_for() {
  local url="$1" name="$2" tries="${3:-60}"
  for ((i=1; i<=tries; i++)); do
    if curl -s -o /dev/null -m 1 "$url"; then return 0; fi
    sleep 1
  done
  return 1
}
 
"$OLLAMA_BIN" serve >> "$OLLAMA_LOG" 2>&1 &                  # & でバックグラウンド起動(2>&1 でエラーもログへ)
OLLAMA_PID=$!                                                # $! で、起動したプロセスのPIDを保存
wait_for "http://127.0.0.1:$OLLAMA_PORT/api/tags" "Ollama"  # 待機関数で、応答するまで待つ
  • & … バックグラウンド起動し、プロセス終了を待たずに次へ進みます。ollama serve は動き続けるので、& がないとそこで止まってしまいます(末尾の 2>&1 は「エラーも同じログへ」の指定)。
  • $! … 直前に & で起動したプロセスのPID。保存しておくと、後で狙って止められます(工夫⑤で使います)
  • 待機関数(wait_for) … サービスが応答できる状態になるまで、1秒ごとに確認して待つ関数。 & が「プロセスの終了を待たない」ための処理であるのに対して、待機関数(wait_for)は「準備完了を待つ」のがポイントです。

工夫⑤ 終了時に子プロセスをまとめて片付ける trap

trap を用いると、スクリプトが終了するときに、指定した処理を必ず実行させることができます。

今回のスクリプトでは、Ollama や VOICEVOX を & でバックグラウンド起動しています。しかし、このままではスクリプトだけ終了しても、裏で動くサービスはそのまま残ってしまいます。Ctrl-C を押したりウィンドウを閉じたりしても、バックグラウンドのプロセスだけが生き残る、いわゆる「幽霊プロセス」になってしまうのです。

それを防ぐために、終了時に子プロセスをまとめて止める処理を trap に登録しています。

cleanup() {
  echo "🧹 子プロセスを停止します..."
  kill_group "$APP_PID" -TERM; kill_group "$VOICEVOX_PID" -TERM; kill_group "$OLLAMA_PID" -TERM
  sleep 2
  kill_group "$APP_PID" -KILL; kill_group "$VOICEVOX_PID" -KILL; kill_group "$OLLAMA_PID" -KILL
}
trap cleanup EXIT INT TERM

trap cleanup EXIT INT TERM は、通常終了(EXIT)・Ctrl-C(INT)・終了要求(TERM)のいずれかを受けたときに、cleanup() を自動で実行する処理です。

cleanup()関数の停止方法も勉強になりました。まず -TERM を送って正常終了を促し、2秒待っても終了しない場合だけ -KILL で強制終了します。いきなり -KILL を使わず、段階的に終了させることで、データ破損のリスクを減らしているのです。

工夫⑥ 別環境で壊れる書き方を機械に見つけさせる(shellcheck)

shellcheck は、シェルスクリプトの静的解析ツールです。スクリプトを実行することなく読み取り、別の環境で壊れる可能性がある書き方や、見落としやすいミスを指摘してくれます。

使い方はシンプルで、完成したスクリプトに対して以下のように実行するだけです。

shellcheck start_mac.command

代表的な指摘には、次のようなものがあります。

  • SC2086:$VAR を "$VAR" で囲う(スペースで壊れるのを防ぐ)
  • SC2164: cd 失敗に備えて cd ... || exit を使う
  • SC2006:古い `...` ではなく $(...) を使う

どれも、普段は動いていても、環境が変わると突然問題になるような落とし穴です。そのため開発の現場では、この shellcheck を自動チェックの仕組み(CI)に組み込んで、コードを変えるたびに自動で検査させることが多いそうです。

おわりに

AI にコードを書いてもらう機会が増えていますが、まだ経験の少ない私にとって、生成されたコードには多くの学びが詰まっていました。

今回紹介した工夫はどれも派手なものではありませんが、「どの環境でも動く」「何度実行しても壊れにくい」といった、実用的なスクリプトを書くうえで大切な考え方ばかりでした。

今後も「動いたから終わり」にするのではなく、そのコードの意図まで理解しようとする姿勢を忘れず、学び続けていきたいと思います。

最後に、この記事がAIの生成したコードを読み解くきっかけや、シェルスクリプトを書く際の参考になれば嬉しいです。