TL;DR
- Claude Code は
settings.jsonのstatusLineフィールドに登録した 任意のコマンド を stdin 経由で叩き、その標準出力を画面下に表示してくれる - 入力は JSON 1 行。
cwd/model/context_window/rate_limitsなどが全部入っている - bash +
jqだけで、ターミナル風プロンプト+モデル名+ctx 使用量+5h/7d レート制限 まで一望できる statusLine が組める - デバッグ用に生 JSON をダンプする env フラグを仕込んでおくと改造が一気に楽になる
この記事では、筆者が実際に使っている 250 行ほどの bash スクリプトを題材に、作り方とハマりどころを解説する。
1. statusLine とは
Claude Code (CLI) にはプロンプト下部に任意の 1〜数行を描画する statusLine 機能がある。ターミナルの PS1 と似た発想で、
- 今どのモデルで動いているか
- コンテキストウィンドウをどれくらい食っているか
- 5 時間レート / 週次レートの残量
- カレントディレクトリ / git ブランチ
あたりを常に視界に入れておくと、長時間の作業で「気づいたら Opus が 1M ctx の 80% を舐めていた」みたいな事故が減る。
設定は ~/.claude/settings.json にこう書くだけ。
{
"statusLine": {
"type": "command",
"command": "bash ~/.claude/statusline-command.sh",
"padding": 0
}
}
type: "command" だと Claude Code はこのコマンドを起動し、stdin に JSON を流し込み、stdout に書かれたテキストをそのまま描画する。ANSI エスケープもそのまま効く。
2. 入力 JSON の中身
まずは何が来るのかを知らないと作りようがない。以下を仕込んでおくと捗る。
# Debug dump: set CLAUDE_STATUSLINE_DEBUG=1 to capture raw JSON for inspection
if [ "${CLAUDE_STATUSLINE_DEBUG:-0}" = "1" ]; then
echo "$input" > /tmp/claude-statusline-debug.json
fi
CLAUDE_STATUSLINE_DEBUG=1 claude で起動して /tmp/claude-statusline-debug.json を眺めれば、だいたい以下のようなキーが見える(抜粋)。
{
"cwd": "/home/jfk/works/foo",
"model": {
"id": "claude-opus-4-6[1m]",
"display_name": "Opus 4.6 (1M context)"
},
"context_window": {
"context_window_size": 1000000,
"used_percentage": 12.3,
"current_usage": {
"input_tokens": 1234,
"output_tokens": 567,
"cache_read_input_tokens": 8901
}
},
"rate_limits": {
"five_hour": { "used_percentage": 42.5, "resets_at": 1712500000 },
"seven_day": { "used_percentage": 18.2, "resets_at": 1713000000 }
}
}
⚠️ フィールドは Claude Code のバージョンによって変わる可能性がある。debug dump を一度覗いてから組むのが安全。
3. 設計方針
-
jq は 1 回だけ呼ぶ:statusLine は描画のたびに叩かれるので、
jqを 10 連発するとカーソル移動がもたつく。欲しいフィールドを TSV で一括取得し、bash のreadで変数に流し込む -
display_nameを尊重する:モデル表示名はサーバから降ってくるdisplay_nameをそのまま使うのが正。ただし空や 1 ワードしか無い場合のフォールバックとして、model.idから "Claude Opus 4.6 (1M)" を合成する経路も用意する -
rate_limitsは存在しないことがある:claude.ai サブスクライバ以外だと降ってこないので、欠損前提でガードする -
cwd の省略:長いパスは
~/works/.../repo/subのように途中を省略する -
git 情報は
--no-optional-locksで:裏で別の git プロセスが走ってると statusLine がブロックされるので--no-optional-locks必須
4. 実装を分解して読む
以下、冒頭で紹介したスクリプトを段階的に切り出す。
4.1 JSON を 1 回で全部バラす
input=$(cat)
IFS=$'\t' read -r cwd model_id model_display \
ctx_window used_pct \
cur_input cur_output cur_cache_r \
five_pct five_rst week_pct week_rst \
< <(echo "$input" | jq -r '[
.cwd,
(.model.id // ""),
(.model.display_name // ""),
(.context_window.context_window_size // ""),
(.context_window.used_percentage // ""),
(.context_window.current_usage.input_tokens // ""),
(.context_window.current_usage.output_tokens // ""),
(.context_window.current_usage.cache_read_input_tokens // ""),
(.rate_limits.five_hour.used_percentage // ""),
(.rate_limits.five_hour.resets_at // ""),
(.rate_limits.seven_day.used_percentage // ""),
(.rate_limits.seven_day.resets_at // "")
] | @tsv')
-
// ""でフィールドが無い場合を空文字に正規化 - 配列 →
@tsvで タブ区切り →IFS=$'\t' readで一発分解
この書き方は、jq プロセス 1 つ・read 1 つ で済むので統計的に最速クラス。
4.2 モデル名の決定
サーバ側の display_name が正っぽい場合は尊重しつつ、空/1ワードしかない時だけ model_id から合成する:
# "claude-opus-4-6[1m]" → "Claude Opus 4.6 (1M)"
canonical_name=""
if [ -n "$model_id" ]; then
ctx_variant=""
if echo "$model_id" | grep -qiE '[\[_-]1m[\]_-]?$|1m$'; then
ctx_variant=" (1M)"
elif echo "$model_id" | grep -qiE '[\[_-]200k[\]_-]?$|200k$'; then
ctx_variant=" (200K)"
fi
stripped=$(echo "$model_id" | sed 's/^claude-//i; s/[\[_-]\?[0-9]*[mk][\]]*$//i; s/-[0-9]\{8,\}$//')
canonical_name=$(echo "$stripped" | awk '{
n = split($0, parts, "-")
out = ""
for (i=1; i<=n; i++) {
w = parts[i]
if (w ~ /^[a-zA-Z]/) { w = toupper(substr(w,1,1)) substr(w,2) }
if (w ~ /^[0-9]/ && out != "") { out = out "." w }
else { out = (out == "") ? w : out " " w }
}
print out
}')
canonical_name="Claude ${canonical_name}${ctx_variant}"
fi
chosen_name="$model_display"
if [ -z "$chosen_name" ] || ! echo "$chosen_name" | grep -q ' '; then
[ -n "$canonical_name" ] && chosen_name="$canonical_name"
fi
if ! echo "$chosen_name" | grep -qi '^claude '; then
chosen_name="Claude ${chosen_name}"
fi
ポイントは "Opus 4.6" の "4.6" 部分を . で繋ぐこと。claude-opus-4-6 を愚直に - で split して capitalize すると "Opus 4 6" になって気持ち悪いので、数字が連続する場合だけ . で結合する小細工を入れている。
4.3 コンテキスト使用量
fmt_k() {
local n=$1
if [ "$n" -ge 1000 ]; then
printf "%.0fk" "$(awk "BEGIN{printf \"%.1f\", $n/1000}")"
else
printf "%d" "$n"
fi
}
ctx_part=""
sess_part=""
if [ -n "$used_pct" ] && [ -n "$ctx_window" ]; then
used_tokens=$(awk "BEGIN{printf \"%.0f\", $ctx_window * $used_pct / 100}")
used_k=$(fmt_k "$used_tokens")
total_k=$(fmt_k "$ctx_window")
pct_int=$(printf '%.0f' "$used_pct")
ctx_part="📦 ctx:${used_k}/${total_k}(${pct_int}%)"
cur_tokens=$(( ${cur_input:-0} + ${cur_output:-0} + ${cur_cache_r:-0} ))
if [ "$cur_tokens" -gt 0 ]; then
sess_part="💬 sess:$(fmt_k "$cur_tokens")"
fi
fi
-
ctxは累積(会話全体での使用量) -
sessは直近 1 ターン(今まさに投げた分)
この 2 つを分けて出すと「もう ctx 詰まってるのか、今回の入力が重いのか」が一発で分かる。
4.4 レート制限(5h / 7d)
fmt_reset_time() { # "HH:MM JST"
local epoch=$1
[ -z "$epoch" ] && return
TZ=Asia/Tokyo date -d "@$epoch" "+%H:%M JST" 2>/dev/null || \
TZ=Asia/Tokyo date -r "$epoch" "+%H:%M JST" 2>/dev/null
}
fmt_reset_dow_time() { # "月 HH:MM JST"
local epoch=$1
[ -z "$epoch" ] && return
local raw_day
raw_day=$(TZ=Asia/Tokyo date -d "@$epoch" "+%a" 2>/dev/null || \
TZ=Asia/Tokyo date -r "$epoch" "+%a" 2>/dev/null)
local raw_time
raw_time=$(TZ=Asia/Tokyo date -d "@$epoch" "+%H:%M" 2>/dev/null || \
TZ=Asia/Tokyo date -r "$epoch" "+%H:%M" 2>/dev/null)
local jp_day
case "$raw_day" in
Mon) jp_day="月" ;; Tue) jp_day="火" ;; Wed) jp_day="水" ;;
Thu) jp_day="木" ;; Fri) jp_day="金" ;; Sat) jp_day="土" ;;
Sun) jp_day="日" ;; *) jp_day="$raw_day" ;;
esac
printf "%s %s JST" "$jp_day" "$raw_time"
}
date -d (GNU) と date -r (BSD / macOS) の両方にフォールバックしている点がポイント。macOS でそのまま使える。
週次リセットは「木 15:00 JST」のように 曜日+時刻の方が脳に優しいので、英語の %a を日本語 1 文字にマップしている。
4.5 cwd とブランチ名の短縮
shorten_pwd() {
local dir="$1"
local home="$HOME"
dir="${dir/#$home/\~}"
if [ ${#dir} -gt 40 ]; then
local parts
IFS='/' read -ra parts <<< "$dir"
local n=${#parts[@]}
if [ $n -gt 4 ]; then
local head="${parts[0]}/${parts[1]}"
local tail="${parts[$((n-2))]}/${parts[$((n-1))]}"
dir="${head}/.../${tail}"
fi
fi
echo "$dir"
}
shorten_branch() {
local b="$1"
if [ ${#b} -gt 50 ]; then
echo "${b:0:47}..."
else
echo "$b"
fi
}
長大な feature ブランチ名(feature/really-long-name-that-nobody-reads...)は 47 文字で切って ... を足す。
4.6 描画
line1=$(printf "\033[01;32m%s@%s\033[00m%s" \
"$(whoami)" "$(hostname -s)" "$short_branch")
line2_parts=("🤖 ${model_part}")
[ -n "$ctx_part" ] && line2_parts+=("$ctx_part")
[ -n "$sess_part" ] && line2_parts+=("$sess_part")
line2=""
for p in "${line2_parts[@]}"; do
if [ -z "$line2" ]; then line2="$p"
else line2="${line2} ${p}"
fi
done
if [ -n "$rate_part" ]; then
line3="${rate_part# }"
printf "%s\n%s\n%s" "$line1" "$line2" "$line3"
else
printf "%s\n%s" "$line1" "$line2"
fi
- 1 行目:
user@host (branch)を太字グリーンで(.bashrcの PS1 風) - 2 行目: モデル / ctx / sess
- 3 行目: 5h / 7d レート制限(ある場合のみ)
printf の末尾に \n を付けない(Claude Code 側が勝手に改行するため)のが地味なコツ。
5. 完成品(全文)
~/.claude/statusline-command.sh として保存し、chmod +x すれば OK。
#!/bin/bash
# Claude Code status line — derived from ~/.bashrc PS1
input=$(cat)
# Debug dump: set CLAUDE_STATUSLINE_DEBUG=1 to capture raw JSON for inspection
if [ "${CLAUDE_STATUSLINE_DEBUG:-0}" = "1" ]; then
echo "$input" > /tmp/claude-statusline-debug.json
fi
# --- Parse all fields in one jq call ---
IFS=$'\t' read -r cwd model_id model_display \
ctx_window used_pct \
cur_input cur_output cur_cache_r \
five_pct five_rst week_pct week_rst \
< <(echo "$input" | jq -r '[
.cwd,
(.model.id // ""),
(.model.display_name // ""),
(.context_window.context_window_size // ""),
(.context_window.used_percentage // ""),
(.context_window.current_usage.input_tokens // ""),
(.context_window.current_usage.output_tokens // ""),
(.context_window.current_usage.cache_read_input_tokens // ""),
(.rate_limits.five_hour.used_percentage // ""),
(.rate_limits.five_hour.resets_at // ""),
(.rate_limits.seven_day.used_percentage // ""),
(.rate_limits.seven_day.resets_at // "")
] | @tsv')
# --- Model display ---
canonical_name=""
if [ -n "$model_id" ]; then
ctx_variant=""
if echo "$model_id" | grep -qiE '[\[_-]1m[\]_-]?$|1m$'; then
ctx_variant=" (1M)"
elif echo "$model_id" | grep -qiE '[\[_-]200k[\]_-]?$|200k$'; then
ctx_variant=" (200K)"
elif echo "$model_id" | grep -qiE '[\[_-]128k[\]_-]?$|128k$'; then
ctx_variant=" (128K)"
fi
stripped=$(echo "$model_id" | sed 's/^claude-//i; s/[\[_-]\?[0-9]*[mk][\]]*$//i; s/-[0-9]\{8,\}$//')
canonical_name=$(echo "$stripped" | awk '{
n = split($0, parts, "-")
out = ""
for (i=1; i<=n; i++) {
w = parts[i]
if (w ~ /^[a-zA-Z]/) { w = toupper(substr(w,1,1)) substr(w,2) }
if (w ~ /^[0-9]/ && out != "") { out = out "." w }
else { out = (out == "") ? w : out " " w }
}
print out
}')
canonical_name="Claude ${canonical_name}${ctx_variant}"
fi
chosen_name="$model_display"
if [ -z "$chosen_name" ] || ! echo "$chosen_name" | grep -q ' '; then
[ -n "$canonical_name" ] && chosen_name="$canonical_name"
fi
if ! echo "$chosen_name" | grep -qi '^claude '; then
chosen_name="Claude ${chosen_name}"
fi
model_part="${chosen_name}"
# --- Git branch (skip optional locks to avoid blocking) ---
git_branch=$(git -C "$cwd" --no-optional-locks branch --show-current 2>/dev/null)
# --- Context window ---
fmt_k() {
local n=$1
if [ "$n" -ge 1000 ]; then
printf "%.0fk" "$(awk "BEGIN{printf \"%.1f\", $n/1000}")"
else
printf "%d" "$n"
fi
}
ctx_part=""
sess_part=""
if [ -n "$used_pct" ] && [ -n "$ctx_window" ]; then
used_tokens=$(awk "BEGIN{printf \"%.0f\", $ctx_window * $used_pct / 100}")
used_k=$(fmt_k "$used_tokens")
total_k=$(fmt_k "$ctx_window")
pct_int=$(printf '%.0f' "$used_pct")
ctx_part="📦 ctx:${used_k}/${total_k}(${pct_int}%)"
cur_tokens=$(( ${cur_input:-0} + ${cur_output:-0} + ${cur_cache_r:-0} ))
if [ "$cur_tokens" -gt 0 ]; then
sess_part="💬 sess:$(fmt_k "$cur_tokens")"
fi
fi
# --- Rate limits (only present for claude.ai subscribers) ---
fmt_reset_time() {
local epoch=$1
[ -z "$epoch" ] && return
TZ=Asia/Tokyo date -d "@$epoch" "+%H:%M JST" 2>/dev/null || \
TZ=Asia/Tokyo date -r "$epoch" "+%H:%M JST" 2>/dev/null
}
fmt_reset_dow_time() {
local epoch=$1
[ -z "$epoch" ] && return
local raw_day raw_time
raw_day=$(TZ=Asia/Tokyo date -d "@$epoch" "+%a" 2>/dev/null || \
TZ=Asia/Tokyo date -r "$epoch" "+%a" 2>/dev/null)
raw_time=$(TZ=Asia/Tokyo date -d "@$epoch" "+%H:%M" 2>/dev/null || \
TZ=Asia/Tokyo date -r "$epoch" "+%H:%M" 2>/dev/null)
local jp_day
case "$raw_day" in
Mon) jp_day="月" ;; Tue) jp_day="火" ;; Wed) jp_day="水" ;;
Thu) jp_day="木" ;; Fri) jp_day="金" ;; Sat) jp_day="土" ;;
Sun) jp_day="日" ;; *) jp_day="$raw_day" ;;
esac
printf "%s %s JST" "$jp_day" "$raw_time"
}
rate_part=""
if [ -n "$five_pct" ] || [ -n "$week_pct" ]; then
rate_items=()
if [ -n "$five_pct" ]; then
five_pct_int=$(printf '%.0f' "$five_pct")
if [ "$five_pct_int" -gt 100 ] 2>/dev/null; then
part="⚡ 5h:100%+"
else
part="⚡ 5h:${five_pct_int}%"
fi
rst=$(fmt_reset_time "$five_rst")
[ -n "$rst" ] && part="${part} → ${rst}"
rate_items+=("$part")
fi
if [ -n "$week_pct" ]; then
week_pct_int=$(printf '%.0f' "$week_pct")
if [ "$week_pct_int" -gt 100 ] 2>/dev/null; then
part="📅 7d:100%+"
else
part="📅 7d:${week_pct_int}%"
fi
if [ -n "$week_rst" ] && [ "$week_rst" -gt 0 ] 2>/dev/null; then
rst=$(fmt_reset_dow_time "$week_rst")
[ -n "$rst" ] && part="${part} → ${rst}"
fi
rate_items+=("$part")
fi
joined=""
for item in "${rate_items[@]}"; do
if [ -z "$joined" ]; then joined="$item"
else joined="${joined} ${item}"
fi
done
rate_part=" ${joined}"
fi
# --- Shorten branch ---
shorten_branch() {
local b="$1"
if [ ${#b} -gt 50 ]; then echo "${b:0:47}..."
else echo "$b"
fi
}
short_branch=""
[ -n "$git_branch" ] && short_branch=" ($(shorten_branch "$git_branch"))"
# --- Assemble ---
line1=$(printf "\033[01;32m%s@%s\033[00m%s" \
"$(whoami)" "$(hostname -s)" "$short_branch")
line2_parts=("🤖 ${model_part}")
[ -n "$ctx_part" ] && line2_parts+=("$ctx_part")
[ -n "$sess_part" ] && line2_parts+=("$sess_part")
line2=""
for p in "${line2_parts[@]}"; do
if [ -z "$line2" ]; then line2="$p"
else line2="${line2} ${p}"
fi
done
if [ -n "$rate_part" ]; then
line3="${rate_part# }"
printf "%s\n%s\n%s" "$line1" "$line2" "$line3"
else
printf "%s\n%s" "$line1" "$line2"
fi
そして ~/.claude/settings.json:
{
"statusLine": {
"type": "command",
"command": "bash ~/.claude/statusline-command.sh",
"padding": 0
}
}
6. ハマりどころ
| 症状 | 原因 | 対策 |
|---|---|---|
| statusLine が一瞬フリーズする |
git status 系が裏プロセスとロック競合 |
git --no-optional-locks を使う |
| macOS で時刻が出ない |
date -d は GNU 拡張 |
date -r にフォールバック |
display_name が "Opus" だけで寂しい |
サーバが 1 ワードだけ返す時がある |
canonical_name で model.id から合成 |
rate_limits が null
|
claude.ai サブスクライバ以外だと降ってこない | 欠損前提で [ -n "$..." ] ガード |
| 速度が重い |
jq を複数回呼んでいる |
1 回の jq で配列 → TSV → read で分解 |
printf の末尾が崩れる |
\n を付けすぎ |
最後の行末には \n を付けない |
7. 生成プロンプト
Claude Codeのstatus lineを以下の仕様で設定してください。~/.claude/statusline-command.sh
にbashスクリプトを作成し、~/.claude/settings.json の statusLine をそのスクリプトを呼び出すように設定してください。
以下の変数は環境に合わせて設定してください。
{UserName}@{HostName}
入力: Claude Codeはstdin経由でJSONを渡します。以下のフィールドを使います:
- model.display_name — モデル名(例: "Claude Opus 4.6")
- model.id — モデルID("[1m]" 判定用、1M contextかどうか)
- workspace.current_dir または cwd — 現在のディレクトリ
- session_id — セッションID(~/.claude/projects/ 以下のJSONLログ検索用)
- transcript_path — 現在の会話のトランスクリプトパス(コンテキスト使用量算出用)
出力: 2〜3行構成
1行目(アイデンティティ): シェルのPS1を再現
{UserName}@{HostName}:<cwd_basename in bold blue><git_branch in red>
- $VIRTUAL_ENV がセットされていれば (venv_name) をプレフィックス
- 固定文字列 jfk@lenovo:
- カレントディレクトリのbasenameを太字青 (\033[01;34m)
- gitブランチを赤 (\033[31m) で (branch) 形式。JSON入力の cwd を基準に git -C <cwd> branch --show-current で取得
- 末尾の $ は付けない
2行目(モデル + コンテキスト + セッション):
🤖 <model_display_name> (1M context) 📦 ctx:<used>k/<total>k(<pct>%) 💬 sess:<tokens>k
- (1M context) はモデルIDに [1m] が含まれる場合のみ
- ctx はトランスクリプトの最新メッセージから現在のコンテキスト使用量を算出。1Mコンテキストなら分母1000k、通常は200k
- sess は同一セッション内で消費した累積トークン数(input + output + cache含む)をk単位で
3行目(オプション、レート制限): ~/.claude/ 配下にレート制限情報があれば表示、なければ省略
色の扱い: ANSIエスケープを使用。背景色は使わず、前景色のみ。
8. まとめ
- stdin に JSON、stdout にテキスト という素直なプロトコルなので、シェル芸だけで実用的な statusLine が組める
-
CLAUDE_STATUSLINE_DEBUG=1で生 JSON を/tmpに落とす仕込みは、API 変更の追従にも効く - モデル名の整形・レート制限の JST 表示・累積 ctx と今ターンの sess を分離、といった "ちゃんと見える化" が長時間セッションを救う
自分の PS1 を育てるのと同じ感覚で、Claude Code の statusLine も育てていこう。
