起動系のシェルスクリプトを書いていて、いちばん厄介なのは「もう動いているかどうか」の判定だと思っています。プロセスを1つ数え間違えるだけで、動いていないのに「動いてます」と断られたり、逆に二重に立ち上がったり。しかも外部から叩くツールだと、その誤判定はいちばん困る場面(外出先、電波が弱い、片手)を狙ったように顔を出す。
以前、iPhoneから手元のマシンのClaude Codeセッションを1コマンドで立ち上げる小さなCLIを作った話を書きました(iPhoneから手元のClaude Codeを1タップで起動する)。あの記事は「なぜ作ったか」まで。今回はその中身、つまり cc-launch という起動スクリプトが内部で何をどう判定しているかに踏み込みます。動機の話はもうしません。代わりに、プロジェクト名をどう住所に変換するか・二重起動をどう冪等に防ぐか・壊れた入力をどう手前で弾くかの3点を、実際のコードと、途中で踏んだ判定バグの直し方で追っていきます。
読者として想定しているのは、自分で起動系やラッパー系のCLIを書くエンジニアです。「起動スクリプトが誤ってalready runningと言い張る」あるあるに覚えがある人ほど、たぶん刺さります。
この記事で学べること
- プロジェクト名からディレクトリを解決する「段階フォールバック」の設計
- 曖昧なときに黙って選ばず、エラーで止めて設定を促す作法
pgrepで全プロセスを見て誤判定した二重起動チェックを、対象を絞って直すまで- 外から叩くCLIに入れておく防御的な入力バリデーション
前提
bash(set -euo pipefail前提)で起動系スクリプトを書いたことがあるpgrep/lsof/awkあたりを普段づかいできる- 「外部トリガーから叩かれるスクリプトは冪等であってほしい」という感覚がある
なお、起動対象は Claude Code の Remote Control というモード(執筆時点ではリサーチプレビュー。Claude Code は Pro $20/月・Max $100〜200/月のサブスクに含まれます。料金)ですが、この記事の主題はあくまで起動スクリプトの内部設計なので、そこは「対象のセッションを名前付きで立ち上げる」くらいに捉えてもらえれば十分です。
1. プロジェクト名を住所に変換する — 段階フォールバック
cc-launch myrepo と打ったとき、その myrepo が実際にどのディレクトリなのかを決めなければいけません。ここは3段構えにしました。上から順に探し、最初に見つかったところで確定します。
コードにすると、解決の本体はこれだけです。設定ファイルを引き、無ければリポジトリ管理ツール(ghq)の一覧を引く。どちらでも見つからなければ unknown project で止める。ここで何度も出てくる cc_launch_die は、メッセージを標準エラーに出して終了するだけの自前関数だと思ってください(echo ... >&2; exit 1 を1行にまとめたもの)。
cc_launch_resolve_project() {
local project="$1" line
line="$(cc_launch_lookup_config "$project" || true)"
if [ -z "${line:-}" ]; then
line="$(cc_launch_lookup_ghq "$project" || true)"
fi
[ -n "${line:-}" ] || cc_launch_die "unknown project: $project"
# ...この後で列を分解して検証していく(後述)
}
明示設定を最優先にしているのは、パスやセッション名を自分で決めたいプロジェクトがあるからです。設定ファイル(projects.tsv)はタブ区切りで、1レコードが1プロジェクト。列は「プロジェクト名・パス・セッション名・リモート操作名」の4つです。
# project path session remote-control-name
myrepo ~/path/to/myrepo myrepo myrepo
webapp ~/path/to/webapp webapp webapp
設定を引く側は awk で、コメント行と空行を飛ばしつつ1列目の完全一致を探します。設定ファイルとテンプレートの2つを順に見て、最初にヒットした行を返す作りです。
cc_launch_lookup_config() {
local project="$1" file line
while IFS= read -r file; do
[ -f "$file" ] || continue
line="$(awk -F '\t' -v key="$project" '
$0 ~ /^[[:space:]]*($|#)/ { next }
$1 == key { print; exit }
' "$file")" || true
[ -n "${line:-}" ] && { printf '%s\n' "$line"; return 0; }
done < <(cc_launch_config_files)
return 1
}
2. 曖昧なときは黙って選ばない
3段目のフォールバック(ghqの一覧から名前で引く)には、意図的な安全弁を入れています。webapp のようなよくある名前は、複数の場所に存在しがちです。そこで同名が複数見つかったら、どれか1つを勝手に選んだりせず、候補を並べてエラーで止め、設定ファイルへの登録を促します。
cc_launch_lookup_ghq() {
local project="$1" matches count
command -v ghq >/dev/null 2>&1 || return 1
matches="$(ghq list --full-path 2>/dev/null | awk -F/ -v key="$project" '$NF == key { print }')"
[ -n "$matches" ] || return 1
count="$(printf '%s\n' "$matches" | awk 'NF { n++ } END { print n + 0 }')"
if [ "$count" -ne 1 ]; then
echo "cc-launch: project '$project' matched multiple ghq repos:" >&2
printf '%s\n' "$matches" >&2
echo "cc-launch: add it to ~/.config/cc-launch/projects.tsv to disambiguate" >&2
exit 1
fi
printf '%s\t%s\t%s\t%s\n' "$project" "$matches" "$project" "$project"
}
一意に定まるときだけ暗黙解決を許す、というのがここの方針です。外部から叩く起動ツールでいちばん怖いのは、意図と違うリポジトリを立ち上げて、しかもそれに気づかないこと(外出先で、別プロジェクトのセッションを黙って掴んでいる図を想像すると背筋が寒い)。だから「候補が複数ある」は成功ではなく停止として扱い、人間に一度だけ設定してもらう。曖昧さは、暗黙に潰すより明示的に突き返したほうが安全です。
3. 二重起動チェックが「もう動いてる」と嘘をついた話
外部トリガーから叩くツールなので、同じコマンドを二度踏んでも大丈夫、つまり冪等であってほしい。そこで cc-launch は「すでに対象のリモート操作が動いていたら、何もせず終わる」ようにしています。スマホの通信が不安定で二度叩いても、セッションが二重に立たない。
ところが、最初の実装はこの判定が雑でした。コミットログを追うと、最初の追加コミット(起動ランチャーの新設)の時点では、判定関数は cc_launch_has_live_claude という名前で、こう書かれていました。
# 最初の実装(悪い例): その dir にいる claude を無条件に稼働中とみなす
cc_launch_has_live_claude() {
local dir="$1" pid cwd
for pid in $(pgrep -x claude 2>/dev/null || true); do
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -1)"
[ "$cwd" = "$dir" ] && return 0
done
return 1
}
pgrep -x claude で claude という名前のプロセスを全部拾い、lsof でその作業ディレクトリ(cwd)を調べ、対象ディレクトリと一致したら「動いている」と判定する。一見それらしく見えます。でも実際どうなの? 答えは「大きな穴がある」でした。
pgrep -x claude は、リモート操作のセッションだろうと、目の前で普通に開いて編集している対話セッションだろうと、区別なく claude を数えてしまう。だから、手元のマシンで同じプロジェクトを普通に開いて作業している最中にスマホから起動をかけると——動いているのは対話セッションのほうなのに——already running と断られて、肝心のリモート操作を立ち上げられませんでした(自分がさっき開いたセッションに、自分が締め出される)。
修正コミット(Remote Control判定を --remote-control プロセスに限定)では、関数名を cc_launch_has_live_remote_control に改め、判定対象を「リモート操作フラグ付きで起動しているプロセス」だけに絞りました。ps -o args= でコマンドライン引数を取り出し、--remote-control を含むものだけを数える。
cc_launch_has_live_remote_control() {
local dir="$1" pid cwd
for pid in $(pgrep -x claude 2>/dev/null || true); do
# --remote-control 付きのプロセスだけを数える。
# 同じ dir の通常セッション(編集作業中)は起動をブロックさせない。
case " $(ps -o args= -p "$pid" 2>/dev/null) " in
*' --remote-control '*) ;;
*) continue ;;
esac
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -1)"
[ "$cwd" = "$dir" ] && return 0
done
return 1
}
case " ... " in *' --remote-control '*) の前後にスペースを付けているのは、引数の途中一致ではなく「独立した引数として --remote-control が並んでいる」ことを見たいからです。ここは地味ですが、フラグ名が別の文字列の部分に紛れ込んで誤ヒットするのを避ける、素直なやり方だと思います。
学びとしては単純で、「何かが動いているか」ではなく「何が動いているか」まで見て初めて、二重起動防止は実用になるということでした(つまり、プロセス名だけを鍵にした時点で負けが確定していた)。プロセス名だけを鍵にすると、同名の別用途プロセスに足をすくわれる。判定の粒度は、名前ではなく役割に合わせる。テストは通っていても、この手のバグは現場の使い方(同じdirで普通に作業しながら外から叩く)から飛んできます。
4. 壊れた入力を手前で弾く
外から叩かれる前提だと、渡ってくる値を信用しすぎないほうがいい。cc-launch は解決した設定行を分解した直後に、防御的なチェックをまとめて通します。
IFS=$'\t' read -r key dir session remote_name rest <<< "$line"
[ -z "${rest:-}" ] || cc_launch_die "too many columns for project: $project"
[ "$key" = "$project" ] || cc_launch_die "config key mismatch: $key"
dir="$(cc_launch_expand_path "$dir")"
session="${session:-$project}"
remote_name="${remote_name:-$session}"
cc_launch_validate_name "project" "$project"
cc_launch_validate_name "session name" "$session"
cc_launch_validate_name "Remote Control name" "$remote_name"
[ -d "$dir" ] || cc_launch_die "project directory does not exist: $dir"
やっているのは、地味だけど効くチェックの束です。
- 列数チェック:
restに値が残っていたら「列が多すぎる」で止める。タブ区切りの設定に、想定外の5列目が紛れ込んでいないか - キー整合チェック: 引いてきた行の1列目が、渡されたプロジェクト名と本当に一致しているか
- 名前バリデーション: プロジェクト名・セッション名・リモート操作名を、許可文字だけに制限する
- ディレクトリ存在チェック: 起動する前に、そのパスが実在するディレクトリか
名前バリデーションの中身は case 一発です。空文字を弾き、許可した文字集合の「外」の文字が1つでもあれば止める。
cc_launch_validate_name() {
local kind="$1" value="$2"
case "$value" in
'')
cc_launch_die "$kind is empty"
;;
*[!A-Za-z0-9._-]*)
cc_launch_die "$kind contains unsupported characters: $value"
;;
esac
}
*[!A-Za-z0-9._-]* は「英数字・ドット・アンダースコア・ハイフン以外の文字が、どこかに1つでも含まれる」というパターンです。これらの名前は、この先でセッション名やプロセス名として別コマンドに渡っていく値。空白や記号が混じったまま下流に流すより、入口で突き返したほうが事故が少ない(変な文字が混じった名前を、外出先でデバッグしたい人はいない)。外から叩くツールほど、足元のこういう門番が効いてきます。
5. 起動そのものは薄く(zellijは手段)
ここまで来れば、あとの起動は薄いラッパーです。セッションが無ければバックグラウンドで作り、その中でpaneを差し替えて対象を起動する。
# セッションが無ければバックグラウンドで作る
zellij attach --create-background "$session" options --default-cwd "$dir"
# 作ったセッションの中で pane を差し替えて起動する
zellij --session "$session" run --in-place --name claude-remote --cwd "$dir" -- \
"$claude_bin" --remote-control "$remote_name"
attach --create-background でセッションをバックグラウンド生成し、run --in-place で最初のpaneを起動プロセスに差し替える——このあたりは端末マルチプレクサ側の手段の話なので、ここでは深追いしません。この記事の主役は、あくまでその手前の「解決・判定・検証」のほうです。
動作確認
一通り組んだら、実機で次を順に確かめると安心です。
--listで、起動対象が想定どおり並ぶか- 設定に無い名前を渡したとき、
unknown projectで素直に止まるか - 同名リポが複数ある名前を渡したとき、候補を出して止まり、設定登録を促すか
- 同じプロジェクトを手元で普通に開いて作業している最中でも、リモート操作の起動がブロックされないか
- 二度続けて叩いても、セッションが二重に立たないか
特に4番目は、最初にハマった判定バグそのものなので、実際に「編集セッションを開いた状態」を作って試す価値があります。
注意点・Tips
- 判定の鍵はプロセス名ではなく役割:
pgrep -xで名前一致だけを見ると、同名の別用途プロセスに誤判定される。ps -o args=で引数まで見て、目的のプロセスに絞る - 曖昧は成功にしない: フォールバックで候補が複数出たら、黙って選ばずエラーで止め、明示設定を促す。暗黙解決は一意なときだけ
- 下流に渡す値は入口で検証: セッション名やプロセス名になる文字列は、許可文字・列数・存在チェックを resolve 直後にまとめて通す
- 冪等性は現場の使い方で壊れる: 「動いているか」だけでなく「何が動いているか」まで見ないと、テストが通っていても実運用でブロックされる
まとめ
外から叩く起動CLIの難しさは、派手なところにはありません。プロジェクト名を段階フォールバックで住所に変換し、曖昧なら黙って選ばずに止め、下流に渡す値を入口で検証し、二重起動は「何が動いているか」まで見て防ぐ。この地味な4点を積むことで、ようやく1コマンドの起動が誤判定なく冪等になりました。
いちばん効いた学びは、二重起動チェックの直しです。pgrep -x claude で全プロセスを数えていたのを、--remote-control 付きに絞っただけ。たったそれだけで、「編集中だから起動できない」という理不尽が消えました(正直、あれだけ悩んだ割に直しは一行で、拍子抜けするくらいでした)。起動系スクリプトを書くなら、判定の粒度は名前ではなく役割に——それが、今回いちばん持ち帰りたかった一行です。



