Claude Media
Claude Codeでシェル補完を自作CLIに付ける — bashとzshの配置と確認

Claude Codeでシェル補完を自作CLIに付ける — bashとzshの配置と確認

自作CLIにbashとzshのTAB補完を付ける手順です。Claude Codeに書かせる指示、置き場所、読み込みの確認、zshで補完が効かない典型原因を実機の結果つきで示します。

自作CLIのTAB補完は、Claude Codeに書かせること自体は難しくありません。つまずくのは、書いたあとの「どこに置けば読まれるか」と「効いているかをどう確かめるか」です。

この記事では、サブコマンドを3つ持つ小さなCLI(notectl)を題材に、bashとzshの補完スクリプトを作って配置し、読み込まれたことを確かめるところまでを通します。実行結果はmacOS上のbash 3.2.57とzsh 5.9で取ったものです。

先に結論: 補完は「スクリプト」「置き場所」「読み込み確認」の3点で決まる

補完が効かない原因は、スクリプトの中身より置き場所と読み込み順にあることが少なくありません。bashはcompleteコマンドで関数を登録し、zshは_始まりのファイルをfpathからcompinitが探します。登録の仕組みが根本から違います。

項目bashzsh
補完の実体bashcomplete -F 関数名 コマンド名zsh#compdef コマンド名 で始まる _コマンド名 ファイル
読み込まれる条件bashスクリプトをsourceする(またはeval)zshcompinitより前にfpathへディレクトリを足す
読み込みの確認bashcomplete -p notectlzshecho ${_comps[notectl]}
候補に説明を付けるbash標準ではできないzsh_describe / _arguments で付けられる

Claude Code側の話は短く済みます。補完スクリプトの生成と修正はClaude Codeに任せ、TABを実際に押す確認だけは人が受け持つ、という分担が現実的です。理由は後半の「Claude Codeに検証させる範囲」で説明します。

題材のCLIと、Claude Codeへの指示

題材は次のnotectlです。サブコマンドはadd、list、export、completionの4つで、exportだけ--formatにjson csv markdownを取ります。

#!/usr/bin/env bash
case "$1" in
  add)    echo "add: ${*:2}" ;;
  list)   echo "list: ${*:2}" ;;
  export) echo "export: ${*:2}" ;;
  completion)
    case "$2" in
      bash) cat "$(dirname "$0")/../notectl.bash" ;;
      zsh)  cat "$(dirname "$0")/../_notectl" ;;
      *) echo "usage: notectl completion {bash|zsh}" >&2; exit 2 ;;
    esac ;;
  *) echo "usage: notectl {add|list|export|completion}" >&2; exit 2 ;;
esac

CLI自身にcompletion bashとcompletion zshを持たせておくと、利用者はeval "$(notectl completion bash)"の一行で導入できます。スクリプトを別ファイルで配る手間が消えます。

CLAUDE.mdに書いておく規約

補完を毎回ゼロから頼むと、書き方がぶれます。プロジェクトのCLAUDE.mdに次のような断片を置くと、サブコマンドを足したときに補完も一緒に直されます。

## 補完スクリプトの規約
- サブコマンドやオプションを追加・変更したら、`notectl.bash` と `_notectl` を同じ変更で更新する
- bash版は `complete -F _notectl notectl` で終える。外部コマンドや `bash-completion` に依存しない
- zsh版は1行目を `#compdef notectl` にし、候補の説明を日本語で付ける
- 変更後は「補完の検証手順」(後述のスキル)を実行して結果を貼る

「同じ変更で更新する」の一文が肝です。CLIの本体だけが変わり、補完が古いまま残る状態を防げます。

検証手順をスキルにする

確認コマンドを毎回説明するのは手間なので、スキルに固めます。スキルはSKILL.mdのフロントマターに説明を書くと、/名前で直接呼べます。disable-model-invocation: trueは、Claudeが会話の流れから自動で起動しないようにする指定で、利用者が/check-completionと打ったときだけ動きます。

事前承認する対象は、検証手順をまとめた専用スクリプト1本に絞ります。bash -c *のような形で承認すると、任意のシェルコマンドを通すのと同じになってしまうためです。

---
name: check-completion
description: notectl の bash/zsh 補完が読み込まれ、候補が正しいか確認する
disable-model-invocation: true
allowed-tools: Bash(./scripts/check-completion.sh *)
---
 
`./scripts/check-completion.sh` を実行し、出力を表にして報告する。
TAB を押す確認は人に依頼する。

scripts/check-completion.shの中身は、次の3段です。

  1. bash: 補完スクリプトをsourceし、complete -p notectlで登録を確認する
  2. bash: COMP_WORDSとCOMP_CWORDを手で組み、_notectlのCOMPREPLYを表示する
  3. zsh: fpathを設定してcompinitを実行し、${_comps[notectl]}が_notectlになるか確認する

allowed-toolsが効くのは、スキルを呼んだそのターンだけです。次のメッセージでは付与が外れます。公式ドキュメントはこの付与が権限設定を置き換えるものではないとも書いており、列挙していないツールも使えるまま、自分の許可ルールが引き続き効きます。承認する範囲を1本のスクリプトに絞る理由はここにあります。

補完の確認だけを毎回許可したいなら、権限ルールとしてBash(notectl completion *)のように、プログラム名とサブコマンドまでを固定して*を後ろに置く形が公式の書き方です。*をサブコマンドより前に置くと、起動時に警告が出ます。

bash: 関数を書いて、登録を確認する

bash版は、現在の単語位置に応じて候補を返す関数と、その登録の2点です。

# notectl.bash
_notectl() {
  local cur prev
  cur="${COMP_WORDS[COMP_CWORD]}"
  prev="${COMP_WORDS[COMP_CWORD-1]}"
  if [ "$COMP_CWORD" -eq 1 ]; then
    COMPREPLY=( $(compgen -W "add list export completion --help" -- "$cur") )
    return
  fi
  case "${COMP_WORDS[1]}" in
    list)       COMPREPLY=( $(compgen -W "--format --limit" -- "$cur") ) ;;
    export)
      if [ "$prev" = "--format" ]; then
        COMPREPLY=( $(compgen -W "json csv markdown" -- "$cur") )
      else
        COMPREPLY=( $(compgen -W "--format --output" -- "$cur") )
      fi ;;
    completion) COMPREPLY=( $(compgen -W "bash zsh" -- "$cur") ) ;;
  esac
}
complete -F _notectl notectl

COMP_WORDSは入力中の単語の配列、COMP_CWORDはカーソルがある単語の添字です。この2つを手で組めば、TABを押さなくても関数の返り値を確かめられます。

TABを押さずに候補を確かめる

bash 3.2.57で、入力状態を模した呼び出しを5パターン実行した結果です。

bash -c 'source ./notectl.bash
t(){ COMP_WORDS=("$@"); COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 )); COMPREPLY=(); _notectl; echo "[$*] -> ${COMPREPLY[*]}"; }
t notectl ""
t notectl e
t notectl export --format ""
t notectl export --format m
t notectl completion ""
complete -p notectl'
入力状態返った候補
notectl 返った候補add list export completion --help
notectl e返った候補export
notectl export --format 返った候補json csv markdown
notectl export --format m返った候補markdown
notectl completion 返った候補bash zsh

最後のcomplete -p notectlはcomplete -F _notectl notectlと出力され、登録は成功しています。

利用者の導入はevalの一行

eval方式の導入は、~/.bashrcに次の1行を足すだけです。

eval "$(notectl completion bash)"

notectl completion bashの出力を同じbash -cの中でevalしたところ、complete -p notectlが登録済みと答えました。一方、別のシェルプロセスでcomplete -p notectlを実行すると「no completion specification」(rc=1)になります。登録はシェルプロセスの中にしか残らないため、~/.bashrcに書いて毎回の起動で読ませる必要があります。

zsh: ファイル名と読み込み順が効く

zshはbashより候補の見た目が豊かで、説明文も付けられます。その代わり、置き場所の条件が厳しいシェルです。

#compdef notectl
_notectl() {
  local -a subcmds
  subcmds=(
    'add:ノートを追加する'
    'list:ノートを一覧表示する'
    'export:ノートを書き出す'
    'completion:補完スクリプトを出力する'
  )
  if (( CURRENT == 2 )); then
    _describe -t commands 'notectl command' subcmds
    return
  fi
  case $words[2] in
    list)       _arguments '--format[出力形式]:format:(table json)' '--limit[件数]:count:' ;;
    export)     _arguments '--format[書き出し形式]:format:(json csv markdown)' '--output[出力先]:file:_files' ;;
    completion) _values 'shell' bash zsh ;;
  esac
}
_notectl "$@"

ファイル名は必ず_notectlにします。先頭の#compdef notectlが「このファイルはnotectlの補完だ」という宣言で、compinitがfpathのディレクトリを走査するときに読み取ります。

_argumentsの指定は、'--format[書き出し形式]:format:(json csv markdown)'のように3つのコロン区切りで読みます。1つ目がオプション名と角括弧の説明、2つ目が値に付ける見出し、3つ目が値の候補です。候補をその場で固定せずファイル名にしたいなら_filesを置き、固定の語彙なら括弧で列挙します。listの--limitのように、候補を持たない数値は3つ目を空にして見出しだけ残しています。

Claude Codeにzsh版を書かせるときは、この3区切りを指示に含めると、説明文と値の候補が取り違えられにくくなります。

配置と読み込みの確認

~/.zfuncのようなディレクトリを作って_notectlを置き、~/.zshrcに次の順で書きます。

fpath=(~/.zfunc $fpath)
autoload -Uz compinit
compinit

読み込みの確認はcompinitのあとでecho ${_comps[notectl]}を実行します。_notectlと出れば、zshはnotectlの補完関数を認識しています。_compsは補完を登録した「コマンド名と関数名」の連想配列です。

zshで実機確認したのは、この登録までです。サブコマンドの後ろでオプションや値の候補が実際に出るかは、TABを押して確かめていません。上のcase $words[2]の分岐は_argumentsの標準的な書き方に沿っていますが、手元のターミナルで一度確認してください。

効かない3つのパターンを再現した

macOSのzsh 5.9で、fpathの指定順とキャッシュの状態を変えて_comps[notectl]を見ました。

条件${_comps[notectl]}
_notectlを置いたディレクトリをfpathに足してからcompinit${_comps[notectl]}_notectl(登録された)
先にcompinit -uを実行し、あとからfpathにディレクトリを足した${_comps[notectl]}空(登録されない)
補完ファイルを置く前に作られたキャッシュ(zcompdump)を、compinit -Cで再利用した${_comps[notectl]}空(登録されない)
上のキャッシュを削除してcompinitをやり直した${_comps[notectl]}_notectl(登録された)

落とし穴

後からファイルを足したのに zsh が反応しない

compinitはfpathを走査した結果をキャッシュ(既定では~/.zcompdump)に残します。-Cを付けた起動は、そのキャッシュを再利用して走査を省きます。新しい_notectlを足したあとに効かないときは、~/.zcompdump*を消してからシェルを開き直してください。fpathの追加がcompinitより後ろにある場合も同じ症状になります。

notectl completion zsh > ~/.zfunc/_notectlで配置する運用なら、CLIの更新後にこのコマンドを再実行し、キャッシュも消す、という2手順を案内文に入れておくと問い合わせが減ります。

Claude Codeに検証させる範囲

Claude Codeに確認を任せるとき、押さえておく点が1つあります。公式ドキュメントでは、Bashツールは各コマンドを別のプロセスで実行するとされています。環境変数は次のコマンドに引き継がれません。起動時に~/.zshrcか~/.bashrcなどを読み込んで、エイリアスや関数、シェルオプションを取り込みます。

つまり、Bashツールの中は「キーボードのTABを待つ対話シェル」ではなく、コマンドを渡して出力を受け取る実行環境です。TABの挙動そのものは再現できません。そこで上で示したように、COMP_WORDSを手で組んで関数を直接呼ぶ(bash)、compinit後の_compsを見る(zsh)、という間接的な確認に寄せます。

この確認方法には見落としがあります。zshの_describeが作る説明付きの候補表示は、_compsの登録確認では分かりません。見た目まで確かめるなら、実際のターミナルでnotectl に続けてTABを押す最後の一手は人が担当します。

シェルの設定ファイルまわりで予期しない挙動に当たったときは、Claude CodeのBashツールとシェル起動設定で、zsh・bash・PowerShellごとの読み込みの違いを整理しています。zshでbash構文が失敗する場合はClaude Codeがzshでbash構文を書いて失敗する原因と対処が近い症状を扱います。

CLIの定義と補完のずれを検出する

補完で一番多い事故は、サブコマンドを足したのに補完が古いままになることです。人がレビューで見落とすぶん、検出をスクリプトに寄せます。次のdrift.shは、CLIの使い方メッセージに出るサブコマンドが、補完の第1候補に全部含まれるかを調べます。

#!/usr/bin/env bash
usage=$(notectl 2>&1 | grep -o '{[^}]*}' | tr -d '{}' | tr '|' ' ')
source ./notectl.bash
COMP_WORDS=(notectl ""); COMP_CWORD=1; COMPREPLY=(); _notectl
for sub in $usage; do
  case " ${COMPREPLY[*]} " in
    *" $sub "*) echo "ok      $sub" ;;
    *) echo "MISSING $sub"; rc=1 ;;
  esac
done
exit ${rc:-0}

補完が揃っている状態では4行すべてがokで、終了コードは0でした。notectl.bashの第1候補からcompletionをわざと外して再実行すると、MISSING completionが出て終了コードは1になります。この終了コードをコミット前のフックやCIに載せれば、Claude Codeが補完を直し忘れたまま変更を終えることを防げます。

Claude Codeへの頼み方も変わります。「補完を直して」ではなく「drift.shが0で終わるまで補完を直して」と、合格条件を実行可能な形で渡します。出力を読んで自分で直す反復が成立するので、人が差分を目で追う回数が減ります。

この方式の限界は、第1候補しか見ていないことです。--formatのような値の補完や、サブコマンドごとのオプションは別の検査が要ります。値の補完は、先ほどのt notectl export --format mのような呼び出しを、期待値つきでテストに並べるのが手軽です。

よくあるつまずき

  • bashでサブコマンドは出るのにオプションが出ない: caseの分岐がCOMP_WORDS[1]に依存しています。サブコマンドの前にグローバルオプションを許すCLIでは、先頭から走査して最初の非オプション単語を探す書き方に直します
  • zshで_argumentsが空の候補を出す: 候補の指定がオプション[説明]:見出し:候補の3区切りの形になっているか確認します。値の候補を持たないオプションは、3つ目を空にして見出しだけ残します
  • macOSの標準bashで動かない: macOS付属のbashは3.2系です。compgen -Wとcomplete -Fは3.2でも動きましたが、新しめのbashだけにある機能を使った補完を生成させないよう、CLAUDE.mdに「bash 3.2で動くこと」と書いておくと安全です
  • eval方式が遅い: 起動のたびにnotectl completion bashが走ります。出力をファイルへ保存してsourceする方式に変えると、CLI本体の起動コストが毎回のシェル起動に乗りません

自作CLIをプラグインと組み合わせる設計はplugin hintsで自作CLIに公式プラグインを推薦させる、スクリプトからCLIを呼ぶ設計はant CLIでAPIリソースをスクリプトで自動化するが参考になります。

まとめ

補完は、書き方よりも読み込みの条件で動作が決まります。bashはcompleteの登録をシェル起動のたびに実行すること、zshはcompinitより前にfpathへ入れてキャッシュを疑うこと。この2点を押さえれば、大半の「効かない」は説明がつきます。

Claude Codeには規約をCLAUDE.md、検証手順をスキルに書いておくと、サブコマンドを増やしたときに補完が置き去りになりません。最後のTAB確認だけは手元のターミナルで行ってください。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/skills
  2. code.claude.com/docs/en/tools-reference
  3. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn