SLOPSHOPPER

test-runner

/testrun でテストをボタン 1 つで走らせ、結果を信号と直近 20 回の履歴でペインに SVG で表示する。Claude が走らせたテストも拾い、落ちている間は入力欄の上に出し、落ちたままのコミットを知らせる

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · test-runner
│ ┃ テスト ✕ › fix the failing auth test and add an audit log call │ ┃ 失敗 / 所要 0.0 秒 / 終了コード ─ │ ┃ Claude が走らせた結果 ⏺ Read(src/auth.ts) │ ┃ bun test ⎿ Read 6 lines │ ┃ 直近 ■ (● 通過 / ■ 失敗 / ○ 中断) ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ╭──────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │ │ ⎿ 3 pass, 1 fail │ ┃ │ 落ちています │ │ ┃ │ bun test ・ 0.0 秒 ・ Claude が実行 │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ [ この失敗を直して ][ もう一度走らせる ] │ │ ┃ │ │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰──────────────────────────────────────────╯ │ ┃ › /testrun │ ┃ src/auth.test.ts: │ ┃ ✓ refreshes expired token │ ┃ ✓ rejects a bad signature │ ┃ ✓ issues a new token │ ┃ ✗ revokes on logout │ ┃ error: expected 401, got 200 │ ┃ │ ┃ 3 pass │ ┃ 1 fail │ テストが落ちたままです: bun test (/testrun で見る) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
テストが落ちたままです: bun test (/testrun で見る)
Pane · テスト
失敗 / 所要 0.0 秒 / 終了コード ─ Claude が走らせた結果 bun test 直近 ■ (● 通過 / ■ 失敗 / ○ 中断) ╭──────────────────────────────────────────────────────────╮ │ │ │ 落ちています │ │ bun test ・ 0.0 秒 ・ Claude が実行 │ │ [ この失敗を直して ][ もう一度走らせる ] │ │ │ ╰──────────────────────────────────────────────────────────╯ src/auth.test.ts: ✓ refreshes expired token ✓ rejects a bad signature ✓ issues a new token ✗ revokes on logout error: expected 401, got 200 3 pass 1 fail
README

claude-code-mods

English

Claude Code の関数フック mod を集めたリポジトリです。 ペイン・入力欄の上の帯・枠・トーストを使い、VS Code 拡張でもターミナルでも見た目が崩れないように作っています。 VS Code では SVG のグラフやアニメ(SMIL / CSS)も描けます。

Anthropic とは関係のない、個人が作った非公式の mod です。

入っている mod

mod呼び方何をするか
mod-hub/mod画面のある mod(ペインや帯を出す物)をアイコン付きで並べる。名前・説明で絞る、固定して上に置く、カテゴリー別(開発・状況・道具・遊び)・名前順・最近開いた順。見出しに季節の小さな飾り
cc-dash/dashセッションの計器盤。経過・ターン・ツール・エラーのタイル、ターンの所要時間の棒グラフ、ツールのタイムライン、使用量のリング型ゲージ。「自分の並び」で出す枠と順を自分で決められる
work-bar入力欄の上に自動で出る作業中は経過時間・今のツール・流れる光の帯。待ち中は前のターンのまとめと定型文のボタン
aqua-pet/aquarium作業に反応する水槽。魚は 1 匹ずつターンで 稚魚 → 若魚 → 成魚 → 大物 と育ち、節目で新しい魚(全 10 種、後ほど珍しい)が来る。エラーで赤く震える。えさ・名前・掃除・魚の一覧のボタン。日本時間の時刻で水の色と光が(夕は橙で沈む日、夜は月と星)、季節で飾り(花びら・泡・落ち葉・雪)が変わる。turn-gacha で集めたカードでかざり(水草・石・流木・貝・つぼ・城・宝箱・沈没船)が手に入り、底の 5 つの置き場に置ける(城と沈没船の周りを魚が回る)。記録は毎日控えを取り、ペインから戻せる
project-board/projectsフォルダに並んだ引き継ぎメモ(*.md)の鮮度グラフと一覧・詳細
git-pane/git今のフォルダの git の様子。ブランチ・変更行数の差分バー・直近コミット。git には書き込まない
token-meter/tokensターンごとのトークンの積み上げ棒とキャッシュの当たり率
focus-timer/focus-timerポモドーロ。減っていくリングと今日の本数
session-recap/session-recapセッションのふりかえり。ターンの帯のタイムラインと、押した時だけ作る要約
edit-log/editsこのセッションで Claude が Edit / Write などの編集ツールで変えたファイルの時系列。押すと git の差分(HEAD と比べる)と「レビューして」
todo-board/todos今のフォルダの TODO / FIXME / HACK / XXX の件数・推移・一覧。git の外はフォルダを歩いて読む。種類のボタンで絞り、押すと「片付けて」
test-runner/testrun設定したテストのコマンドをボタンで走らせる。信号と直近 20 回の結果、失敗したら「直して」。Claude が走らせたテストも拾い、落ちている間は入力欄の上に 1 行出す。落ちたままコミットしようとしたらトーストで知らせる
prompt-book/promptsよく使う依頼文を保存して、ボタンで入力欄へ。タグ・使った回数・並び替え
live-commentary/jikkyoClaude の動きをスポーツ実況風にステータス行へ。口調は熱血・冷静・関西
turn-gacha/gachaターンが終わるたびにガチャ。N / R / SR / SSR のカード 60 種と図鑑、集まり具合の実績 6 個。出たカードは dev-rpg で装備にできる。daily-quest のお題をこなすと、おまけで引ける。記録は毎日控えを取り、「控えから戻す」で戻せる
dev-rpg/rpg作業すると経験値がたまる RPG。レベル・称号・実績 22 個。ターンを終えるとたまる「魔物の気配」で魔物 8 種・ボス 3 体とボタンで戦う。ボスは時々湧き、湧いたボスは気配を使わずに挑めて経験値 1.5 倍。戦いで減った HP は 15 分休むか新しいセッションで戻る。レベル・実績・勝利・ボスで入る SP を能力(力・体力・知恵・敏捷)に振り、スキル 10 種を覚える。turn-gacha のカードを武器・防具・お守りに装備して能力が上がる。daily-quest のお題をこなすと経験値が入る。ターンで潜るダンジョン(8 ターンで 1 階、10 階ごとに番人、5 階ごとに休み場)で一番深い階を目指し、勝った分は「帰る」で持ち帰る。記録は毎日控えを取れる
code-omikuji/omikuji今日のコード運勢。筒を振ると棒の番号が回って止まり、和紙のおみくじが開く。文は 238 通りと、その季節の日だけ出る一言
agent-watch/agent-watch動いているサブエージェントの帯のタイムライン(経過・ツール回数・状態)。ボタンでその会話へ切り替え、メインへ戻る
canvas-pane/canvasClaude が呼べるツール show を足す。「売上をグラフにしてペインに出して」と頼むと、棒・折れ線・円・表・Markdown・SVG をペインに描く。直近 20 枚をめくれる
rule-switch/rules「日本語で答える」「commit しない」などの約束ごとをボタンでオン/オフし、システムプロンプトに入れる。自分の約束も足せる
wait-games/games長いターンの間に遊ぶ 2048・マインスイーパー・15 パズル・ライツアウト・数独(4x4 / 9x9)・神経衰弱。ゲームごとに勝ち数・最短手数・最速を残す。ターンが終わったら知らせる。記録は毎日控えを取り、「控えから戻す」で戻せる
spec-board/specsSpecStatus の確認待ち・途中の仕様書と次のおすすめ。押すと詳細(読むだけ)
ci-pane/ci今のブランチの PR(状態・レビュー・チェックの丸)と GitHub Actions の結果。gh を使う。何かを起こす操作は無い
port-watch/ports待ち受け中の TCP ポートと、セッション中に増えた物の印。URL を写すボタンと、開発用の実行環境だけ 2 回押しで止めるボタン
pre-check/precheckコミット前の確かめ。変更の要約・秘密情報らしい文字列(足した行だけ、伏せて表示)・衝突の印・1MB 超のファイル・テスト(ボタンで)
face-band/face で出す / 隠す待ち・考え中・ツール実行中・答え終わり・エラーで変わる Claude の顔。日本時間の時間帯と季節で小物が変わる(夜は眠そうにナイトキャップ、夏は麦わら帽子、冬はマフラーなど)。入れただけでは出ない(最初はオフ)。work-bar と並べて出る
done-chime/chime長いターン(既定 30 秒以上)が終わったら音を鳴らす。音の種類・しきい値・音量・試し鳴らし
turn-haiku/haikuターンが終わるたびに、やった事を五七五の 1 句にして縦書きの短冊に。季節の季語の候補を添えて詠む。過去の句の一覧
session-timeline/timelineこのセッションのターンとツール呼び出しを横の帯の年表で。ツールの種類で色分け、失敗は赤い印、サブエージェントは細い帯、1 分以上のターンは黄の縁
file-map/filemapこのセッションで読んだ・書いたファイルを、フォルダごとの四角(ツリーマップ)で。大きいほど多く触った所、橙は書いた物、青は読んだだけ
toast-inbox/inboxほかの mod が出したトーストを 100 件まで残す受け箱(設定でステータス行も)。未読の数・mod ごとの件数・mod で絞る・全部既読・空にする。通知の行を押すと、それを出した mod のペインを開く
usage-history/history日をまたいだ使い方の記録。日ごとのターン数か出力トークンの棒(7 日 / 30 日)、合計と 1 日平均、曜日と時間帯のヒートマップ。直近 7 日の数をほかの mod(week-card)にも見せる。記録は毎日控えを取れる
fail-log/errorsツールが失敗した記録を 200 件まで残す。似た失敗をまとめた「よく落ちる物」の棒、最近の一覧、今日・通算の数。サブエージェントの分は紫で分ける
daily-quest/quest日替わりのお題 3 つ(ターン・ファイルを直す・テスト・コミットなど 8 種から)。進みの棒・ごほうび・連続日数・直近 7 日。ごほうびは dev-rpg が経験値、turn-gacha がおまけの抽選で受け取る。記録は毎日控えを取り、「控えから戻す」で戻せる
trophy-shelf/trophiesdev-rpg・turn-gacha・aqua-pet・wait-games・daily-quest の記録を 1 枚の棚にまとめ、トロフィーを初めて取った日付つきで集める。ダンジョンの深さのトロフィー 3 つ(番人越え・地下三十階・百階の主)も
mod-check/mod-checkmod がちゃんと動いているかを 1 回で確かめる。入っている・コマンドあり・予備名・見つからないの数と、mod ごとの判定(本来の名前か予備名か、本来の名前を使っている相手)、画面・テーマ・Claude Code の版をペインに出し、同じ結果を 1 行 1 mod の貼れる文で返す。隣の mod の版がセッション中に変わったら、帯とトーストで開き直しを頼む。「点検表」タブで実画面の見え方に よい・だめ・まだ を付け、結果を貼る文にする
compact-cue入力欄の上に自動で出る・/cueコンテキストの使用量が多くなった時、テストが通った・コミットした・引き継ぎメモを書いた直後なら「今が /compact のよい時」、編集の途中なら「区切りまで待って」と帯とトーストで知らせる。ペインは使用量のリングと作業の様子。知らせるだけで compact はしない
ship-guard常時・/guardClaude が git push・gh release・gh repo create・npm publish を打つ時、直前に AskUserQuestion で公開してよいと答えていなければ、その場で「実行しますか?」と聞き、断られたら止める。ペインは記録とオン/オフ。git push の前は、送るコミットの作者が noreply か、足した行に個人のメール・ユーザー名入りのパスが無いかも確かめ、引っかかったら確認済みでも聞き直す
rule-watch入力欄の上に自動で出る・/rulewatchClaude の返事が約束ごとから外れた時に帯で知らせる。英語で答えた・AskUserQuestion を使わずに「〜しますか?」で終わった・コミットの後に引き継ぎメモや開発ログを書いていない・開発ログを書いたのに「📝」の 1 行が無い・締めの言葉なのに開発ログが無い、の 6 つ。ペインは数と記録、「Claude に伝える」で入力欄に一言入れる
enc-guard常時・/enc文字コード・改行の罠を Claude が踏んだ時に、直し方を Claude に伝える(設定で止めるにもできる)。-Encoding の無い Set-Content / Add-Content、日本語入りで BOM の無い .ps1、ASCII 以外の字が入った .bat / .cmd、Windows で encoding= / newline= の無い Python の open
release-draft/release前のタグから今までのコミットと、版が上がった plugin.json / package.json を集めて、CHANGELOG の下書き(Added / Changed / Fixed)と次の版の候補を出す。「これで CHANGELOG を書いて」で Claude に頼む。git は読むだけ
reply-frame常時返答をオレンジ、自分の発言を青の角丸の枠で囲む
danger-frame常時危ないシェルのコマンドの行を赤い枠で囲む
long-turn-toast常時長くかかったターン(既定 120 秒超)をトーストで知らせる
usage-pane/usage-pane使用量の JSON を 3 本のバーで表示
devlog-pane/devlogフォルダにある今日の開発ログ(YYYY-MM-DD.md)を表示
run-guard常時・/runguardClaude が確かめのために窓のあるアプリを起動する・音を鳴らす・claude -p を隔離なしで走らせるのを、実行の前に止めて直し方(-WindowStyle Hidden・--headless・一時の設定フォルダ)を返す。組ごとにオン/オフでき、記録も残る
week-card/week1 週間の使い方と、お題・ガチャの図鑑・冒険の書(レベルとダンジョン)・水槽の増え方を 1 枚のカードにまとめ、週報に貼る文(Markdown)を入力欄に入れる

reply-frame は takosasi-dev/reply-frame でも公開しています。ここに入っているのは、自分の発言の枠を足した新しい版です。

画面

VS Code のペインに描かれる SVG の部分です(架空のデータ。ボタンなどは省いています)。node tools/shots.mts で作り直せます(--light で明るいテーマ)。

<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub.png" width="250" alt="mod-hub"></td> <td align="center" valign="top"><b>cc-dash</b><br><img src="docs/images/cc-dash.png" width="250" alt="cc-dash"></td> <td align="center" valign="top"><b>work-bar</b><br><img src="docs/images/work-bar.png" width="250" alt="work-bar"></td> </tr> <tr> <td align="center" valign="top"><b>face-band</b><br><img src="docs/images/face-band.png" width="250" alt="face-band"></td> <td align="center" valign="top"><b>aqua-pet</b><br><img src="docs/images/aqua-pet.png" width="250" alt="aqua-pet"></td> <td align="center" valign="top"><b>project-board</b><br><img src="docs/images/project-board.png" width="250" alt="project-board"></td> </tr> <tr> <td align="center" valign="top"><b>git-pane</b><br><img src="docs/images/git-pane.png" width="250" alt="git-pane"></td> <td align="center" valign="top"><b>token-meter</b><br><img src="docs/images/token-meter.png" width="250" alt="token-meter"></td> <td align="center" valign="top"><b>focus-timer</b><br><img src="docs/images/focus-timer.png" width="250" alt="focus-timer"></td> </tr> <tr> <td align="center" valign="top"><b>session-recap</b><br><img src="docs/images/session-recap.png" width="250" alt="session-recap"></td> <td align="center" valign="top"><b>edit-log</b><br><img src="docs/images/edit-log.png" width="250" alt="edit-log"></td> <td align="center" valign="top"><b>todo-board</b><br><img src="docs/images/todo-board.png" width="250" alt="todo-board"></td> </tr> <tr> <td align="center" valign="top"><b>test-runner</b><br><img src="docs/images/test-runner.png" width="250" alt="test-runner"></td> <td align="center" valign="top"><b>pre-check</b><br><img src="docs/images/pre-check.png" width="250" alt="pre-check"></td> <td align="center" valign="top"><b>ci-pane</b><br><img src="docs/images/ci-pane.png" width="250" alt="ci-pane"></td> </tr> <tr> <td align="center" valign="top"><b>spec-board</b><br><img src="docs/images/spec-board.png" width="250" alt="spec-board"></td> <td align="center" valign="top"><b>port-watch</b><br><img src="docs/images/port-watch.png" width="250" alt="port-watch"></td> <td align="center" valign="top"><b>prompt-book</b><br><img src="docs/images/prompt-book.png" width="250" alt="prompt-book"></td> </tr> <tr> <td align="center" valign="top"><b>agent-watch</b><br><img src="docs/images/agent-watch.png" width="250" alt="agent-watch"></td> <td align="center" valign="top"><b>canvas-pane</b><br><img src="docs/images/canvas-pane.png" width="250" alt="canvas-pane"></td> <td align="center" valign="top"><b>rule-switch</b><br><img src="docs/images/rule-switch.png" width="250" alt="rule-switch"></td> </tr> <tr> <td align="center" valign="top"><b>live-commentary</b><br><img src="docs/images/live-commentary.png" width="250" alt="live-commentary"></td> <td align="center" valign="top"><b>turn-gacha</b><br><img src="docs/images/turn-gacha.png" width="250" alt="turn-gacha"></td> <td align="center" valign="top"><b>dev-rpg</b><br><img src="docs/images/dev-rpg.png" width="250" alt="dev-rpg"></td> </tr> <tr> <td align="center" valign="top"><b>code-omikuji</b><br><img src="docs/images/code-omikuji.png" width="250" alt="code-omikuji"></td> <td align="center" valign="top"><b>turn-haiku</b><br><img src="docs/images/turn-haiku.png" width="250" alt="turn-haiku"></td> <td align="center" valign="top"><b>wait-games</b><br><img src="docs/images/wait-games.png" width="250" alt="wait-games"></td> </tr> <tr> <td align="center" valign="top"><b>session-timeline</b><br><img src="docs/images/session-timeline.png" width="250" alt="session-timeline"></td> <td align="center" valign="top"><b>file-map</b><br><img src="docs/images/file-map.png" width="250" alt="file-map"></td> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history.png" width="250" alt="usage-history"></td> </tr> <tr> <td align="center" valign="top"><b>toast-inbox</b><br><img src="docs/images/toast-inbox.png" width="250" alt="toast-inbox"></td> <td align="center" valign="top"><b>done-chime</b><br><img src="docs/images/done-chime.png" width="250" alt="done-chime"></td> <td align="center" valign="top"><b>fail-log</b><br><img src="docs/images/fail-log.png" width="250" alt="fail-log"></td> </tr> <tr> <td align="center" valign="top"><b>daily-quest</b><br><img src="docs/images/daily-quest.png" width="250" alt="daily-quest"></td> <td align="center" valign="top"><b>trophy-shelf</b><br><img src="docs/images/trophy-shelf.png" width="250" alt="trophy-shelf"></td> <td align="center" valign="top"><b>mod-check</b><br><img src="docs/images/mod-check.png" width="250" alt="mod-check"></td> </tr> <tr> <td align="center" valign="top"><b>compact-cue</b><br><img src="docs/images/compact-cue.png" width="250" alt="compact-cue"></td> <td align="center" valign="top"><b>ship-guard</b><br><img src="docs/images/ship-guard.png" width="250" alt="ship-guard"></td> <td align="center" valign="top"><b>rule-watch</b><br><img src="docs/images/rule-watch.png" width="250" alt="rule-watch"></td> </tr> <tr> <td align="center" valign="top"><b>enc-guard</b><br><img src="docs/images/enc-guard.png" width="250" alt="enc-guard"></td> <td align="center" valign="top"><b>release-draft</b><br><img src="docs/images/release-draft.png" width="250" alt="release-draft"></td> <td align="center" valign="top"><b>run-guard</b><br><img src="docs/images/run-guard.png" width="250" alt="run-guard"></td> </tr> <tr> <td align="center" valign="top"><b>week-card</b><br><img src="docs/images/week-card.png" width="250" alt="week-card"></td> </tr> </table>

動く絵

ペインの絵は開いた時に 1 回動きます(数字が増える・棒が伸びる・おみくじが開く)。node tools/gif.mts <mod> で作り直せます。

<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub.gif" width="250" alt="mod-hub(動く絵)"></td> <td align="center" valign="top"><b>cc-dash</b><br><img src="docs/images/cc-dash.gif" width="250" alt="cc-dash(動く絵)"></td> </tr> <tr> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history.gif" width="250" alt="usage-history(動く絵)"></td> <td align="center" valign="top"><b>code-omikuji</b><br><img src="docs/images/code-omikuji.gif" width="250" alt="code-omikuji(動く絵)"></td> </tr> </table>

明るいテーマ

Claude Code の /config で theme を明るい物(light で始まる物)にすると、SVG も明るい色で描きます(VS Code の配色は mod から読めないので、Claude Code の theme に合わせます)。

<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub-light.png" width="250" alt="mod-hub(明るいテーマ)"></td> <td align="center" valign="top"><b>git-pane</b><br><img src="docs/images/git-pane-light.png" width="250" alt="git-pane(明るいテーマ)"></td> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history-light.png" width="250" alt="usage-history(明るいテーマ)"></td> </tr> </table>

動作環境

  • Claude Code 2.1.300 以降(関数フックの mod を読み込める版)
  • VS Code で描くには、VS Code 拡張 2.1.300 以降が要ります。2.1.288 までの拡張は mod の描画に対応していません
  • ターミナル・デスクトップアプリでも動きます。ターミナルでは SVG の代わりに文字だけの表示になります

入れ方

好きな mod だけ入れられます。冒険の書(dev-rpg)とガチャ(turn-gacha)の 2 本だけが欲しい時は、単体版の claude-code-dev-rpg からも入れられます(中身は同じなので、どちらか片方から入れてください)。ターミナルの Claude Code の入力欄で:

/plugin install cc-dash --marketplace takosasi-dev/claude-code-mods

y でマーケットプレイスを足し、スコープを選ぶと入ります。シェルからは:

claude plugin install cc-dash --marketplace takosasi-dev/claude-code-mods

デスクトップアプリでは、入力欄の横の + → Plugins → Add marketplace に takosasi-dev/claude-code-mods を入れ、Add plugin で選びます。

mod-hub に並ぶのは、mod-hub より後に読み込まれる mod だけです。mod-hub を最初に入れてください。カテゴリーとアイコンはこのリポジトリの mod だけに付いていて、ほかの mod は「その他」に入ります。toast-inbox と mod-check も、ほかの mod より後から読み込まれる物を見るので、mod-hub の次に入れるのがおすすめです。

コマンドの名前がほかのプラグインや組み込みと被った時は、/edit-log・/todo-board・/test-runner・/prompt-book・/live-commentary・/turn-gacha・/dev-rpg・/code-omikuji・/gitpane・/token-meter・/mod-hub・/canvas-pane・/rule-switch・/wait-games・/spec-board・/ci-pane・/port-watch・/pre-check・/face-band・/done-chime・/turn-haiku・/session-timeline・/file-map・/toast-inbox・/usage-history・/fail-log・/compact-cue・/ship-guard・/rule-watch・/enc-guard・/release-draft・/daily-quest・/trophy-shelf・/modcheck・/run-guard・/week-card のように mod の名前のコマンドになります。

まとめて入れる

claude plugin install は 1 回に 1 本だけ受け取ります。まとめて入れる時は、下の行のかたまりをシェル(bash・zsh・PowerShell のどれでも)にそのまま貼ってください。最初の 1 本でマーケットプレイスが足され、2 本目からはそれを使います。スコープは既定で user(全プロジェクト)で、-s project を付けると今のプロジェクトだけに入ります。mod-hub・toast-inbox・mod-check を先に入れてから、ほしいカテゴリーを開いて貼ります。

最初に(mod-hub・toast-inbox・mod-check)

claude plugin install mod-hub --marketplace takosasi-dev/claude-code-mods
claude plugin install toast-inbox --marketplace takosasi-dev/claude-code-mods
claude plugin install mod-check --marketplace takosasi-dev/claude-code-mods
claude plugin install project-board --marketplace takosasi-dev/claude-code-mods
claude plugin install git-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install edit-log --marketplace takosasi-dev/claude-code-mods
claude plugin install todo-board --marketplace takosasi-dev/claude-code-mods
claude plugin install test-runner --marketplace takosasi-dev/claude-code-mods
claude plugin install spec-board --marketplace takosasi-dev/claude-code-mods
claude plugin install ci-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install port-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install pre-check --marketplace takosasi-dev/claude-code-mods
claude plugin install fail-log --marketplace takosasi-dev/claude-code-mods
claude plugin install ship-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install rule-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install enc-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install release-draft --marketplace takosasi-dev/claude-code-mods
claude plugin install run-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install cc-dash --marketplace takosasi-dev/claude-code-mods
claude plugin install work-bar --marketplace takosasi-dev/claude-code-mods
claude plugin install token-meter --marketplace takosasi-dev/claude-code-mods
claude plugin install session-recap --marketplace takosasi-dev/claude-code-mods
claude plugin install agent-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install session-timeline --marketplace takosasi-dev/claude-code-mods
claude plugin install file-map --marketplace takosasi-dev/claude-code-mods
claude plugin install usage-history --marketplace takosasi-dev/claude-code-mods
claude plugin install compact-cue --marketplace takosasi-dev/claude-code-mods
claude plugin install usage-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install devlog-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install focus-timer --marketplace takosasi-dev/claude-code-mods
claude plugin install prompt-book --marketplace takosasi-dev/claude-code-mods
claude plugin install canvas-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install rule-switch --marketplace takosasi-dev/claude-code-mods
claude plugin install done-chime --marketplace takosasi-dev/claude-code-mods
claude plugin install aqua-pet --marketplace takosasi-dev/claude-code-mods
claude plugin install live-commentary --marketplace takosasi-dev/claude-code-mods
claude plugin install turn-gacha --marketplace takosasi-dev/claude-code-mods
claude plugin install dev-rpg --marketplace takosasi-dev/claude-code-mods
claude plugin install code-omikuji --marketplace takosasi-dev/claude-code-mods
claude plugin install wait-games --marketplace takosasi-dev/claude-code-mods
claude plugin install face-band --marketplace takosasi-dev/claude-code-mods
claude plugin install turn-haiku --marketplace takosasi-dev/claude-code-mods
claude plugin install daily-quest --marketplace takosasi-dev/claude-code-mods
claude plugin install trophy-shelf --marketplace takosasi-dev/claude-code-mods
claude plugin install week-card --marketplace takosasi-dev/claude-code-mods
claude plugin install reply-frame --marketplace takosasi-dev/claude-code-mods
claude plugin install danger-frame --marketplace takosasi-dev/claude-code-mods
claude plugin install long-turn-toast --marketplace takosasi-dev/claude-code-mods

全部(48 本)をまとめて(bash / zsh):

for m in mod-hub toast-inbox mod-check cc-dash work-bar aqua-pet project-board git-pane token-meter focus-timer session-recap edit-log todo-board test-runner prompt-book live-commentary turn-gacha dev-rpg code-omikuji agent-watch canvas-pane rule-switch wait-games spec-board ci-pane port-watch pre-check face-band done-chime turn-haiku session-timeline file-map usage-history compact-cue fail-log ship-guard rule-watch enc-guard release-draft daily-quest trophy-shelf reply-frame danger-frame long-turn-toast usage-pane devlog-pane run-guard week-card; do claude plugin install "$m" --marketplace takosasi-dev/claude-code-mods; done

PowerShell:

foreach ($m in 'mod-hub','toast-inbox','mod-check','cc-dash','work-bar','aqua-pet','project-board','git-pane','token-meter','focus-timer','session-recap','edit-log','todo-board','test-runner','prompt-book','live-commentary','turn-gacha','dev-rpg','code-omikuji','agent-watch','canvas-pane','rule-switch','wait-games','spec-board','ci-pane','port-watch','pre-check','face-band','done-chime','turn-haiku','session-timeline','file-map','usage-history','compact-cue','fail-log','ship-guard','rule-watch','enc-guard','release-draft','daily-quest','trophy-shelf','reply-frame','danger-frame','long-turn-toast','usage-pane','devlog-pane','run-guard','week-card') { claude plugin install $m --marketplace takosasi-dev/claude-code-mods }

この一覧は node tools/plugin_dirs.mts --readme で作り直せます(--en で英語版)。

clone したフォルダから読ませる

リポジトリを clone して、そのフォルダから読ませることもできます。

  • フォルダをマーケットプレイスとして足す: 上の行の takosasi-dev/claude-code-mods を clone 先のパス(例 D:\work\claude-code-mods)に変えて入れます。mod はそのフォルダから直接読まれるので、git pull の後に /reload-plugins で新しい版になります
  • CLAUDE_CODE_PLUGIN_DIRS で読ませる: 入れずに、セッションを始める時に毎回フォルダから読みます。値は clone 先で node tools/plugin_dirs.mts を走らせると出ます(別の場所を指すなら node tools/plugin_dirs.mts D:\work\claude-code-mods)。mod-hub・toast-inbox・mod-check を先頭に、全 mod のフォルダを Windows は ;、ほかは : でつないだ 1 行です。これを環境変数か、~/.claude/settings.json の env の CLAUDE_CODE_PLUGIN_DIRS に入れます(道具は settings.json を書き換えません)。要らない mod はその行から消してください
D:\work\claude-code-mods\mods\mod-hub;D:\work\claude-code-mods\mods\toast-inbox;D:\work\claude-code-mods\mods\cc-dash;...

気を付けること

  • rule-switch の約束ごとは、サブエージェントのシステムプロンプトにも入ります。また、組織が Claude Code を管理している環境では、利用者が入れたプラグインのシステムプロンプトのフックは動きません
  • rule-switch を切り替えると、プロンプトのキャッシュが作り直されます
  • canvas-pane のツールに渡された生の SVG は、スクリプト・イベント属性・外部の参照を含む物を断ります(ペインでは画像として描かれ、スクリプトは動きません)
  • turn-haiku は、答えて終わったターンごと(ツールを使ったか 20 秒以上かかった時)に Haiku のモデルを 1 回呼び、トークンを使います。/haiku の「自動で詠む」で止められます
  • port-watch の「止める」は子プロセスごと止めます(Windows は taskkill /T /F)。node で動く MCP サーバーも開発用に見えるので、止める前に名前とポートを確かめてください。macOS / Linux(lsof)の実機では未確認です
  • pre-check の秘密情報の検出は手がかり程度です。見つからなくても安全とは限りません
  • done-chime は Windows では PowerShell の SoundPlayer で鳴らします。それ以外は Claude Code の音の再生を使い、Linux のターミナルでは鳴らないことがあります
  • spec-board の一覧は SpecStatus が GitHub の API を使うため、回数の上限に当たることがあります。読み直しは 5 分ごとです
  • ci-pane と git を読む mod は、所有者の違うフォルダ(別ドライブなど)では git が止まります。git config --global --add safe.directory <フォルダ> を足してください
  • aqua-pet の背景は 15 分ごとに描き直します。描き直すと泳ぎや泡のアニメが頭から始まります
  • aqua-pet 0.2 の記録は、開いた時に今の匹数の魚を作って引き継ぎます。0.2 と 0.3 のセッションを同時に開いていると、魚の来た日が作り直されることがあります
  • dev-rpg の装備は turn-gacha 0.2.0 以降と一緒に入れた時だけ使えます。ガチャの図鑑から消えたカードは外れた扱いになりますが、記録は残ります
  • toast-inbox が拾うのは、ほかの mod が $.ui.toast / $.ui.status を呼んだ時だけです。「空にする」はほかのセッションの記録も消します
  • usage-history は入れた後のターンから数えます。日付・曜日・時刻は日本時間です
  • file-map は失敗した呼び出しを数えません。Grep と Glob は path を渡した時だけ数えます
  • 明るいテーマでも、水槽・俳句の短冊・おみくじの紙・実況のスタジアム・RPG の窓は絵の色のままです
  • mod の版を上げたら、開いているほかのセッションも開き直してください。0.7.0 から記録は知らない欄を残して書くので、新しい版の記録は消えなくなりましたが、0.6 以前の mod を読んだまま動いているセッションは、新しい版で増えた欄(魚の種類・装備・SP の割り振りなど)を消して書き戻します
  • fail-log の記録は、同時に開いた全セッションで共有します。「今日」は日本時間の日付で、残している 200 件から数えます
  • todo-board は git の中では git grep で読みます(.gitignore の物は読みません)。git の外では作業フォルダを歩いて読み(2000 ファイル・5000 件・8 秒で止め、1 MiB を超えるファイルと . で始まるフォルダ・node_modules・dist などは読みません)、この時はファイルを変えても自動では読み直さないので「再読込」を押してください
  • daily-quest のお題は日本時間の日付だけで決まります。数えるのはメインのターンだけで、「テスト」はコマンドの名前の形(npm test・pytest など)で見ます。ごほうびは dev-rpg と turn-gacha がターンの終わりに 1 回だけ受け取ります
  • trophy-shelf はほかの mod の記録を読むだけです。入っていない mod の棚は「入っていません」と出ます。一度取ったトロフィーは、その mod を外しても残ります
  • cc-dash の「自分の並び」は記録に残るので、全セッションで共有されます
  • mod-check が版と「読み込みを拒まれた」を出せるのは、mod-check より後に読み込まれた mod だけです。「state まだ」は壊れているという意味ではありません(開
Source 4 files
hooks/register.tsx 248 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { TestRunnerResult } from '../types'
5import { HISTORY, UNSET_HINT, cardOf, claudeResult,countResults, failingLine, fixPrompt, isCommitCommand, isTestCommand, paneLines, paneSvg, splitCommand, statusOf, tailOf } from './runner'
6import { followTheme, readTheme } from './style'
7
8const PANE = 'test-runner'
9const TITLE = 'テスト'
10const DESCRIPTION = 'テストを走らせて結果を見るペインを開く'
11// 出力はこの長さの末尾だけ持つ (数を拾うのも末尾 60 行もここから)
12const KEEP = 200_000
13/** 時間の上限の上限 (1 時間) */
14const MAX_TIMEOUT_SECONDS = 3600
15
16const RUN = atom({ plugin: 'test-runner', key: 'run' } as const, { kind: 'idle' })
17const HIST = atom({ plugin: 'test-runner', key: 'history' } as const, [])
18const NOTE = atom({ plugin: 'test-runner', key: 'note' } as const, '')
19
20type $ = EngineInterface
21
22// ponytail: 走っている子の止め方はモジュール変数。リロードで消えるが、その時は子もエンジンが止める
23let stopActive: ((why: 'user' | 'timeout') => void) | undefined
24
25// 設定は register で userConfig から入れる
26const cfg = { command: '', argv: [] as string[], timeoutMs: 300_000 }
27let ownsMain = false
28
29async function runTests($: $) {
30  const { command, argv, timeoutMs } = cfg
31  if (argv.length === 0) {
32    await update($, NOTE, () => UNSET_HINT)
33    return
34  }
35  const startedAt = await $.clock.now()
36  // 二重に走らせない: 実行中でなければ自分が「実行中」を書く
37  let isMine = false
38  await update($, RUN, r => {
39    isMine = r.kind !== 'running'
40    return isMine ? { kind: 'running' as const, command, startedAt } : r
41  })
42  if (!isMine) return
43  await update($, NOTE, () => '')
44
45  const cwd = await $.session.cwd()
46  let out = ''
47  let stoppedBy: 'user' | 'timeout' | undefined
48  let code: number | null = null
49  // シェルを通さずに起動する。npm などの .cmd もエンジンがそのまま起動する (Windows で確認)
50  const stream = $.process.spawn({ argv, cwd })
51  // 止める時は読み待ちを待たずにループを抜ける。抜けたら return() で子 (とその子) をエンジンが止める
52  let wake = () => {}
53  const stopped = new Promise<undefined>(resolve => (wake = () => resolve(undefined)))
54  const stop = (why: 'user' | 'timeout') => {
55    stoppedBy ??= why
56    wake()
57  }
58  stopActive = stop
59  const timer = $.clock.after(timeoutMs, () => stop('timeout'))
60  try {
61    for (;;) {
62      const pending = stream.next()
63      const step = await Promise.race([pending, stopped])
64      if (!step) {
65        pending.catch(() => {})
66        void stream.return({ code: null, signal: null }).catch(() => {})
67        break
68      }
69      if (step.done) {
70        code = step.value.code
71        break
72      }
73      out += step.value.text
74      if (out.length > KEEP * 1.5) out = out.slice(-KEEP)
75    }
76  } catch (err) {
77    // 起動できない時は最初の読み取りが reject する (ENOENT など)
78    const message = err instanceof Error ? err.message : String(err)
79    await update($, RUN, () => ({ kind: 'error' as const, command, message }))
80    return
81  } finally {
82    timer.cancel()
83    if (stopActive === stop) stopActive = undefined
84  }
85
86  const finishedAt = await $.clock.now()
87  const counts = countResults(out)
88  const result: TestRunnerResult = {
89    status: statusOf(code, stoppedBy, counts.failed),
90    command,
91    exitCode: code,
92    ms: finishedAt - startedAt,
93    finishedAt,
94    tail: tailOf(out),
95    ...counts,
96  }
97  await update($, RUN, () => ({ kind: 'done' as const, result }))
98  await update($, HIST, h => [...h, { status: result.status, ms: result.ms }].slice(-HISTORY))
99}
100
101/** Claude が Bash / PowerShell で走らせたテストを拾う。落ちたままコミットしようとしたらトーストで知らせる (止めない) */
102async function watchShell<R extends { isError?: boolean; text?: string; result?: unknown; deny?: string }>($: $, command: string, isBackground: boolean, next: () => Promise<R>) {
103  if (isCommitCommand(command)) {
104    const run = await read($, RUN)
105    if (failingLine(run) !== '') $.ui.toast('テストが落ちたままコミットしようとしています。/testrun で結果を見られます', { timeoutMs: 8000 })
106  }
107  if (isBackground || !isTestCommand(command, cfg.command)) return next()
108  const startedAt = await $.clock.now()
109  const r = await next()
110  // 止められた・裏に回された物は数えない
111  const out = (r.result ?? {}) as { interrupted?: unknown; backgroundTaskId?: unknown }
112  if (r.deny !== undefined || typeof out.backgroundTaskId === 'string') return r
113  const finishedAt = await $.clock.now()
114  await keep($, claudeResult(command, typeof r.text === 'string' ? r.text : '', r.isError === true, out.interrupted === true, finishedAt - startedAt, finishedAt))
115  return r
116}
117
118async function fixIt($: $) {
119  const run = await read($, RUN)
120  if (run.kind !== 'done') return
121  const filled = await $.prompt.fill({ text: fixPrompt(run.result), mode: 'insert' }).then(
122    r => (r.isFilled ? '' : r.refusal === 'dialog' ? '(ダイアログが開いています)' : r.refusal === 'no_composer' ? '(入力欄がありません)' : '(断られました)'),
123    (err: unknown) => `(${err instanceof Error ? err.message : String(err)})`,
124  )
125  await update($, NOTE, () => (filled === '' ? '入力欄に入れました(送信はしていません)' : `入力欄に入れられませんでした${filled}`))
126}
127
128/** 結果を「今の結果」と履歴に入れる。ボタンで走らせている最中は「今の結果」を上書きしない */
129async function keep($: $, result: TestRunnerResult) {
130  await update($, RUN, r => (r.kind === 'running' ? r : { kind: 'done' as const, result }))
131  await update($, HIST, h => [...h, { status: result.status, ms: result.ms }].slice(-HISTORY))
132}
133
134const SHELL_TOOLS = ['Bash', 'PowerShell']
135const drawsSomething = (tree: RenderElement) => tree.type !== 'engine'
136
137const openPane = async ($: $) => {
138  await $.ui.open({ id: PANE, title: TITLE })
139  return { text: 'テストのペインを開きました。' }
140}
141
142export const register: Register = (on, options) => {
143  cfg.argv = splitCommand(String(options.testCommand ?? ''))
144  // 見せるのは実際に起動する argv。長い空白の後ろの引数が … に隠れないように
145  cfg.command = cfg.argv.join(' ')
146  cfg.timeoutMs = Math.min(MAX_TIMEOUT_SECONDS, Math.max(1, Number(options.timeoutSeconds) || 300)) * 1000
147
148  followTheme(on)
149  on('session.start', async ($, e, next) => {
150    const started = await next(e)
151    await readTheme($)
152    // 途中で読み込まれた時も帯を聞きに来るように (docs/mod-guide.md の 3 節)
153    $.ui.invalidate('ui.render')
154    // /testrun が拒まれたら (組み込みと被ったら) /test-runner にする
155    const got = await $.command.register({ name: 'testrun', description: DESCRIPTION }).catch(() => undefined)
156    ownsMain = got?.command === 'testrun'
157    if (!ownsMain) await $.command.register({ name: 'test-runner', description: DESCRIPTION }).catch(() => {})
158    return started
159  })
160
161  on('command.run', { command: 'testrun' }, ($, e, next) => (ownsMain ? openPane($) : next(e))).catch(($, e, next) => next(e))
162  // /testrun を持てた時は予備名を開かない (同じ名前の別のコマンドを横取りしない)
163  on('command.run', { command: 'test-runner' }, ($, e, next) => (ownsMain ? next(e) : openPane($)))
164
165  // Claude が走らせたテスト (サブエージェントの分も) を履歴に入れる。見ているだけなので失敗しても止めない
166  on('tool.call', ($, e, next) => {
167    const args = e as unknown as { command?: unknown; run_in_background?: unknown }
168    if (!SHELL_TOOLS.includes(String(e.tool)) || typeof args.command !== 'string') return next(e)
169    return watchShell($, args.command, args.run_in_background === true, () => next(e))
170  }).catch(($, e, next) => next(e))
171
172  // 入力欄の上の帯。最後のテストが落ちている間だけ 1 行出す
173  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
174    const line = e.props.hasSurvey ? '' : failingLine(await read($, RUN))
175    if (line === '') return next(e)
176    const below = await next(e)
177    const { Box, Text } = $.ui.resolve(e)
178    const band = (
179      <Box key="test-runner" flexShrink={0}>
180        <Text color="error">{line}</Text>
181      </Box>
182    )
183    if (!drawsSomething(below)) return band
184    return (
185      <Box flexDirection="column">
186        {band}
187        <Box key="below">{below}</Box>
188      </Box>
189    )
190  }).catch(($, e, next) => next(e))
191
192  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
193    const t = $.ui.resolve(e)
194    const { Box, Text, Button, Code } = t
195    // terminal の表にも Svg の名前があるので、画面の種類でも分ける
196    const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : undefined
197    const run = await read($, RUN)
198    const history = await read($, HIST)
199    const note = await read($, NOTE)
200    const lines = paneLines(run, history, cfg.command)
201    const isRunning = run.kind === 'running'
202    const failed = run.kind === 'done' && (run.result.status === 'fail' || run.result.status === 'timeout') ? run.result : undefined
203    const card = cardOf(run, cfg.command)
204
205    return (
206      <Box flexDirection="column" gap={1}>
207        {Svg ? (
208          <Svg source={paneSvg(run, history, cfg.command)} alt={lines.map(l => l.text).join('\n')} />
209        ) : (
210          <Box key="lines" flexDirection="column">
211            {lines.map(l => (
212              <Text color={l.color} dimColor={l.isDim} bold={l.isBold}>
213                {l.text}
214              </Text>
215            ))}
216          </Box>
217        )}
218        {/* 操作のカード: 状態の色の枠と見出し、主のボタン 1 つと脇のボタン */}
219        <Box key="card" flexDirection="column" borderStyle="round" borderColor={card.tone} paddingX={1} paddingY={1} gap="xs">
220          <Text bold color={card.tone}>
221            {card.title}
222          </Text>
223          <Text dimColor>{card.sub}</Text>
224          <Box key="card-buttons" flexDirection="row" flexWrap="wrap" gap="sm">
225            {isRunning ? (
226              <Button
227                key="stop"
228                label="止める"
229                // リロードで止め方を失っていたら (子はもう止まっている) 未実行に戻す
230                onPress={() => (stopActive ? stopActive('user') : void update($, RUN, () => ({ kind: 'idle' as const })))}
231              />
232            ) : failed ? (
233              [
234                <Button key="fix" label="この失敗を直して" variant="primary" onPress={() => void fixIt($)} />,
235                <Button key="run" label="もう一度走らせる" dimColor onPress={() => void runTests($).catch(() => {})} />,
236              ]
237            ) : (
238              <Button key="run" label="テストを走らせる" variant="primary" onPress={() => void runTests($).catch(() => {})} />
239            )}
240          </Box>
241        </Box>
242        {note !== '' && <Text dimColor>{note}</Text>}
243        {run.kind === 'done' && run.result.tail !== '' && <Code source={run.result.tail} />}
244      </Box>
245    )
246  })
247}
248
hooks/runner.ts 363 lines
1// テストの出力を読み、ペインの SVG と terminal 用の行を組む。どれも純関数
2import type { TestRunnerMark, TestRunnerResult, TestRunnerRun, TestRunnerStatus } from '../types'
3import { C, HEADER_H, clean as cleanText, countUp, esc, frame, header } from './style'
4
5const MONO = 'ui-monospace, Consolas, monospace'
6export const HISTORY = 20
7export const TAIL_LINES = 60
8
9export const UNSET_HINT = '未設定。/config で test-runner の testCommand に npm test のように入れてください'
10
11/** 空白で区切って argv にする (シェルは通さない) */
12export const splitCommand = (s: string) => s.trim().split(/\s+/).filter(Boolean)
13
14const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
15// 色の後に残った制御文字も落とす。Code は制御文字を含むと描かれない (タブと改行は残す)
16const CTRL = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g
17const clean = (out: string) => out.replace(ANSI, '').replace(/\r(?!\n)/g, '\n').replace(CTRL, '').split(/\r?\n/)
18
19/** 出力の末尾 n 行 (色の制御文字と末尾の空行は落とす) */
20export function tailOf(out: string, n = TAIL_LINES) {
21  const lines = clean(out)
22  while (lines.length > 0 && (lines[lines.length - 1] ?? '').trim() === '') lines.pop()
23  return lines.slice(-n).join('\n')
24}
25
26// 1 行は先頭 2000 字だけ見る。数字の途中からは拾わない (長い数字の並びで 2 乗の時間にならない)
27const num = (re: RegExp, s: string) => {
28  const m = re.exec(s.slice(0, 2000))
29  return m ? Number(m[1]) : undefined
30}
31const PASSED = /(?<!\d)(\d+) (?:passed|passing)\b/
32const FAILED = /(?<!\d)(\d+) (?:failed|failing)\b/
33
34/**
35 * 出力から通過・失敗の数を拾う。拾えなければ空。
36 * jest / vitest / pytest / mocha は最後の「N passed」「N failed」の行、cargo は「test result:」の行の合計、
37 * node --test は「# pass N」「# fail N」、go test -v は「--- PASS」「--- FAIL」の行の数
38 */
39export function countResults(out: string): { passed?: number; failed?: number } {
40  const lines = clean(out)
41  const cargo = lines.filter(l => /^test result: /.test(l))
42  if (cargo.length > 0) {
43    return {
44      passed: cargo.reduce((n, l) => n + (num(PASSED, l) ?? 0), 0),
45      failed: cargo.reduce((n, l) => n + (num(FAILED, l) ?? 0), 0),
46    }
47  }
48  for (let i = lines.length - 1; i >= 0; i--) {
49    const l = lines[i] ?? ''
50    let passed = num(PASSED, l)
51    let failed = num(FAILED, l)
52    if (passed === undefined && failed === undefined) continue
53    // mocha は「3 passing」と「1 failing」が別の行。すぐ上の数行から足りない方を探す
54    for (const near of lines.slice(Math.max(0, i - 3), i)) {
55      passed ??= num(PASSED, near)
56      failed ??= num(FAILED, near)
57    }
58    return { passed: passed ?? 0, failed: failed ?? 0 }
59  }
60  const tapPass = lines.map(l => num(/^# pass (\d+)/, l)).find(n => n !== undefined)
61  const tapFail = lines.map(l => num(/^# fail (\d+)/, l)).find(n => n !== undefined)
62  if (tapPass !== undefined || tapFail !== undefined) return { passed: tapPass ?? 0, failed: tapFail ?? 0 }
63  const goPass = lines.filter(l => /^\s*--- PASS: /.test(l)).length
64  const goFail = lines.filter(l => /^\s*--- FAIL: /.test(l)).length
65  if (goPass + goFail > 0) return { passed: goPass, failed: goFail }
66  return {}
67}
68
69// ── Claude が走らせたテストを拾う
70
71/** 引用符の中身とヒアドキュメントの本文を Q に置き換える (コミットの文の中の言葉を拾わない) */
72const unquote = (command: string) =>
73  command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2\s*(?=\n|$)/g, ' Q').replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, 'Q')
74
75/** コマンドの区切り (&& ; | など) で分けた 1 つずつ。頭の VAR=値 と & は落とす */
76const segments = (command: string) =>
77  unquote(command)
78    .split(/&&|\|\||[;|\n`{}]|\$\(|\(/)
79    .map(s => s.trim().replace(/^(?:[\w.]+=\S*\s+)*(?:&\s*)?/, ''))
80    .filter(s => s !== '')
81
82const TEST_COMMAND =
83  /^(?:\S*[\\/])?(?:(?:npm|pnpm|yarn|bun)(?:\.cmd)?\s+(?:run\s+)?test\b|npx\s+(?:vitest|jest)\b|vitest\b|jest\b|pytest\b|python\d*(?:\.exe)?\s+-m\s+pytest\b|cargo\s+test\b|go\s+test\b|node(?:\.exe)?\s+--test\b|dotnet\s+test\b|(?:\.[\\/])?gradlew?(?:\.bat)?\s+test\b|mvn\s+test\b|ctest\b|claude(?:\.exe)?\s+plugin\s+test\b)/i
84
85/** テストを走らせるコマンドか。設定したコマンド (testCommand) と同じ頭で始まる物も含める */
86export function isTestCommand(command: string, configured = '') {
87  const own = configured.trim().replace(/\s+/g, ' ').toLowerCase()
88  return segments(command).some(s => TEST_COMMAND.test(s) || (own !== '' && s.replace(/\s+/g, ' ').toLowerCase().startsWith(own)))
89}
90
91/** git commit か */
92export const isCommitCommand = (command: string) =>
93  segments(command).some(s => /^(?:\S*[\\/])?git(?:\.exe)?\b(?:\s+(?:-[cC]\s+\S+|--?[\w-]+(?:=\S+)?))*\s+commit\b/i.test(s))
94
95/**
96 * Claude の Bash / PowerShell の結果から 1 回分を組む。エラーで返ったら失敗、止められたら中断。
97 * 終了コードが 0 でも、出力の数で失敗が 1 件以上なら失敗 (npm test | tail のようにパイプで終了コードが消える)
98 */
99export function claudeResult(command: string, text: string, isError: boolean, isInterrupted: boolean, ms: number, finishedAt: number): TestRunnerResult {
100  const code = /exit code[:\s]+(-?\d+)/i.exec(text.slice(0, 2000))
101  const counts = countResults(text)
102  return {
103    status: isInterrupted ? 'stopped' : isError || (counts.failed ?? 0) > 0 ? 'fail' : 'pass',
104    command: cleanText(command).replace(/\s+/g, ' '),
105    exitCode: isError ? (code ? Number(code[1]) : null) : 0,
106    ms,
107    finishedAt,
108    tail: tailOf(text),
109    ...counts,
110    source: 'claude',
111  }
112}
113
114/** 落ちたままのテストの帯の 1 行。落ちていなければ空 */
115export function failingLine(run: TestRunnerRun) {
116  if (run.kind !== 'done' || (run.result.status !== 'fail' && run.result.status !== 'timeout')) return ''
117  const r = run.result
118  const n = r.failed ? ` 失敗 ${r.failed} 件` : ''
119  return `テストが落ちたままです:${n} ${headFit(r.command, 48)} (/testrun で見る)`
120}
121
122/**
123 * 操作のカード (色枠の Box) の色・見出し・説明。色はテーマの名前で返す (明るいテーマにも付いていく)。
124 * VS Code では Button の色を変えられないので、状態の色はカードの枠と見出しで出す (docs/mod-guide.md の 1 節)
125 */
126export function cardOf(run: TestRunnerRun, command: string): { tone: string; title: string; sub: string } {
127  const what = command === '' ? 'testCommand が未設定です' : headFit(command, 40)
128  if (run.kind === 'idle') return { tone: 'inactive', title: 'まだ走らせていません', sub: what }
129  if (run.kind === 'running') return { tone: 'suggestion', title: '走っています', sub: headFit(run.command, 40) }
130  if (run.kind === 'error') return { tone: 'error', title: '起動できませんでした', sub: headFit(run.message, 40) }
131  const r = run.result
132  const by = r.source === 'claude' ? ' ・ Claude が実行' : ''
133  const sub = `${headFit(r.command, 30)} ・ ${seconds(r.ms)}${by}`
134  if (r.status === 'pass') return { tone: 'success', title: r.passed ? `通りました(${r.passed} 件)` : '通りました', sub }
135  if (r.status === 'fail') return { tone: 'error', title: r.failed ? `落ちています(${r.failed} 件)` : '落ちています', sub }
136  if (r.status === 'timeout') return { tone: 'error', title: '時間切れで止めました', sub }
137  return { tone: 'warning', title: '途中で止めました', sub }
138}
139
140/** 終わり方から状態を決める。終了コードが 0 でも、出力の数で失敗が 1 件以上なら失敗 */
141export const statusOf = (exitCode: number | null, stoppedBy: 'user' | 'timeout' | undefined, failed = 0): TestRunnerStatus =>
142  stoppedBy === 'user' ? 'stopped' : stoppedBy === 'timeout' ? 'timeout' : exitCode === 0 && failed === 0 ? 'pass' : 'fail'
143
144/** 「この失敗を直して」に入れる出力の末尾の文字数 */
145export const PROMPT_TAIL = 4000
146
147/** 「この失敗を直して」で入力欄に入れる文 */
148export function fixPrompt(r: TestRunnerResult) {
149  const how = r.status === 'timeout' ? '時間の上限を過ぎて止まりました' : `失敗しました(終了コード ${r.exitCode})`
150  const tail = r.tail.slice(-PROMPT_TAIL)
151  // 出力に ``` があると囲いが途中で閉じるので、出力に無い長さの囲いにする
152  let fence = '```'
153  while (tail.includes(fence)) fence += '`'
154  return (
155    `テストのコマンド \`${r.command}\` が${how}。出力の末尾は次のとおりです。\n\n` +
156    `${fence}\n` +
157    tail +
158    `\n${fence}\n\n` +
159    '原因を調べて直してください。直したらもう一度テストを走らせて確かめてください。'
160  )
161}
162
163export const seconds = (ms: number) => (ms < 10_000 ? `${(ms / 1000).toFixed(1)} 秒` : ms < 120_000 ? `${Math.round(ms / 1000)} 秒` : `${Math.floor(ms / 60_000)} 分 ${Math.round((ms % 60_000) / 1000)} 秒`)
164
165/** 日本時間 (JST) の時:分:秒 */
166export const clockLabel = (ms: number) => {
167  const d = new Date(ms + 9 * 3_600_000)
168  return [d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds()].map(n => String(n).padStart(2, '0')).join(':')
169}
170
171const LABEL: Record<TestRunnerStatus, string> = { pass: '通過', fail: '失敗', stopped: '止めました', timeout: '時間切れ' }
172/** 結果の色(C は今のテーマの色なので、呼ぶたびに引く) */
173const colorOf = (status: TestRunnerStatus) => ({ pass: C.green, fail: C.red, stopped: C.dim, timeout: C.red })[status]
174const MARK: Record<TestRunnerStatus, string> = { pass: '●', fail: '■', stopped: '○', timeout: '■' }
175
176// 全角は 2、半角は 1 と数えた幅で、頭を残して末尾を … にする。外から来た文字なので制御文字も落とす (Text と帯に出す前)
177const wide = (ch: string) => /[⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/.test(ch)
178export function headFit(raw: string, max: number) {
179  const s = cleanText(raw)
180  if ([...s].reduce((n, ch) => n + (wide(ch) ? 2 : 1), 0) <= max) return s
181  let out = ''
182  let n = 1
183  for (const ch of s) {
184    n += wide(ch) ? 2 : 1
185    if (n > max) break
186    out += ch
187  }
188  return `${out}…`
189}
190
191/** 共通の見出し帯の下に body を置く(body は見出しを除いた座標で書く)。信号の光り方の defs も入れる。
192 * 光沢の白(#ffffff)は灯の玉の絵の中身なので、テーマで変えない */
193const svgDoc = (h: number, body: string, accent: string, sub: string) =>
194  frame(
195    h + HEADER_H,
196    `<defs><radialGradient id="shine" cx="0.35" cy="0.3" r="0.7"><stop offset="0" stop-color="#ffffff" stop-opacity="0.55"/><stop offset="0.5" stop-color="#ffffff" stop-opacity="0"/></radialGradient>` +
197  `<filter id="glow" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="6"/></filter></defs>` +
198      header('テスト', sub, accent) +
199      `<g transform="translate(0 ${HEADER_H})">${body}</g>`,
200  )
201
202type Lamp = 'red' | 'yellow' | 'green' | 'none'
203
204/** 信号機。点いている灯だけ色とにじみを付け、実行中の黄色は明滅させる */
205function signal(lit: Lamp, isBlinking: boolean) {
206  let s =
207    `<rect x="40" y="200" width="12" height="20" fill="${C.line}"/>` +
208    `<rect x="16" y="16" width="60" height="186" rx="14" fill="${C.face}" stroke="${C.line}" stroke-width="2"/>`
209  const lamps: [Lamp, string, number][] = [
210    ['red', C.red, 50],
211    ['yellow', C.yellow, 109],
212    ['green', C.green, 168],
213  ]
214  for (const [name, color, cy] of lamps) {
215    s += `<rect x="20" y="${cy - 28}" width="52" height="8" rx="3" fill="${C.bg}" opacity="0.8"/>`
216    if (name !== lit) {
217      s += `<circle cx="46" cy="${cy}" r="21" fill="${C.bg}" stroke="${C.line}"/><circle cx="46" cy="${cy}" r="21" fill="${color}" opacity="0.12"/>`
218      continue
219    }
220    const blink = isBlinking ? `<animate attributeName="opacity" values="1;0.35;1" dur="1.2s" repeatCount="indefinite"/>` : ''
221    s +=
222      `<g>${blink}<circle cx="46" cy="${cy}" r="24" fill="${color}" opacity="0.7" filter="url(#glow)"/>` +
223      `<circle cx="46" cy="${cy}" r="21" fill="${color}"/><circle cx="46" cy="${cy}" r="21" fill="url(#shine)"/></g>`
224  }
225  return s
226}
227
228/** 回るスピナー (SMIL) */
229const spinner = (cx: number, cy: number) =>
230  `<circle cx="${cx}" cy="${cy}" r="10" fill="none" stroke="${C.line}" stroke-width="3"/>` +
231  `<circle cx="${cx}" cy="${cy}" r="10" fill="none" stroke="${C.yellow}" stroke-width="3" stroke-linecap="round" stroke-dasharray="18 46">` +
232  `<animateTransform attributeName="transform" type="rotate" from="0 ${cx} ${cy}" to="360 ${cx} ${cy}" dur="0.9s" repeatCount="indefinite"/></circle>`
233
234/** 数のタイル。数が拾えた時は 0 から増えて止まる。拾えなければ ─ */
235function tile(x: number, label: string, value: number | undefined, color: string, delay = 0) {
236  const shown = value === undefined ? '─' : String(value)
237  const attrs = `x="${x + 12}" y="136" font-size="${shown.length > 5 ? 20 : 26}" font-weight="bold" fill="${color}"`
238  return (
239    `<rect x="${x}" y="86" width="128" height="60" rx="6" fill="${C.bg}" stroke="${C.line}"/>` +
240    `<rect x="${x}" y="86" width="3" height="60" rx="1.5" fill="${color}"/>` +
241    `<text x="${x + 12}" y="104" font-size="12" fill="${C.dim}">${label}</text>` +
242    (value === undefined ? `<text ${attrs}>${shown}</text>` : countUp(value, attrs, String, delay))
243  )
244}
245
246/** 上の札: 信号・状態・コマンド・数・所要時間 */
247function headCard(run: TestRunnerRun, command: string) {
248  let s = `<rect x="8" y="8" width="364" height="220" rx="8" fill="${C.face}" stroke="${C.line}"/>`
249  const R = 96
250  if (run.kind === 'running') {
251    s += signal('yellow', true)
252    s += `<text x="${R}" y="44" font-size="24" font-weight="bold" fill="${C.yellow}">実行中</text>` + spinner(R + 98, 36)
253    s += `<text x="${R}" y="68" font-size="12" font-family="${MONO}" fill="${C.dim}">${esc(headFit(run.command, 40))}</text>`
254    s += `<text x="${R}" y="104" font-size="13" fill="${C.text}">開始 ${clockLabel(run.startedAt)}</text>`
255    s += `<text x="${R}" y="128" font-size="12" fill="${C.dim}">終わると結果をここに出します</text>`
256    s += `<text x="${R}" y="148" font-size="12" fill="${C.dim}">「止める」で途中で止められます</text>`
257    return s
258  }
259  if (run.kind === 'error') {
260    s += signal('red', false)
261    s += `<text x="${R}" y="44" font-size="20" font-weight="bold" fill="${C.red}">起動できません</text>`
262    s += `<text x="${R}" y="68" font-size="12" font-family="${MONO}" fill="${C.dim}">${esc(headFit(run.command, 40))}</text>`
263    const words = run.message.match(/.{1,36}/g) ?? []
264    words.slice(0, 5).forEach((w, i) => (s += `<text x="${R}" y="${96 + i * 18}" font-size="12" fill="${C.text}">${esc(w)}</text>`))
265    s += `<text x="${R}" y="204" font-size="12" fill="${C.dim}">コマンドの綴りを確かめてください</text>`
266    return s
267  }
268  if (run.kind === 'idle') {
269    s += signal('none', false)
270    const isUnset = command === ''
271    s += `<text x="${R}" y="44" font-size="24" font-weight="bold" fill="${C.dim}">${isUnset ? '未設定' : '未実行'}</text>`
272    if (isUnset) {
273      ;['/config で test-runner の', 'testCommand に', 'npm test のように', '入れてください'].forEach(
274        (w, i) => (s += `<text x="${R}" y="${76 + i * 20}" font-size="13" fill="${C.text}">${w}</text>`),
275      )
276    } else {
277      s += `<text x="${R}" y="68" font-size="12" font-family="${MONO}" fill="${C.dim}">${esc(headFit(command, 40))}</text>`
278      s += `<text x="${R}" y="104" font-size="13" fill="${C.text}">「テストを走らせる」で始めます</text>`
279    }
280    return s
281  }
282  const r = run.result
283  const color = colorOf(r.status)
284  s += signal(r.status === 'pass' ? 'green' : r.status === 'stopped' ? 'none' : 'red', false)
285  s += `<text x="${R}" y="44" font-size="24" font-weight="bold" fill="${color}">${LABEL[r.status]}</text>`
286  s += `<text x="${R}" y="68" font-size="12" font-family="${MONO}" fill="${C.dim}">${esc(headFit(r.command, 40))}</text>`
287  const has = r.passed !== undefined || r.failed !== undefined
288  s += tile(R, '通過', has ? (r.passed ?? 0) : undefined, C.green)
289  s += tile(R + 140, '失敗', has ? (r.failed ?? 0) : undefined, r.failed ? C.red : C.dim, 0.1)
290  s += `<text x="${R}" y="174" font-size="13" fill="${C.text}">所要 ${seconds(r.ms)}</text>`
291  s += `<text x="${R}" y="196" font-size="12" fill="${C.dim}">終了コード ${r.exitCode ?? '─'} / ${clockLabel(r.finishedAt)}</text>`
292  if (!has) s += `<text x="${R}" y="216" font-size="12" fill="${C.dim}">数は出力から拾えませんでした</text>`
293  return s
294}
295
296/** 下の札: 直近 20 回の信号の丸と所要時間の棒。右が新しい */
297function historyCard(history: TestRunnerMark[], y0: number) {
298  const pass = history.filter(h => h.status === 'pass').length
299  let s = `<rect x="8" y="${y0}" width="364" height="138" rx="8" fill="${C.face}" stroke="${C.line}"/>`
300  s += `<text x="20" y="${y0 + 24}" font-size="13" font-weight="bold" fill="${C.text}">直近 ${HISTORY} 回</text>`
301  s +=
302    `<text x="360" y="${y0 + 24}" font-size="12" text-anchor="end">` +
303    `<tspan fill="${C.green}">通過 ${pass}</tspan><tspan dx="8" fill="${C.dim}">/ ${history.length} 回</tspan></text>`
304  const step = 340 / HISTORY
305  const base = y0 + 88
306  const max = Math.max(1, ...history.map(h => h.ms))
307  s += `<line x1="20" y1="${base}" x2="360" y2="${base}" stroke="${C.line}"/>`
308  const pad = HISTORY - history.length
309  for (let i = 0; i < HISTORY; i++) {
310    const cx = 20 + step * i + step / 2
311    const h = history[i - pad]
312    if (!h) {
313      s += `<circle cx="${cx}" cy="${base + 18}" r="6" fill="none" stroke="${C.line}" stroke-dasharray="2 2"/>`
314      continue
315    }
316    const color = colorOf(h.status)
317    const bh = Math.max(3, (h.ms / max) * 48)
318    const isNewest = i === HISTORY - 1
319    const grow = isNewest
320      ? `<animate attributeName="height" from="0" to="${bh}" dur="0.6s" fill="freeze"/><animate attributeName="y" from="${base}" to="${base - bh}" dur="0.6s" fill="freeze"/>`
321      : ''
322    s += `<rect x="${cx - 5}" y="${base - bh}" width="10" height="${bh}" rx="2" fill="${color}" opacity="0.55">${grow}</rect>`
323    s += h.status === 'stopped'
324      ? `<circle cx="${cx}" cy="${base + 18}" r="6" fill="none" stroke="${color}" stroke-width="2"/>`
325      : `<circle cx="${cx}" cy="${base + 18}" r="6.5" fill="${color}"/>`
326    if (isNewest) s += `<circle cx="${cx}" cy="${base + 18}" r="9.5" fill="none" stroke="${C.text}" stroke-width="1"/>`
327  }
328  s += `<text x="20" y="${y0 + 128}" font-size="12" fill="${C.dim}">古い</text>`
329  s += `<text x="360" y="${y0 + 128}" font-size="12" fill="${C.dim}" text-anchor="end">新しい ─ 棒は所要時間</text>`
330  return s
331}
332
333/** ペインに出す SVG 1 枚 */
334export function paneSvg(run: TestRunnerRun, history: TestRunnerMark[], command: string) {
335  const accent = run.kind === 'running' ? C.yellow : run.kind === 'error' ? C.red : run.kind === 'idle' ? C.dim : colorOf(run.result.status)
336  const sub = run.kind === 'done' && run.result.source === 'claude' ? 'Claude が実行' : ''
337  return svgDoc(382, headCard(run, command) + historyCard(history, 236), accent, sub)
338}
339
340export type Line = { text: string; color?: string; isDim?: boolean; isBold?: boolean }
341
342/** Svg の alt と terminal で使う文の行 */
343export function paneLines(run: TestRunnerRun, history: TestRunnerMark[], command: string): Line[] {
344  const lines: Line[] = []
345  if (run.kind === 'idle') {
346    if (command === '') lines.push({ text: UNSET_HINT, color: 'yellow' })
347    else lines.push({ text: '未実行', isBold: true }, { text: cleanText(command), isDim: true })
348  } else if (run.kind === 'running') {
349    lines.push({ text: `実行中 (開始 ${clockLabel(run.startedAt)})`, color: 'yellow', isBold: true }, { text: cleanText(run.command), isDim: true })
350  } else if (run.kind === 'error') {
351    lines.push({ text: `起動できません: ${cleanText(run.message)}`, color: 'red' }, { text: cleanText(run.command), isDim: true })
352  } else {
353    const r = run.result
354    const color = r.status === 'pass' ? 'green' : r.status === 'stopped' ? undefined : 'red'
355    lines.push({ text: `${LABEL[r.status]} / 所要 ${seconds(r.ms)} / 終了コード ${r.exitCode ?? '─'}`, color, isBold: true })
356    if (r.source === 'claude') lines.push({ text: 'Claude が走らせた結果', isDim: true })
357    if (r.passed !== undefined || r.failed !== undefined) lines.push({ text: `通過 ${r.passed ?? 0} / 失敗 ${r.failed ?? 0}` })
358    lines.push({ text: cleanText(r.command), isDim: true })
359  }
360  if (history.length > 0) lines.push({ text: `直近 ${history.map(h => MARK[h.status]).join('')} (● 通過 / ■ 失敗 / ○ 中断)`, isDim: true })
361  return lines
362}
363
hooks/style.ts 315 lines
1// 全 mod で共通の見た目(色・見出し・動き)と小物。
2// 正本は shared/style.ts。各 mod の hooks/style.ts はその写しなので、直すのは正本だけにして
3// `node tools/copy_shared.mts` で写す(`--check` で写しのずれを見つける)。
4import type { EngineInterface, On } from 'claude-code'
5
6export type Palette = {
7  bg: string
8  face: string
9  line: string
10  text: string
11  dim: string
12  weak: string
13  blue: string
14  green: string
15  yellow: string
16  red: string
17  purple: string
18  orange: string
19}
20
21/** GitHub Dark 系(docs/mod-guide.md の 7 節) */
22export const DARK: Palette = {
23  bg: '#0d1117',
24  face: '#161b22',
25  line: '#30363d',
26  text: '#c9d1d9',
27  dim: '#8b949e',
28  weak: '#8b949e',
29  blue: '#58a6ff',
30  green: '#3fb950',
31  yellow: '#d29922',
32  red: '#f85149',
33  purple: '#bc8cff',
34  orange: '#f78166',
35}
36
37/** GitHub Light 系 */
38export const LIGHT: Palette = {
39  bg: '#ffffff',
40  face: '#f6f8fa',
41  line: '#d0d7de',
42  text: '#1f2328',
43  dim: '#656d76',
44  weak: '#656d76',
45  blue: '#0969da',
46  green: '#1a7f37',
47  yellow: '#9a6700',
48  red: '#cf222e',
49  purple: '#8250df',
50  orange: '#bc4c00',
51}
52
53// ponytail: 今の色はモジュールの変数 1 個(この mod の中だけ)。SVG を作る関数は C.bg などを呼ぶたびに読むので、
54// 引数を増やさずにテーマが変わる。描く途中で切り替わることは無い(JS は 1 本で走る)
55/** 今の色。setTheme で中身が入れ替わる(同じ物を指し続けるので、import した C のままでよい) */
56export const C: Palette = { ...DARK }
57let isLightNow = false
58
59export const isLight = () => isLightNow
60
61/** /config の theme の値が明るい系か('light'・'light-daltonized'・'light-ansi' など) */
62export const isLightTheme = (value: unknown) => typeof value === 'string' && value.startsWith('light')
63
64export function setTheme(light: boolean) {
65  isLightNow = light
66  Object.assign(C, light ? LIGHT : DARK)
67}
68
69/**
70 * 今の /config の theme を読んで色を合わせる。mod 自身の session.start の中で呼ぶ
71 * (同じイベントに matcher 無しのフックを 2 つ置くと読み込みを拒まれるので、ここでは on しない)。
72 * VS Code の配色は mod から読めないので、Claude Code の theme が light 系の時だけ明るくなる
73 */
74export async function readTheme($: EngineInterface) {
75  const rows = await $.config.list().catch(() => [])
76  setTheme(isLightTheme(rows.find(r => r.key === 'theme')?.value))
77}
78
79/** /config で theme が変わったら色を替えて描き直す。register の中で 1 回呼ぶ */
80export function followTheme(on: On) {
81  on('config.set', { key: 'theme' }, async ($, e, next) => {
82    const r = await next(e)
83    if (r.deny === undefined) {
84      setTheme(isLightTheme(r.value))
85      $.ui.invalidate('ui.render')
86    }
87    return r
88  })
89}
90
91// ---- 小物 ----
92
93/** 外から来る文字列の制御文字を落とす。エンジンは制御文字を含む Text の子や alt を拒み、ペインが閉じる */
94export const clean = (s: string) => s.replace(/[\t\n\r]+/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim()
95
96/** SVG の文字に入れる前のエスケープ(制御文字も落とす) */
97export const esc = (s: string) =>
98  clean(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
99
100// ---- 時間帯と季節 (日本時間 JST = UTC+9 固定。動いている環境のタイムゾーンには頼らない) ----
101
102export type DayPart = '朝' | '昼' | '夕' | '夜'
103export type Season = '春' | '夏' | '秋' | '冬'
104
105const jstDate = (now: number) => new Date(now + 9 * 3_600_000)
106
107/** 時間帯。朝 5-10 時 / 昼 10-16 時 / 夕 16-19 時 / 夜 19-5 時 */
108export const dayPart = (now: number): DayPart => {
109  const h = jstDate(now).getUTCHours()
110  return h >= 5 && h < 10 ? '朝' : h >= 10 && h < 16 ? '昼' : h >= 16 && h < 19 ? '夕' : '夜'
111}
112
113/** 季節。春 3-5 月 / 夏 6-8 月 / 秋 9-11 月 / 冬 12-2 月 */
114export const season = (now: number): Season => {
115  const m = jstDate(now).getUTCMonth() + 1
116  return m >= 3 && m <= 5 ? '春' : m >= 6 && m <= 8 ? '夏' : m >= 9 && m <= 11 ? '秋' : '冬'
117}
118
119/** n 文字を超えたら … で切る */
120export const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(0, n - 1))}…` : s)
121
122// ---- 記録を守る(docs/mod-guide.md の 9 節) ----
123
124/** JST の YYYY-MM-DD */
125export const jstDay = (now: number) => jstDate(now).toISOString().slice(0, 10)
126
127export type Backup = { day: string; value: unknown }
128
129// ponytail: $.fs に消す手が無いので、曜日ごとの 7 つの枠を上書きして 7 日分だけ残す。もっと残すなら枠を日付にして process で消す
130const backupPath = (dir: string, plugin: string, key: string, wd: number) => `${dir.replace(/[\\/]+$/, '')}/${plugin}/${key}-${wd}.json`
131
132/**
133 * $.store の key の値を、その日はじめて呼ばれた時だけ dir に控える(曜日ごとの枠で 7 日分)。
134 * dir が空なら何もしない(公開の既定は空。入れた人が backupDir を設定する)。書いたら true
135 */
136export async function backupDaily($: EngineInterface, dir: string, key: string, value: unknown, now: number): Promise<boolean> {
137  if (!dir.trim() || value === undefined) return false
138  const day = jstDay(now)
139  const path = backupPath(dir, $.plugin.name, key, jstDate(now).getUTCDay())
140  // 壊れた枠(途中まで・null)は無いのと同じに扱って上書きする
141  const old = await $.fs.read(path).then(t => parseBackup(t)?.day ?? '', () => '')
142  if (old === day) return false
143  await $.fs.write(path, JSON.stringify({ day, value } satisfies Backup))
144  return true
145}
146
147/** 控えの 1 枠を読む。壊れていれば undefined */
148export function parseBackup(text: string): Backup | undefined {
149  try {
150    const b = JSON.parse(text) as Partial<Backup> | null
151    return b && typeof b.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(b.day) && b.value !== undefined ? (b as Backup) : undefined
152  } catch {
153    return undefined
154  }
155}
156
157/** 控えの一覧。新しい日から順。読めない枠・壊れた枠は飛ばす */
158export async function listBackups($: EngineInterface, dir: string, key: string): Promise<Backup[]> {
159  if (!dir.trim()) return []
160  const all = await Promise.all(
161    [0, 1, 2, 3, 4, 5, 6].map(wd =>
162      $.fs.read(backupPath(dir, $.plugin.name, key, wd)).then(parseBackup, () => undefined),
163    ),
164  )
165  return all.filter((b): b is Backup => b !== undefined).sort((a, b) => b.day.localeCompare(a.day))
166}
167
168let loadedPrint: string | undefined
169let staleAt = 0
170let isStaleNow = false
171let toldStale = false
172
173const normPath = (p: string) => p.replace(/[\\/]+/g, '/').replace(/\/$/, '').toLowerCase()
174
175/** FNV-1a 32bit。中身が変わったかを見るだけ */
176function fnv(s: string) {
177  let h = 0x811c9dc5
178  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
179  return (h >>> 0).toString(16)
180}
181
182/**
183 * 今ディスクにある、この mod の指紋。plugin.json と hooks の .ts / .tsx の中身から作る
184 * (版の数字を上げずに中身だけ変えた時も変わる)。plugin.json が読めなければ undefined
185 */
186async function diskPrint($: EngineInterface): Promise<string | undefined> {
187  const root = $.plugin.root
188  let manifest: string | undefined
189  for (const p of [`${root}/.claude-plugin/plugin.json`, `${root}/plugin.json`]) {
190    manifest = await $.fs.read(p).then(t => t, () => undefined)
191    if (manifest !== undefined) break
192  }
193  if (manifest === undefined) return undefined
194  const names = await $.fs.list(`${root}/hooks`).then(
195    es => es.filter(e => e.kind === 'file' && /\.tsx?$/.test(e.name)).map(e => e.name).sort(),
196    () => [] as string[],
197  )
198  const parts = await Promise.all(names.map(n => $.fs.read(`${root}/hooks/${n}`).then(t => `${n}\n${t}`, () => n)))
199  return fnv([manifest, ...parts].join('\0'))
200}
201
202/**
203 * マーケットプレイスから入れた形(<plugins>/cache/<market>/<name>/<版>)なら、installed_plugins.json に
204 * 今入っている場所が自分と違うか。違えば新しい版が入っている。分からなければ false
205 */
206async function replacedInCache($: EngineInterface): Promise<boolean> {
207  const root = normPath($.plugin.root)
208  const at = root.lastIndexOf('/plugins/cache/')
209  if (at < 0) return false
210  const raw = await $.fs.read(`${$.plugin.root.slice(0, at + '/plugins'.length)}/installed_plugins.json`).then(t => t, () => '')
211  try {
212    const plugins = (JSON.parse(raw) as { plugins?: Record<string, { installPath?: unknown }[]> }).plugins ?? {}
213    const mine = Object.entries(plugins)
214      .filter(([k]) => k.startsWith(`${$.plugin.name}@`))
215      .flatMap(([, v]) => (Array.isArray(v) ? v : []))
216      .map(e => (typeof e.installPath === 'string' ? normPath(e.installPath) : ''))
217    return mine.length > 0 && !mine.includes(root)
218  } catch {
219    return false
220  }
221}
222
223/** 読み込んだ時の指紋を覚える。mod の session.start で 1 回呼ぶ */
224export async function rememberVersion($: EngineInterface) {
225  loadedPrint ??= await diskPrint($)
226}
227
228/**
229 * 新しい版が入ったのに、このセッションはまだ古い版で動いているか。
230 * フォルダから読ませている時は中身の指紋が読み込んだ時と違えば、マーケットプレイスから入れた時は
231 * 入っている場所が自分でなくなれば true。読み直すのは 60 秒に 1 回まで。分からない時は false
232 */
233export async function isStale($: EngineInterface, now: number): Promise<boolean> {
234  if (isStaleNow || now - staleAt < 60_000) return isStaleNow
235  staleAt = now
236  if (await replacedInCache($)) return (isStaleNow = true)
237  if (loadedPrint === undefined) return false
238  const p = await diskPrint($)
239  isStaleNow = p !== undefined && p !== loadedPrint
240  return isStaleNow
241}
242
243/**
244 * $.store に書いてよいか。古い版のままなら書かず、1 回だけトーストで開き直しを頼む
245 * (古い版は新しい版の欄を知らないので、書くと消してしまう)
246 */
247export async function mayWrite($: EngineInterface, now: number): Promise<boolean> {
248  if (!(await isStale($, now))) return true
249  if (!toldStale) {
250    toldStale = true
251    $.ui.toast(`${$.plugin.name}: 新しい版が入りました。VS Code を開き直すまで記録は書きません`)
252  }
253  return false
254}
255
256// ---- SVG の部品 ----
257
258export const W = 380
259export const HEADER_H = 44
260
261/** SVG 全体の枠。地を塗って body を入れる。幅は W にそろえる */
262export const frame = (h: number, body: string, w = W) =>
263  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" font-family="sans-serif">` +
264  `<rect width="${w}" height="${h}" rx="10" fill="${C.bg}"/>${body}</svg>`
265
266/** 面(カード)。角丸 8、線 1 */
267export const panel = (x: number, y: number, w: number, h: number, fill = C.face) =>
268  `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="${fill}" stroke="${C.line}"/>`
269
270/**
271 * 共通の見出し帯(高さ HEADER_H)。左に色の縦線、左に題、右に添え書き。
272 * 下の細い線が左から伸びる
273 */
274export function header(title: string, sub = '', accent = C.blue, y = 0, w = W) {
275  const pad = 14
276  return [
277    `<rect x="0" y="${y}" width="${w}" height="${HEADER_H}" rx="10" fill="${C.face}"/>`,
278    `<rect x="0" y="${y + 8}" width="4" height="${HEADER_H - 16}" rx="2" fill="${accent}"/>`,
279    `<text x="${pad}" y="${y + 28}" font-size="16" font-weight="bold" fill="${C.text}">${esc(title)}</text>`,
280    sub === '' ? '' : `<text x="${w - pad}" y="${y + 28}" font-size="12" fill="${C.dim}" text-anchor="end">${esc(sub)}</text>`,
281    growBar(pad, y + HEADER_H - 3, w - pad * 2, 2, accent, 0, 0.35),
282  ].join('')
283}
284
285/** 横に伸びる棒。幅 0 から w まで伸びて止まる(shots.mts は終わりの値で撮る) */
286export const growBar = (x: number, y: number, w: number, h: number, fill: string, delay = 0, opacity = 1) =>
287  `<rect x="${x}" y="${y}" width="${Math.max(0, w)}" height="${h}" rx="${Math.min(3, h / 2)}" fill="${fill}"${opacity === 1 ? '' : ` opacity="${opacity}"`}>` +
288  `<animate attributeName="width" from="0" to="${Math.max(0, w)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
289
290/** 下から伸びる縦の棒(下端 bottom・高さ h) */
291export const growColumn = (x: number, bottom: number, w: number, h: number, fill: string, delay = 0) =>
292  `<rect x="${x}" y="${bottom - h}" width="${w}" height="${Math.max(0, h)}" rx="${Math.min(3, w / 2)}" fill="${fill}">` +
293  `<animate attributeName="y" from="${bottom}" to="${bottom - h}" begin="${delay}s" dur="0.6s" fill="freeze"/>` +
294  `<animate attributeName="height" from="0" to="${Math.max(0, h)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
295
296/** ふわっと出る。中身は SVG の断片 */
297export const fadeIn = (body: string, delay = 0) =>
298  `<g opacity="1"><animate attributeName="opacity" from="0" to="1" begin="${delay}s" dur="0.4s" fill="freeze"/>${body}</g>`
299
300/**
301 * 0 から value まで数字が増えて止まる。コマ送りの文字を重ね、最後のコマだけが残る。
302 * attrs は <text> に付ける属性(x・y・font-size・fill など)。format で桁区切りや単位を付ける
303 */
304export function countUp(value: number, attrs: string, format: (n: number) => string = n => String(n), delay = 0) {
305  const STEPS = 8
306  const d = 0.08
307  const last = `<text ${attrs} opacity="0">${esc(format(value))}<animate attributeName="opacity" from="0" to="1" begin="${(delay + (STEPS - 1) * d).toFixed(2)}s" dur="0.01s" fill="freeze"/></text>`
308  if (!Number.isFinite(value) || value === 0) return `<text ${attrs}>${esc(format(value))}</text>`
309  const frames = Array.from({ length: STEPS - 1 }, (_, k) => {
310    const n = Number.isInteger(value) ? Math.round((value * k) / (STEPS - 1)) : (value * k) / (STEPS - 1)
311    return `<text ${attrs} opacity="0">${esc(format(n))}<animate attributeName="opacity" values="1;1" begin="${(delay + k * d).toFixed(2)}s" dur="${d}s"/></text>`
312  })
313  return frames.join('') + last
314}
315
types/index.d.ts 40 lines
1export type TestRunnerStatus = 'pass' | 'fail' | 'stopped' | 'timeout'
2
3/** 1 回分の結果 */
4export type TestRunnerResult = {
5  status: TestRunnerStatus
6  command: string
7  /** 止めた・時間切れなら null */
8  exitCode: number | null
9  ms: number
10  finishedAt: number
11  /** 出力 (標準出力と標準エラーを来た順に) の末尾 60 行 */
12  tail: string
13  /** 出力から拾えた数。拾えなければ無し */
14  passed?: number
15  failed?: number
16  /** claude なら Claude が Bash / PowerShell で走らせた物を拾った結果。無ければボタン */
17  source?: 'claude'
18}
19
20export type TestRunnerRun =
21  | { kind: 'idle' }
22  | { kind: 'running'; command: string; startedAt: number }
23  | { kind: 'done'; result: TestRunnerResult }
24  /** コマンドを起動できなかった */
25  | { kind: 'error'; command: string; message: string }
26
27/** 履歴の 1 点。古い順、最大 20 */
28export type TestRunnerMark = { status: TestRunnerStatus; ms: number }
29
30declare module 'claude-code' {
31  interface PluginState {
32    'test-runner': {
33      run: TestRunnerRun
34      history: TestRunnerMark[]
35      /** ボタンの結果などの一言。空なら出さない */
36      note: string
37    }
38  }
39}
40