add-zsh-hook は、zshが特定のタイミングで自動的に呼び出す関数(Zsh Hook)を登録・解除するための、zsh同梱の関数です。autoload -Uz add-zsh-hook で読み込み、add-zsh-hook precmd 関数名 のように「フック名+関数名」を渡すだけで、プロンプト表示の直前やコマンド実行の直前に処理を差し込めます。この記事では、7種類のフックの発動タイミングと引数、1回のコマンド実行で呼ばれる順番、エラーで後続の関数が止まる条件を、zsh 5.9(macOS同梱)と最新安定版 5.9.2 の両方で実測した結果とともに整理し、実行時間の通知や仮想環境の自動有効化といった実用コードまで載せます。
まとめ:add-zsh-hookとZsh Hookの要点
- 使い方は2行:
autoload -Uz add-zsh-hookのあとadd-zsh-hook <フック名> <関数名>。解除は-d(パターン指定は-D)、一覧は-L。 - フックは7種:chpwd・periodic・precmd・preexec・zshaddhistory・zshexit・zsh_directory_name。これ以外の名前はエラーになります。
- 1回のコマンドで呼ばれる順番:zshaddhistory → preexec → コマンド本体(途中の cd で chpwd)→ precmd → periodic。空のEnterでは preexec が呼ばれません。
- 戻り値では止まらず、エラーでは止まる:
return 1しても後続の関数は実行されますが、シェルのエラーが起きるとそのフックの残りの関数は実行されません。 - 配列への直接追記より add-zsh-hook:
precmd_functions+=(f)は同じ関数を二重登録しますが、add-zsh-hook は登録済みなら追加しません。 - precmd は毎回走る:
vcs_infoはGitリポジトリ内で1回あたり中央値約247ミリ秒でした(後述の計測環境)。重い処理は計測してから入れます。
以下、登録の手順、フックごとの挙動、実行順の実測、実用例、動かないときの確認の順に説明します。
add-zsh-hookによるフックの登録・解除
add-zsh-hook は zsh 本体と一緒に配布される関数ファイルで、macOS では /usr/share/zsh/5.9/functions/add-zsh-hook にあります。ファイルの中身は数十行のzshスクリプトで、最新安定版 5.9.2 と開発版(GitHubの master)を取得して比べたところ、5.9 から1文字も変わっていませんでした。
autoloadによるadd-zsh-hookの読み込みと登録
# ~/.zshrc
autoload -Uz add-zsh-hook
show_time() {
print -P '%F{green}%D{%H:%M:%S}%f'
}
add-zsh-hook precmd show_time # プロンプトの直前に毎回時刻を出す
autoload の -U は関数を読み込むときにエイリアスを展開しない指定、-z は zsh 形式で読み込む指定です。add-zsh-hook は登録のついでに、渡された関数名も autoload します。そのため、関数を $fpath に置いた別ファイルに分けておけば、.zshrc で定義していなくても登録できます(初回の呼び出し時に読み込まれることを実測で確認)。
-d・-D・-Lで解除と一覧表示
add-zsh-hook -d precmd show_time # 指定した関数を外す
add-zsh-hook -D precmd 'show_*' # パターンに一致する関数をまとめて外す
add-zsh-hook -L # 全フックの登録状況を表示
add-zsh-hook -L precmd # precmd だけ表示
-L の出力は typeset -g -a precmd_functions=( a_ret b_ok c_err ) のような形式で、どの関数がどの順で並んでいるかがそのまま分かります。-d は同名の関数が配列内に2つあれば両方を外し、配列が空になると precmd_functions という変数自体を削除します。
precmd_functionsに直接追記する方法との違い
add-zsh-hook がしているのは、precmd_functions のように「フック名+_functions」という名前の配列へ関数名を足すことだけです。配列へ直接 precmd_functions+=(show_time) と書いても動きますが、.zshrc を source し直すたびに同じ名前が増え、プロンプトのたびに同じ関数が2回、3回と実行されます。実測では、add-zsh-hook で同じ関数を2回登録しても配列には1つしか入らず、直後に precmd_functions+=(...) で足した分だけが重複しました。設定を何度も読み直す運用なら add-zsh-hook を使います。
逆に、配列を直接書き換える必要があるのは「先頭で実行したい」ときです。add-zsh-hook は常に末尾へ追加するため、先頭に入れるには precmd_functions=(my_first $precmd_functions) と書き換えます。direnv はこの方法で自分の関数を先頭に入れています(後述)。
7種類のフックの発動タイミングと引数
add-zsh-hook が受け付けるフック名は、ソース内の hooktypes に並ぶ7つです。発動条件はzsh公式マニュアルの「9.3.1 Hook Functions」に定義されています。
| フック | 呼ばれるタイミング | 引数 | 主な用途 |
|---|---|---|---|
| precmd | プロンプト表示の直前 | なし | プロンプト更新・時間計測の終点 |
| preexec | コマンドを読み終え実行する直前 | 3つ | 実行ログ・時間計測の始点 |
| chpwd | カレントディレクトリの変更時 | なし | 環境の自動切り替え |
| periodic | $PERIOD 秒ごと(プロンプト前) | なし | 定期チェック |
| zshaddhistory | 履歴行を保存する前 | 履歴行 | 履歴に残す行の選別 |
| zshexit | メインのシェルが正常終了する時 | なし | 後始末 |
| zsh_directory_name | 動的な名前付きディレクトリの解決時 | 種別と文字列 | ~[名前] の変換 |
どのフックでも、フック名と同じ名前の関数(たとえば precmd)を定義すると、それが配列の関数より先に呼ばれます。zsh_directory_name だけは扱いが異なり、マニュアルの「Dynamic named directories」のとおり、0を返す関数が見つかった時点で残りは呼ばれません。
precmd・preexec:プロンプトとコマンド実行の前後
precmd は引数を取らず、プロンプトを出す直前に呼ばれます。バックグラウンドジョブの終了通知などでコマンドラインが再描画されただけでは、再実行されません。preexec は3つの引数を受け取ります。第1引数は履歴機能が有効なら入力文字列、無効なら空文字列です。第2引数はエイリアス展開後のコマンドを長さ制限付きの1行にし、関数本体などを省略したもの、第3引数は実行対象の全文です。実測では、alias ll='ls -d' を定義して ll /tmp と打つと、第1引数は ll /tmp、第2・第3引数は ls -d /tmp でした。入力したとおりに記録したいなら第1引数、実際に動いた内容を記録したいなら第3引数を使います。
chpwd・periodic:ディレクトリ移動と定期実行
chpwd は cd・pushd・popd でカレントディレクトリが変わるたびに呼ばれ、$PWD と $OLDPWD を参照できます。periodic は変数 PERIOD に秒数を入れたときだけ有効で、前回から PERIOD 秒以上たった後のプロンプト表示の前に呼ばれます。periodic に複数の関数を登録しても、間隔は全体で1つです。
zshaddhistory・zshexit:履歴の選別と終了処理
zshaddhistory には入力した行が改行付きで渡され、戻り値で履歴への保存を制御できます。すべてのフックが0を返せば通常どおり保存します。1を返した行も次の行が実行されるまでは一時的に履歴に残り、直後の再編集が可能です。複数のフックの戻り値が競合すると、最初の非ゼロ値が採用されます。2 を返すと、シェル内の履歴には残りますが HISTFILE には書き込まれません。zshexit はメインのシェルが正常終了するときに呼ばれ、exec で外部コマンドに置き換わった場合は呼ばれません。ただし、サブシェルについてはマニュアルと実際の挙動が食い違います(後述のトラブルシューティング)。
1回のコマンド実行でフックが呼ばれる順番(実測)
5種類のフックすべてに表示用の関数を登録し、PERIOD=1 にした状態で sleep 1.2; cd /tmp && echo hoge を実行しました。zsh 5.9(macOS 26.6.2 同梱)と、ソースからビルドした zsh 5.9.2 の両方で、出力は同じでした。
[zshaddhistory sleep 1.2; cd /tmp && echo hoge]
[preexec 1=sleep 1.2; cd /tmp && echo hoge | 2=... | 3=...]
[chpwd base] # cd を実行した瞬間(echo より前)
[chpwd_functions c1]
hoge
[precmd status=0]
[periodic]
押さえておくべき点が3つあります。chpwd は「コマンドの後」ではなく、cd が実行されたその時点で呼ばれるため、同じ行の後続コマンドより先に出力されます。precmd の時点では直前のコマンドの終了コードが $? に入っているので、成否による分岐は precmd で書きます(zshaddhistory と preexec はコマンドの前に動くため、結果を知りません)。空のEnterを押すと zshaddhistory と precmd が呼ばれ、実行時期を迎えた periodic も呼ばれます。preexec は呼ばれず、$? は前のコマンドの値のまま残ります。preexec で記録した値を precmd で使う処理は、この空Enterを考えて書く必要があります。
同じフックに複数登録したときの実行順とエラー時の挙動
同じフックに登録した関数は、フック名と同じ名前の関数を先頭に、配列の並び順で1つずつ呼ばれます。マニュアルには、どの関数も同じ引数と同じ初期値の $? で呼ばれること、そして「An error in any function causes subsequent functions not to be run.」(いずれかの関数でエラーが起きると後続の関数は実行されない)と書かれています。ここでいうエラーは0以外の戻り値とは別物で、実測で区別できます。
a_ret() { print '[a returns 1]'; return 1 }
b_ok() { print '[b runs]' }
c_err() { print '[c error start]'; local x=${undefined_var:?boom}; print '[c after]' }
d_after() { print '[d runs]' }
add-zsh-hook precmd a_ret
add-zsh-hook precmd b_ok
add-zsh-hook precmd c_err
add-zsh-hook precmd d_after
5.9・5.9.2とも、表示は [a returns 1]・[b runs]・[c error start]・c_err: undefined_var: boom までで、[d runs] は一度も出ませんでした。return 1 では後続が動き、${var:?} の展開エラーのようなシェルのエラーでは、そのフックの残りの関数がすべて止まります。プラグインを複数入れていると、前に登録された1つのエラーで後ろのプロンプト更新が止まり、「プロンプトの表示が古いまま」に見えます。マニュアルによれば、precmd でエラーが起きると直後の periodic も1回飛ばされます。
Zsh Hookの実用例
ここで紹介する4つのコードは、すべて zsh 5.9 と 5.9.2 の対話シェルで動作を確認したものです。
vcs_infoとprecmdによるGitブランチのプロンプト表示
autoload -Uz add-zsh-hook vcs_info
zstyle ':vcs_info:git:*' formats '%b'
_update_vcs() { vcs_info }
add-zsh-hook precmd _update_vcs
setopt PROMPT_SUBST
RPROMPT='${vcs_info_msg_0_}'
vcs_info は zsh 同梱の関数で、呼ぶたびに $vcs_info_msg_0_ を現在のブランチ名で更新します。precmd で呼ぶため、cd repo; echo $vcs_info_msg_0_ のように同じ行で参照すると、まだ前の値のままです。表示を細かく作り込みたいなら、プロンプト専用ツールのStarshipの導入とstarship.toml設定に任せる方法もあります。Starship も内部では add-zsh-hook precmd と add-zsh-hook preexec で登録しています。
preexecとprecmdによる実行時間の計測と長時間処理の通知
autoload -Uz add-zsh-hook
zmodload zsh/datetime
typeset -g _cmd_start=0
_timer_start() { _cmd_start=$EPOCHREALTIME }
_timer_report() {
local st=$?
(( _cmd_start )) || return 0 # 空Enterでは preexec が呼ばれない
local elapsed=$(( EPOCHREALTIME - _cmd_start ))
_cmd_start=0
if (( elapsed >= 10 )); then
printf '%.1f秒かかりました(終了コード %d)\n' $elapsed $st
print -n '\a' # 端末のベルで知らせる
fi
}
add-zsh-hook preexec _timer_start
add-zsh-hook precmd _timer_report
preexec で開始時刻を記録し、precmd で差分を取ります。local st=$? を関数の1行目に置くのは、ほかの処理を挟むと $? が上書きされるためです。_cmd_start=0 に戻しておかないと、空Enterのたびに前回のコマンドからの経過時間で通知が出ます。しきい値を1秒にして zsh 5.9・5.9.2 で実行すると、sleep 1.2 では1回だけ通知が出て、直後の空Enterと true では何も出ませんでした。
chpwdによるPython仮想環境の自動有効化
autoload -Uz add-zsh-hook
_auto_venv() {
(( ZSH_SUBSHELL )) && return # サブシェル内の cd では何もしない
local root=${VIRTUAL_ENV:A:h}
if [[ -n $VIRTUAL_ENV && ${PWD:A} != $root && ${PWD:A} != $root/* ]]; then
deactivate
fi
if [[ -z $VIRTUAL_ENV && -f .venv/bin/activate ]]; then
source .venv/bin/activate
fi
}
add-zsh-hook chpwd _auto_venv
_auto_venv # 起動したディレクトリにも適用する
この例は、自分で作成し内容を信頼できる .venv だけを扱う環境向けです。移動先の activate を現在のシェルで自動実行するため、第三者から取得した未確認のディレクトリを巡回する環境では採用しません。有効化後は配下で維持し、外へ出ると無効化します。:A(シンボリックリンクを解決した絶対パス)で比べているのが要点です。macOS の /tmp は /private/tmp へのシンボリックリンクで、venv の activate は VIRTUAL_ENV に /private/tmp/... を入れます。最初に $PWD とそのまま比べるコードでは、配下のディレクトリへ移っただけで無効化されました。サブシェルの中の (cd dir) でも chpwd は呼ばれるので、ZSH_SUBSHELL で除外しています。環境変数をディレクトリ単位で切り替える用途が広いなら、同じ chpwd を使うdirenvの.envrcによる切り替えのほうが管理しやすくなります。
zshaddhistoryによる特定の変数代入行の履歴保存除外
autoload -Uz add-zsh-hook
_skip_history() {
local line=${1%%$'\n'}
[[ $line == *(TOKEN|PASSWORD|SECRET)=* ]] && return 1 # 履歴に残さない
[[ $line == (rm|kill)\ * ]] && return 2 # ファイルには書かない
return 0
}
add-zsh-hook zshaddhistory _skip_history
引数の行末には改行が付いているため、${1%%$'\n'} で落としてから判定します。INC_APPEND_HISTORY を有効にして実行すると、export GH_TOKEN=xyz は fc -l にも HISTFILE にも残りませんでした。rm -f ... は fc -l には出て、ファイルには書かれませんでした。戻り値が 2 の行は今のシェルの中では上矢印で呼び出せ、次回のシェルには持ち越されません。失敗したコマンドだけを除きたい場合、zshaddhistory の時点では結果が分からないので、precmd の $? で判定して print -s で追加する、という組み立てになります。
フックが動かないときの確認手順
-L・whence・functionsによる登録状況と定義の確認
最初に add-zsh-hook -L precmd で配列に名前が入っているかを見ます。名前があるのに動かないなら、whence -w 関数名 で関数として定義されているかを確認します。マニュアルの規定では、配列にある名前の関数が存在しなければ、エラーを出さずに読み飛ばされます。ただし、add-zsh-hook は登録名を autoload の対象にもするため、タイプミスした名前で登録すると、プロンプトのたびに typo_fn: function definition file not found のようなエラーが出ます(実測では後続の関数は実行されました)。autoload の登録だけでも whence -w は function と表示するので、functions 関数名 で中身が読み込めているかも確認します。名前も定義もあるのに途中から動かないなら、前のセクションで見た「前の関数のエラーで後続が止まる」を疑い、配列の前の関数を1つずつ -d で外して切り分けます。
サブシェル内の明示的なexitによるzshexitの発動
マニュアルは zshexit について「This is not called by exiting subshells」と書いています。ところが zsh 5.9 と 5.9.2 の対話シェルで ( exit 3 ) や $(exit 0) のように exit を明示したサブシェルを実行すると、zshexit が ZSH_SUBSHELL=1 の状態で呼ばれました。( true ) のように exit を書かずに終わるサブシェルと、exec で外部コマンドに置き換えた場合は呼ばれません。zshexit に一時ファイルの削除やログの書き出しを入れているなら、先頭に (( ZSH_SUBSHELL )) && return を置きます。direnv にも $ZSH_SUBSHELL -ne 0 の除外がありますが、これは補完などのサブシェル内で chpwd が発動する場合への対策で、zshexit の挙動への対策ではありません。
precmd登録関数の実行時間とプロンプト表示遅延の計測
zmodload zsh/datetime
_measure_precmd() {
local f t
for f in $precmd_functions; do
t=$EPOCHREALTIME
$f
printf '%-16s %6.2f ms\n' $f $(( (EPOCHREALTIME - t) * 1000 ))
done
}
この関数を手で実行すると、precmd_functions 配列内の関数を実際に呼び出して所要時間を表示します。直接定義した precmd は対象外で、通知や変数更新などの副作用も発生します。また、通常のフック呼び出しと同じ初期値の終了コードは再現しません。Intel Core i9-9880H の MacBook Pro(macOS 26.6.2、Apple Git 2.50.1、コミット1件だけの検証用リポジトリ、formats '%b' のみ設定)で、zsh 5.9 と 5.9.2 それぞれ5回ずつ測りました。vcs_info はGitリポジトリ内で194〜527ミリ秒(10回中9回は262ミリ秒以下、中央値は約247ミリ秒)、リポジトリ外で70〜95ミリ秒でした。リポジトリ内で git branch --show-current を1回呼ぶだけなら69〜92ミリ秒です。実際の所要時間はリポジトリの大きさと zstyle の設定で変わるので、自分の環境で測ってから判断します。入力のたびに約0.25秒待たされるのが気になるなら、ブランチ名だけを取得する処理へ絞ります。chpwd だけで更新する設計では、同じディレクトリで git switch しても表示が更新されないため、ブランチ切り替え後の更新も別途必要です。
Git Hook・trap・bashとの違い
「フック」という名前でも、Git Hook はリポジトリの .git/hooks に置いたスクリプトがコミットやプッシュの際に動く仕組みで、Zsh Hook とは無関係です。Git 側の操作はGitコマンド一覧の用途別早見表で確認できます。zsh の TRAPINT のようなトラップ関数はシグナルを受けたときに動くもので、マニュアルでも「hook arrays を持たない」と区別されています。キー入力のたびに動く行編集(ZLE)のフックは add-zle-hook-widget という別の関数で登録します。
主要なツールの登録方法は分かれます(各リポジトリの2026年10月時点のソースで確認)。
| ツール | 使うフック | 登録方法 |
|---|---|---|
| Starship | precmd・preexec | add-zsh-hook(末尾) |
| direnv | precmd・chpwd | 配列の先頭へ挿入 |
| zoxide | precmd または chpwd | 既存分を除いて配列末尾へ |
direnv は未登録なら配列の先頭に入るため、通常は配列に登録した自作関数より先に動きます。ただし、直接定義した precmd 関数は配列より先に実行され、後から配列を並べ替えた場合も順序が変わります。出典はdirenvのzsh用フック定義とStarshipのzsh初期化スクリプトです。
bash に Zsh Hook と同じ仕組みはありません。precmd に近いのは PROMPT_COMMAND で、bashのNEWSにあるとおり、bash 5.1 から配列で複数のコマンドを登録できるようになりました。macOS に同梱の /bin/bash は 3.2.57 なので配列は使えません。preexec まで欲しいなら、zsh と同じ precmd_functions・preexec_functions を再現する bash-preexec を使います。bash の設定ファイルの読み込み順は.bashrcと.bash_profileの読み込み順にまとめています。
よくある質問
add-zsh-hookはどこで定義されていますか?
zsh に同梱の関数ファイルです。macOS では /usr/share/zsh/5.9/functions/add-zsh-hook にあり、autoload -Uz add-zsh-hook で読み込みます。Oh My Zsh などのフレームワークは不要です。公式の説明はzshマニュアルの「Manipulating Hook Functions」にあります。
precmdとpreexecの違いは何ですか?
preexec はコマンドを入力してEnterを押した後、実行する直前に呼ばれ、入力したコマンドを引数で受け取ります。precmd はコマンドが終わって次のプロンプトを出す直前に呼ばれ、引数はありませんが $? で直前の終了コードを読めます。空のEnterでは preexec は呼ばれませんが、precmd と zshaddhistory が呼ばれ、実行時期を迎えた periodic も呼ばれます。
zshaddhistoryで特定のコマンドを履歴に残さないにはどうしますか?
zshaddhistory に登録した関数で、対象の行なら return 1 を返します。ファイルにだけ書きたくない場合は return 2 です。渡される行には末尾の改行が含まれるので、${1%%$'\n'} で取り除いてから比較します。
Zsh Hookに登録した関数を一覧で確認できますか?
add-zsh-hook -L で全フックの登録状況を、add-zsh-hook -L chpwd で特定のフックだけを表示できます。print -l $precmd_functions でも同じ並びが見られます。表示される順番がそのまま実行順です。
chpwd hookでは何ができますか?
ディレクトリを移動したときの処理を自動化できます。よく使われるのは、仮想環境やNode.jsのバージョンの自動切り替え、移動先の ls 表示、プロンプト情報の更新です。(cd dir) のようなサブシェル内の移動でも呼ばれるため、重い処理には ZSH_SUBSHELL の判定を入れます。よく使う cd の短縮はシェルのaliasの使い方と組み合わせられます。