シェルスクリプトや CI/CD、AI エージェントの実行パイプラインにおいて、使用する外部コマンドが環境に存在するかどうかを事前に確認する処理は広く行われています。その際、環境依存の強い which ではなく、POSIX 準拠で Bash 等でも最も安全かつポータブルな標準手段として command -v を用いる実装が推奨されてきました 12。

しかし、自社の実務パイプラインにおいて、Node.js のバージョン更新後に残存した mise の shim が command -v を正常ステータスで通過し、実行時の起動エラーが「ページの抽出失敗」として誤認・誤報される事態が発生しました。

ツールの存在確認とは、PATH 上に実行可能ファイルが存在するかを判定する静的検査であり、プロセスの正常起動を保証する起動確認(動的ヘルスチェック)とは根本的に異なるものです。ファイルが PATH に「存在する」ことと、ツールが実際に「正常に動く」ことはまったく別物であり、形式的な存在確認から実態を伴う起動確認へ設計を切り替える必要があります。

Q: command -v が成功するのにコマンドが実行できないのはなぜですか? A: command -v は POSIX 規格上「コマンドを呼び出さずにパスを出力する」仕様であり、PATH 上にラッパーファイル(shim)が存在するだけで成功(終了ステータス 0)を返します。実体バイナリの未インストールやバージョン設定の不備があっても、実際に起動する瞬間までエラーを検知できません。

なぜ存在確認をすり抜けたのか:POSIX 仕様と shim のメカニズム

なぜ静的な存在確認は、壊れたツールの検出に失敗したのでしょうか。その理由は、POSIX 規格が定める command -v の基本仕様と、現代のバージョン管理ツールが採用する shim アーキテクチャの組み合わせにあります。

POSIX 規格における command -v の「呼び出さない」仕様

POSIX(IEEE Std 1003.1)規格において、command ユーティリティの -v オプションは「コマンドの呼び出しを行わずに、指定された名前を呼び出すためのパス名またはコマンド名を出力する(“do not invoke command_name”)」と明確に規定されています 3。

終了ステータスについても、ユーティリティが見つからなかった場合(エラー時)のみ非ゼロとなり、指定された名前のファイルが PATH 上に実行可能ファイルとして存在する限り、そのファイルを実行した際に何が起きるかに関わらず 0 を返します。つまり、command -v は「実行可能ファイルの有無」を確認する静的な探索コマンドであり、実行可能ファイルが正常にプロセスとして起動できるかという動的検証は仕様上完全にスコープ外です。

mise の shim が残存して誤報を生んだ実例

mise は、~/.local/share/mise/shims ディレクトリに薄い実行ラッパー(shim)を配置し、そのディレクトリを PATH に加えることで、コマンドの呼び出しをインターセプトします 4。shim 経由のコマンド呼び出しはまずこの shim を通り、現在有効なバージョン設定を参照して実体バイナリを呼び出します。

この構造が、ツールの存在確認において深刻な偽陽性を生み出しました。自社の運用環境において、実際に起きた事象は以下の通りです。

  1. グローバル設定(~/.config/mise/config.toml)で node = "lts" を指定していた環境で、lts の解決先が 24.15.0 から 24.21.0 へ上がった。
  2. Web ページ抽出ツールである defuddle は、旧バージョン(24.15.0)の global npm パッケージとしてインストールされていたため、新バージョン(24.21.0)の環境下では実体バイナリが参照できなくなった。
  3. しかし、mise が配置した ~/.local/share/mise/shims/defuddle というファイル自体は PATH 上に実行可能ファイルのまま残り続けた。
  4. その結果、パイプラインの事前チェックである command -v defuddle は正常終了(ステータス 0)を返し、処理を続行させてしまった。

この環境は対話シェルで eval "$(mise activate zsh)"(PATH を直接書き換える方式)を使っており、shim 方式ではありませんでした。それでも shim に当たったのは、mise の activate_shims 設定が true で、PATH の後方に ~/.local/share/mise/shims も並んでいたためです。スクリプトはこの PATH をそのまま継承するので、有効な Node の実体パスに defuddle が見つからないと、探索は後方の shim に行き着きます。

実際にこの状態でツールを実行すると、mise の shim が起動した直後にアクティブなバージョンが存在しないことを検知し、標準エラー出力にエラーを出力し、終了ステータス 1 で異常終了します。

mise ではバージョンが設定されていないツールを shim から呼び出すと mise ERROR No version is set for shim が出力されることが報告されています 5(終了ステータス 1 は社内実測ログで確認)。また、同様の shim アーキテクチャを採用する asdf でも、指定バージョンの未インストールなどバージョン不整合時に No version is set for command となり起動不能に陥る共通の挙動が存在します 6。

以下は、問題発生時にスクラッチパッドで手動切り分けを行った際のログです。

# ツール単体を手動実行した際のエラー
defuddle parse uber.html -j
# 出力: rc=1, mise ERROR No version is set for shim: defuddle

# 旧 Node バージョンを指定した実行(実体の呼び出し)
mise exec node@24.15.0 -- defuddle parse uber.html -j

手動実行時の出力ログは以下の通りです。

rc=1
mise ERROR No version is set for shim: defuddle
Set a global default version with one of the following:
mise use -g node@24.15.0

一方で、実体が存在する旧バージョンを明示指定して mise exec node@24.15.0 -- defuddle parse uber.html -j を実行したところ、正常にタイトル(Running a Software Factory Efficiently at Uber Scale)と本文(wordCount: 3293)が抽出されました。

パイプライン側では command -v が成功していたため、「ツールは正常に存在している」という前提で後続のエラーハンドリングを行っていました。その結果、ツールの起動失敗によって空出力となった事象が、上位処理において「対象 Web ページの HTML 構造による抽出失敗」と誤認され、誤った原因調査に時間を費やすことになったのです。

「存在確認」から「起動確認」への切り替え:実務での選択理由

このインシデントを通じて、私たちはツールの検査方針を「静的な存在確認」から「動的な起動確認(ヘルスチェック)」へと切り替えました。その設計判断の背景には、不具合を捉える思考モデルの転換があります。

予防(原因の空間)から検収(結果の空間)への転換

コマンドが動くかどうかを確かめようとするとき、私たちは無意識に「動かない原因を事前に排除しよう」と考えがちです。ファイルの存在を確かめ(command -v)、パーミッションを確認し、シンボリックリンクのリンク先を追跡する、といったアプローチです。

しかし、私はこれを「原因の空間に対する予防」に過ぎないと考えています。「原因の空間」は事実上、無限です。今回のように環境管理ツールの shim だけが残骸として残るケースもあれば、ランタイムのマイナーバージョン更新によるバイナリの非互換、共有ライブラリの欠落、実行パーミッションの不備、環境変数の不足など、コマンドが起動に失敗する原因を事前にすべて予見して個別に検査することは不可能です。

一方で、私の見方では「結果の空間」は有限です。コマンドが健全に動作可能であるならば、「安全な非破壊引数を与えて起動したとき、プロセスの終了ステータスとして 0 が返ってくる」という結果の性質は 1 つしかありません。原因をいくら列挙して検査しても想定外の壊れ方を防ぐことはできませんが、「実際に安全に起動してステータス 0 で返るか」という結果の空間を 1 点検収すれば、あらゆる原因による起動不能を確実に検出できます。

verify-urls.sh の改修と「実行不可」の分離

この思想に基づき、自社の URL 検証パイプラインスクリプト(scripts/verify-urls.sh)において、command -v による判定を実際の起動確認へと書き換えました。

 write_cache() {
   local body="$1" url="$2" f rc
-  command -v defuddle >/dev/null 2>&1 || { printf 'キャッシュ: defuddle 不在'; return; }
+  # command -v では足りない。mise の shim は node 切り替え後も残り、存在確認は通るのに
+  # 起動で落ちる。それが「抽出失敗」と誤報されていた(2026-09-24 実測)。実際に起動して確かめる
+  defuddle --help >/dev/null 2>&1 || { printf 'キャッシュ: defuddle 実行不可(mise の node 切り替え等)'; return; }

この改修における要点は、単に起動確認を行うだけでなく、エラーメッセージを「defuddle 不在」から「defuddle 実行不可(mise の node 切り替え等)」へと分離したことです。これにより、ツール自体のランタイム破損と、対象 Web ページの取得・抽出失敗が明確に区別され、障害発生時にメッセージだけでツール側とページ側のどちらの失敗かを区別できるようになりました。

なお、根本的な環境設定としても ~/.config/mise/config.toml に "npm:defuddle" = "latest" を追加し、Node.js のグローバルバージョン更新から独立してツールが管理されるよう是正を行いました。

自己診断(doctor)スクリプトの同一穴と grep による根絶

単一のスクリプトを改修して安心することはできませんでした。開発環境の健全性をチェックする自己診断スクリプト(scripts/doctor.sh)を点検したところ、66 行目にまったく同一の判定 command -v defuddle が取り残されていたのです。

この判定式は shim が残っているだけで通るため、Node.js の更新によって有効な Node から defuddle の実体が参照できなくなり、起動不能になった環境でも、doctor スクリプトは「OK defuddle」と表示する構造になっていました。壊れた環境を「正常」と誤診する誤判定が、診断ツール自身の中に存在していたのです。

局所的な不具合が発生した際、その箇所だけにパッチを当てて済ませてはなりません。判定式そのものに設計上の欠陥がある場合、リポジトリ全体を横断的に grep して同一パターンの判定漏れを完全に根絶することが不可欠です。doctor スクリプトも同様に defuddle --help による起動確認へと修正し、壊れた環境で確実に異常(NG)を報告することをテストスクリプトで実証しました。

起動確認におけるトレードオフと落とし穴

「存在確認から起動確認への切り替え」という方針は明快ですが、いざ実装する段になると固有の落とし穴が存在します。すべてのコマンドが同じ作法で動いているわけではないからです。

「-v」や「-h」の存在を前提としない作法差の罠

起動確認を行う際、最も陥りやすい誤解は「どのコマンドにも -v(バージョン)や -h(ヘルプ)オプションがあるだろう」という思い込みです。

伝統的な Unix ツールと現代的な CLI ツールでは、オプションの設計思想に大きな隔たりがあります。主要な CLI ツールにおいて、バージョン表示やヘルプ表示のオプション・サブコマンドの挙動は一貫しておらず、安易なフラグ指定は誤検知の原因となります。

たとえば curl において -v は --verbose(詳細ログ表示)を意味し、URL を指定せずに単独で実行すると rc=2 で終了します。バージョンを表示するためには大文字の -V または --version を指定しなければなりません 7。GNU Coding Standards ではすべてのプログラムが --version と --help を標準オプションとしてサポートすべきと規定していますが 8、すべての CLI が GNU 標準に従っているわけではありません。近年のツールではサブコマンド方式の採用も増えており、フラグの扱いはツールごとに大きく異なります。

コマンド-v の結果-V の結果--version の結果version サブコマンド
curl❌ (rc=2, URL 必須)⭕ (rc=0, バージョン)⭕ (rc=0, バージョン)❌ (rc=6, ホスト解決失敗)
go❌ (rc=2, 不正フラグ)❌ (rc=2, 不正フラグ)❌ (rc=2, 不正フラグ)⭕ (rc=0, バージョン)
gh❌ (rc=1, 不明フラグ)❌ (rc=1, 不明フラグ)⭕ (rc=0, バージョン)⭕ (rc=0, バージョン)
git⭕ (rc=0, バージョン)❌ (rc=129, 不正フラグ)⭕ (rc=0, バージョン)⭕ (rc=0, バージョン)
python3⭕ (rc=0, 対話突入注意)⭕ (rc=0, バージョン)⭕ (rc=0, バージョン)❌ (rc=2, ファイル不在)

go では -v、-V、--version がすべてエラー(rc=2)となり、go version というサブコマンドのみを受け付けます。

さらに注意が必要なのが python3 です。python3 -v は終了ステータス 0 を返しますが、これは詳細トレースモードを有効にして対話 REPL を起動する挙動です。もしシェルスクリプト内で標準入力を開いたまま python3 -v を実行すると、プロセスがユーザー入力を待ち続けてスクリプト全体がハングアップしてしまいます(※各ツールの挙動や終了ステータスは OS やバージョンにより異なる場合があります)。

また、起動確認が保証するのは「起動できる」ところまでです。--help が終了ステータス 0 を返しても、本処理(今回なら defuddle parse)が成功することまでは保証しません。起動確認が捕まえるのは、今回の shim 残存のように「そもそも起動できない」状態です。

起動確認のコストと安全な実装パターン

もうひとつのトレードオフは、起動に伴うプロセス生成コストです。メモリ内のハッシュテーブルや PATH を走査するだけの command -v と比べ、プロセスを実際にフォークしてランタイムを立ち上げる起動確認には、そのぶんのオーバーヘッドがかかります。頻繁に呼び出されるループの中で毎回起動確認を行うのは現実的ではありません。起動確認はスクリプトの初期化時やバッチ処理の開始時に 1 回だけ行い、結果はその実行の中でだけ使い回すのが現実的です(実行をまたいで持ち越すと、今回のような環境の変化を見逃します)。

Q: シェルスクリプトで安全にコマンドの起動確認を行うにはどう書けばよいですか? A: 対象コマンドがサポートする非破壊的なオプション(--version、--help、または version サブコマンド)を明示的に指定し、標準入出力をリダイレクトした上で終了ステータス($?)が 0 であることを検証します。

以下に、作法差に対応しつつ対話モードへの突入を防ぐ、安全な起動確認関数の実装パターンを示します。

# コマンドごとの適切な起動確認コマンドを定義して実行する
check_executable() {
  local cmd="$1"
  shift
  # 標準入力を遮断し、出力を破棄して実行ステータスのみを判定
  "$cmd" "$@" < /dev/null > /dev/null 2>&1
}

# ツールごとの作法に応じた起動確認
check_executable curl -V || echo "curl 起動不能"
check_executable go version || echo "go 起動不能"
check_executable git --version || echo "git 起動不能"

ポイントは < /dev/null によって標準入力を確実に遮断している点です。これにより、ツールが対話モードや REPL に突入しようとしても入力終端(EOF)を受け取って終了するため、標準入力待ちによるハングアップを防げます。ネットワーク待ちなど標準入力以外の理由で止まり得るツールには、timeout で包むなどの別の手当てが要ります。

バージョン管理とシェル運用の将来展望

ツール環境の管理手法は日々進化していますが、「存在確認」と「起動確認」のギャップは今後も形を変えて残り続けます。

PATH フック方式と shim 方式の棲み分け

mise などの最新ツールでは、対話シェル向けに eval "$(mise activate bash)" のようにシェルの環境変数(PATH)を動的に直接書き換える「PATH activation 方式」が推奨されています。ただし前述のとおり、activate_shims が true のままでは PATH activation 方式でも shim が PATH の後方に残るため、今回の事象は対話環境でも起きます。activate_shims を false にして shim を PATH に入れない構成であれば、実体の無いツールは command -v の時点で見つからなくなるはずで、その場合は存在確認でも今回の壊れ方を捕まえられます。

しかし、エディタ等の環境で安定した実行パスを確保するために、shim 方式も広く利用されています 4。また、社内の自動化スクリプトやパイプラインにおいても、既存資産の互換性や過渡期の実務において shim が使われ続ける限り、静的な存在確認の罠は常に潜んでいます。

コンテナ環境や CI への展開

近年では Dev Containers や Docker による開発環境のコンテナ化が普及し、ホスト環境のランタイム不整合は減少しているように見えます。しかし、コンテナ内部でマルチツール環境を構築したり、複数の言語バージョンを使い分けたりする構成では、同じ「薄いラッパーと実体バイナリの乖離」が起こり得ます。

環境の抽象化レイヤーがどれほど上がろうとも、「ファイルの配置という静的な前提(原因の空間)」を信じるのではなく、「プロセスの実起動という動的な結果(結果の空間)」を検証して品質を保証するアプローチは、自動化運用の基本原則として有効であり続けます。

まとめ

ツールの存在確認と起動確認をめぐる今回の実務トラブルから、持ち帰るべき教訓を 4 つの命題としてまとめます。

  1. 存在確認と起動確認を明確に分ける: command -v は PATH 上の静的ファイルの存在確認に過ぎず、shim の残骸や依存不整合による起動不能は構造的に検出できません。
  2. 原因の空間ではなく結果の空間を検収する: ツールが起動できるかを確かめる確実な方法は、安全な非破壊引数でプロセスを実際に起動し、終了ステータス 0 を確認することです。
  3. コマンド固有の作法差を前提に設計する: -v や -h の普遍性を過信せず、curl -V や go version のようにツールごとの正規の確認方法を選択し、< /dev/null で標準入力を遮断します。
  4. 判定式の不具合はリポジトリ全体を横断 grep する: 障害箇所のパッチ当てで満足せず、自己診断スクリプト(doctor)などに同一の判定漏れが取り残されていないかを確認し、必ず根絶します。

自動化パイプラインの堅牢化や AI エージェントの実行環境設計に関するご相談は、コンタクトフォーム までお気軽にお寄せください。

Footnotes

  1. which is not POSIX Hynek Schlawack, 2021年10月31日。which の非標準性と Debian 削除議論、command -v への移行推奨の背景。 ↩

  2. Reliably Detecting Command Existence in Bash Christian Emmer, 2024年12月7日。Bash における command -v 推奨構文の解説。 ↩

  3. IEEE Std 1003.1 (POSIX) - command The Open Group, 2018 edition / IEEE Std 1003.1-2017。-v オプションの「do not invoke command_name」仕様および終了ステータス定義。 ↩

  4. mise-en-place Documentation: Shims jdx, 2026年9月更新。shim の配置ディレクトリ(~/.local/share/mise/shims)とインターセプト仕様、PATH activation との比較。 ↩ ↩2

  5. jdx/mise Issue #5601 jfmontanaro / jdx, 2025年7月。shim 実行時の No version is set for shim エラーメッセージ出力仕様。 ↩

  6. asdf-vm/asdf Issue #2232 hannah-computer / asdf-vm, 2026年1月。asdf における .tool-versions 不整合時の No version is set for command 事例。 ↩

  7. curl man page Daniel Stenberg / curl project, 2026年。-v(—verbose)の仕様および -V, --version によるバージョン表示仕様。 ↩

  8. GNU Coding Standards (standards.texi) Free Software Foundation, 2026年。--version と --help の標準化仕様、引数無視と通常機能停止の規定。 ↩