SLOPSHOPPER

mod-check

/mod-check でこの mod 集の mod がちゃんと動いているかを 1 回で確かめ、貼れる文にする。隣に並ぶ mod の版がセッション中に変わったら、入力欄の上の帯とトーストで開き直しを頼む。「点検表」タブで実画面の見え方に よい・だめ・まだ を付け、結果を貼る文にする

newpanebandcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-check
│ ┃ mod の点検 ✕ › fix the failing auth test and add an audit log call │ ┃ [ 点検 ] [ 点検表 ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ mod の点検 ⎿ Read 6 lines │ ┃ 全 48 本 入っている 1 コマンドあり 0 ⏺ Update(src/auth.ts) │ ┃ 予備名 0 見つからない 44 確かめられない 3 ⎿ Added 2 lines, removed 1 line │ ┃ 画面 terminal テーマ 分からない Claude ⏺ Bash(bun test) │ ┃ Code 2.1.289 ペイン なし ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ again ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ /modcheck ✻ Worked for 42s · done 4:20 PM │ ┃ の返事に同じ結果が文で出ています。そのままコ │ ┃ ピーして貼ってください › /mod-check │ ┃ │ ┃ ci-pane 見つからない │ ┃ edit-log 見つからない │ ┃ enc-guard 見つからない │ ┃ fail-log 見つからない │ ┃ git-pane 見つからない │ ┃ port-watch 見つからない │ ┃ pre-check 見つからない │ ┃ project-board 見つからない │ ┃ release-draft 見つからない │ ┃ rule-watch 見つからない │ ┃ run-guard 見つからない ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · mod の点検
[ 点検 ] [ 点検表 ] mod の点検 全 48 本 入っている 1 コマンドあり 0 予備名 0 見つからない 44 確かめられない 3 画面 terminal テーマ 分からない Claude Code 2.1.289 ペイン なし [ again ] /modcheck の返事に同じ結果が文で出ています。そのままコピーして貼ってく ださい ci-pane 見つからない edit-log 見つからない enc-guard 見つからない fail-log 見つからない git-pane 見つからない port-watch 見つからない pre-check 見つからない project-board 見つからない release-draft 見つからない rule-watch 見つからない run-guard 見つからない ship-guard 見つからない spec-board 見つからない test-runner 見つからない todo-board 見つからない agent-watch 見つからない cc-dash 見つからない compact-cue 見つからない devlog-pane 見つからない file-map 見つからない session-recap 見つからない session-timeline 見つからない token-meter 見つからない usage-history 見つからない usage-pane 見つからない work-bar 見つからない canvas-pane 見つからない done-chime 見つからない focus-timer 見つからない mod-hub 見つからない prompt-book 見つからない rule-switch 見つからない toast-inbox 見つからない aqua-pet 見つからない code-omikuji 見つからない daily-quest 見つからない dev-rpg 見つからない face-band 見つからない live-commentary 見つからない trophy-shelf 見つからない turn-gacha 見つからない turn-haiku 見つからない wait-games 見つからない week-card 見つからない mod-check コマンドなし state あり /mod-check は組み込みのコマンド danger-frame 確かめられない コマンドも state も無い long-turn-toast 確かめられない コマンドも state も無い reply-frame 確かめられない コマンドも state も無い
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 6 files
hooks/register.tsx 319 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { CheckMark, CheckReport } from '../types'
5import {
6  buildRows,
7  countLine,
8  countRows,
9  diffVersions,
10  envLine,
11  jstStamp,
12  kindTheme,
13  MODS,
14  parentDir,
15  reportText,
16  rowLine,
17  rowsSvg,
18  SELF,
19  siblingManifest,
20  summarySvg,
21  updateBand,
22  versionOf,
23} from './check'
24import { checklistText, countMarks, ITEMS, MARK_LABEL, markCountLine, markOf, marksFrom, withMark } from './checklist'
25import { probeStates } from './probe'
26import { C, clean, followTheme, mayWrite, readTheme, rememberVersion } from './style'
27
28type $ = EngineInterface
29
30const PANE = 'mod-check'
31const TITLE = 'mod の点検'
32const DESCRIPTION = 'この mod 集の mod がちゃんと動いているかを 1 回で確かめる'
33/** 点検表の印を残す $.store の key */
34const STORE_KEY = 'checklist'
35
36const REPORT = atom({ plugin: 'mod-check', key: 'report' } as const, null as CheckReport | null)
37const SEEN = atom({ plugin: 'mod-check', key: 'seen' } as const, [])
38const SURFACE = atom({ plugin: 'mod-check', key: 'surface' } as const, '')
39const UPDATED = atom({ plugin: 'mod-check', key: 'updated' } as const, [])
40const TAB = atom({ plugin: 'mod-check', key: 'tab' } as const, 'check')
41const MARKS = atom({ plugin: 'mod-check', key: 'marks' } as const, {})
42const LIST_TEXT = atom({ plugin: 'mod-check', key: 'listText' } as const, '')
43
44/** 調べる。コマンドの一覧・state の読み・設定・版・自分のペインだけで、他の mod のコマンドは走らせない */
45async function check($: $): Promise<CheckReport> {
46  const [commands, states, config, version, panes, surfaces] = await Promise.all([
47    $.command.list().catch(() => []),
48    probeStates($),
49    $.config.list().catch(() => []),
50    $.session.version().catch(() => undefined),
51    $.ui.panes().catch(() => []),
52    $.session.surfaces().catch(() => []),
53  ])
54  const theme = config.find(r => r.key === 'theme')?.value
55  const report: CheckReport = {
56    at: await $.clock.now(),
57    env: {
58      surface: surfaces.length > 0 ? surfaces.join(' ') : await read($, SURFACE),
59      theme: typeof theme === 'string' ? clean(theme) : '',
60      engine: clean(version?.version ?? ''),
61      panes: panes.map(p => clean(p.id)),
62    },
63    // 自分は動いているので、自分の state は書かれている扱い
64    rows: buildRows(commands, { ...states, [SELF]: true }, await read($, SEEN)),
65    updated: await read($, UPDATED),
66  }
67  await update($, REPORT, () => report)
68  return report
69}
70
71// ---- 版の見張り(共通の rememberVersion / isStale は自分 1 本分なので、全 mod の分はここで持つ) ----
72
73/** session.start で読んだ版。読み直しで消えてよい(読み直した時はその版が新しい基準) */
74let baseline: Record<string, string> = {}
75let checkedAt = 0
76let isToldUpdate = false
77
78/** 隣に並ぶ全 mod の版。読めない mod(マーケットプレイスから入れた形など)は入れない */
79async function readVersions($: $): Promise<Record<string, string>> {
80  const root = $.plugin.root
81  if (parentDir(root) === '') return {}
82  const got = await Promise.all(MODS.map(m => $.fs.read(siblingManifest(root, m.name)).then(versionOf, () => undefined)))
83  return Object.fromEntries(MODS.flatMap((m, i) => (got[i] === undefined ? [] : [[m.name, got[i]] as const])))
84}
85
86/** ターンの終わりに読み直す(60 秒に 1 回まで)。変わった mod があれば帯に出し、トーストを 1 回出す */
87async function recheck($: $) {
88  const now = await $.clock.now()
89  if (now - checkedAt < 60_000) return
90  checkedAt = now
91  const updated = diffVersions(baseline, await readVersions($))
92  if (JSON.stringify(updated) !== JSON.stringify(await read($, UPDATED))) await update($, UPDATED, () => updated)
93  if (updated.length > 0 && !isToldUpdate) {
94    isToldUpdate = true
95    $.ui.toast(updateBand(updated), { timeoutMs: 10_000 })
96  }
97}
98
99// ---- 点検表 ----
100
101/** store の印を読み直して画面に出す。読めなければ今の印のまま */
102async function loadMarks($: $) {
103  const raw = await $.store.get(STORE_KEY).catch(() => undefined)
104  if (raw !== undefined) await update($, MARKS, () => marksFrom(raw))
105}
106
107/** 1 項目の印を付ける。書く直前に読み直し、知らない欄を残して書く。古い版のままなら書かない */
108async function setMark($: $, id: string, mark: CheckMark) {
109  if (!(await mayWrite($, await $.clock.now()))) return
110  let raw: unknown
111  try {
112    raw = await $.store.get(STORE_KEY)
113  } catch {
114    $.ui.toast('点検表の記録を読めなかったので、印を残していません', { timeoutMs: 5000 })
115    return
116  }
117  const next = withMark(raw, id, mark)
118  const isSaved = await $.store.set(STORE_KEY, next).then(
119    () => true,
120    () => false,
121  )
122  if (!isSaved) $.ui.toast('点検表の記録を書けませんでした', { timeoutMs: 5000 })
123  else await update($, MARKS, () => marksFrom(next))
124}
125
126/** 「点検の結果を貼る文」。ペインに出し、コピーも試す */
127async function paste($: $, surface: RenderSurface) {
128  const text = checklistText(await read($, MARKS), jstStamp(await $.clock.now()))
129  await update($, LIST_TEXT, () => text)
130  const r = await $.ui.copy({ text, surface }).catch(() => ({ isCopied: false }))
131  $.ui.toast(r.isCopied ? '点検の結果をコピーしました。そのまま貼ってください' : 'コピーできませんでした。下に出した文を選んでコピーしてください', { timeoutMs: 5000 })
132}
133
134const markColor = (m: CheckMark) => (m === 'good' ? C.green : m === 'bad' ? C.red : C.dim)
135
136// /mod-check を自分で取れたか (取れなければ /modcheck にして、/mod-check は横取りしない)
137let ownsMain = false
138
139const openPane = async ($: $) => {
140  const report = await check($)
141  await $.ui.open({ id: PANE, title: TITLE })
142  return { text: reportText(report) }
143}
144
145export const register: Register = on => {
146  followTheme(on)
147
148  // 自分より後に読み込まれる mod の版と、拒まれたかを覚える(止めはしない)
149  on('plugin.register', async ($, e, next) => {
150    const result = await next(e)
151    const row = { name: clean(e.name), version: clean(e.version ?? ''), refused: result.refuse === undefined ? '' : clean(result.refuse) }
152    await update($, SEEN, list => [...list.filter(s => s.name !== row.name), row]).catch(() => {})
153    return result
154  })
155
156  on('session.start', async ($, e, next) => {
157    const got = await $.command.register({ name: 'mod-check', description: DESCRIPTION }).catch(() => undefined)
158    ownsMain = got?.command === 'mod-check'
159    if (!ownsMain) await $.command.register({ name: 'modcheck', description: DESCRIPTION }).catch(() => {})
160    if (e.surface) await update($, SURFACE, () => e.surface ?? '').catch(() => {})
161    await readTheme($)
162    await rememberVersion($)
163    baseline = await readVersions($)
164    checkedAt = await $.clock.now()
165    isToldUpdate = false
166    await update($, UPDATED, () => []).catch(() => {})
167    await loadMarks($)
168    // 途中で読み込まれた時も帯を聞きに来るように (docs/mod-guide.md の 3 節)
169    $.ui.invalidate('ui.render')
170    return next(e)
171  })
172
173  on('command.run', { command: 'mod-check' }, ($, e, next) => (ownsMain ? openPane($) : next(e))).catch(($, e, next) => next(e))
174  // 本来の名前を持てた時は予備名を登録していないので、同じ名前の別のコマンドを横取りしない
175  on('command.run', { command: 'modcheck' }, ($, e, next) => (ownsMain ? next(e) : openPane($))).catch(($, e, next) => next(e))
176
177  on('turn.complete', async ($, e, next) => {
178    const done = await next(e)
179    if (e.agentId === undefined) await recheck($).catch(() => {})
180    return done
181  }).catch(($, e, next) => next(e))
182
183  // 入力欄の上の帯。版が変わった mod が無い時は下の mod に任せる
184  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
185    if (e.props.hasSurvey) return next(e)
186    const updated = await read($, UPDATED)
187    if (updated.length === 0) return next(e)
188    const below = await next(e)
189    const { Box, Text } = $.ui.resolve(e)
190    const band = (
191      <Box key="mod-update" flexShrink={0}>
192        <Text color="warning">{updateBand(updated)}</Text>
193      </Box>
194    )
195    if (below.type === 'engine') return band
196    return (
197      <Box flexDirection="column">
198        {band}
199        <Box key="below">{below}</Box>
200      </Box>
201    )
202  }).catch(($, e, next) => next(e))
203
204  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
205    const t = $.ui.resolve(e)
206    const { Box, Text, Button, Code } = t
207    const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : undefined
208    const tab = await read($, TAB)
209    const updated = await read($, UPDATED)
210
211    // タブは選んでいる物を普通に、ほかを控えめに
212    const tabs = (
213      <Box key="tabs" flexDirection="row" gap={1}>
214        <Button key="tab:check" label="点検" dimColor={tab !== 'check'} onPress={() => void update($, TAB, () => 'check')} />
215        <Button
216          key="tab:list"
217          label="点検表"
218          dimColor={tab !== 'list'}
219          onPress={() =>
220            void (async () => {
221              await update($, TAB, () => 'list')
222              await loadMarks($)
223            })()
224          }
225        />
226      </Box>
227    )
228    const notice =
229      updated.length > 0 ? (
230        <Text key="updated" color="warning">
231          {updateBand(updated)}
232        </Text>
233      ) : null
234
235    if (tab === 'list') {
236      const marks = await read($, MARKS)
237      const listText = await read($, LIST_TEXT)
238      return (
239        <Box flexDirection="column" gap={1}>
240          {notice}
241          {tabs}
242          <Text key="count" bold>
243            {markCountLine(countMarks(marks), ITEMS.length)}
244          </Text>
245          <Text key="how" dimColor>
246            実画面で見て、項目ごとに よい・だめ・まだ を付けてください。印はこの mod の記録に残ります
247          </Text>
248          <Button key="paste" label="点検の結果を貼る文" variant="primary" onPress={p => void paste($, p.surface)} />
249          {listText !== '' ? <Code key="pasted" source={listText} /> : null}
250          {ITEMS.map(it => {
251            const mark = markOf(marks, it.id)
252            return (
253              <Box key={`item:${it.id}`} flexDirection="column" borderStyle="round" borderColor={markColor(mark)} paddingX={1}>
254                <Text bold color={markColor(mark)}>{`${it.mod}${it.command === '' ? '' : ` ${it.command}`}  ${MARK_LABEL[mark]}`}</Text>
255                <Text dimColor>{it.look}</Text>
256                <Box flexDirection="row" gap={1}>
257                  {(['good', 'bad', 'todo'] as const).map(m => (
258                    <Button key={`mark:${it.id}:${m}`} label={MARK_LABEL[m]} dimColor={m !== mark} onPress={() => void setMark($, it.id, m)} />
259                  ))}
260                </Box>
261              </Box>
262            )
263          })}
264        </Box>
265      )
266    }
267
268    const report = await read($, REPORT)
269    const again = (
270      <Button key="again" variant="primary" onPress={() => void check($).catch(() => {})}>
271        <Text>調べ直す</Text>
272      </Button>
273    )
274    if (report === null) {
275      return (
276        <Box flexDirection="column" gap={1}>
277          {notice}
278          {tabs}
279          <Text bold>mod の点検</Text>
280          <Text dimColor>まだ調べていません</Text>
281          {again}
282        </Box>
283      )
284    }
285    // 画面の種類は今描いている物がいちばん確か
286    const live = { ...report, env: { ...report.env, surface: e.surface } }
287    const counts = countRows(report.rows)
288
289    return (
290      <Box flexDirection="column" gap={1}>
291        {notice}
292        {tabs}
293        {Svg ? (
294          <Svg key="summary" source={summarySvg(live)} alt={countLine(counts)} />
295        ) : (
296          <Box key="summary" flexDirection="column">
297            <Text bold>mod の点検</Text>
298            <Text>{countLine(counts)}</Text>
299            <Text dimColor>{envLine(live.env)}</Text>
300          </Box>
301        )}
302        {again}
303        <Text dimColor>{`/${ownsMain ? 'mod-check' : 'modcheck'} の返事に同じ結果が文で出ています。そのままコピーして貼ってください`}</Text>
304        {Svg ? (
305          <Svg key="rows" source={rowsSvg(report.rows)} alt={`mod ごとの結果 ${report.rows.length} 本`} />
306        ) : (
307          <Box key="rows" flexDirection="column">
308            {report.rows.map(r => (
309              <Text key={`row:${r.name}`} color={kindTheme(r.kind)}>
310                {rowLine(r)}
311              </Text>
312            ))}
313          </Box>
314        )}
315      </Box>
316    )
317  })
318}
319
hooks/check.ts 323 lines
1// mod の表と、見つかった物から判定を作る純関数・SVG。$ に触らない
2import type { CheckEnv, CheckKind, CheckReport, CheckRow, CheckSeen, ModUpdate } from '../types'
3import { C, clean, countUp, esc, fadeIn, frame, growBar, header, HEADER_H, panel, short } from './style'
4
5export const SELF = 'mod-check'
6
7/** 並べるカテゴリーの順(mod-hub と同じ) */
8export const CATEGORIES = ['開発', '状況', '道具', '遊び', 'その他'] as const
9export type Category = (typeof CATEGORIES)[number]
10
11export type ModEntry = {
12  name: string
13  category: Category
14  /** 本来のコマンド(スラッシュ無し)。持たない mod は無し */
15  main?: string
16  /** 本来の名前が取られた時の予備の名前 */
17  spare?: string
18  /** 外に見せる $.state を持つか(hooks/probe.ts に読みが並ぶ) */
19  hasState: boolean
20}
21
22// この mod 集の全 mod(todo-scan は todo-board にまとめたので入れない)。
23// 各 mod の .claude-plugin/plugin.json の name と、hooks/register.tsx の $.command.register から作った。
24// カテゴリーは mod-hub の hub.ts の表に合わせ、表に無い物はここで決めた。mod を足したらここにも足す
25export const MODS: readonly ModEntry[] = [
26  { name: 'agent-watch', category: '状況', main: 'agents', spare: 'agent-watch', hasState: true },
27  { name: 'aqua-pet', category: '遊び', main: 'aquarium', hasState: true },
28  { name: 'canvas-pane', category: '道具', main: 'canvas', spare: 'canvas-pane', hasState: true },
29  { name: 'cc-dash', category: '状況', main: 'dash', hasState: true },
30  { name: 'ci-pane', category: '開発', main: 'ci', spare: 'ci-pane', hasState: true },
31  { name: 'code-omikuji', category: '遊び', main: 'omikuji', spare: 'code-omikuji', hasState: true },
32  { name: 'compact-cue', category: '状況', main: 'cue', spare: 'compact-cue', hasState: true },
33  { name: 'daily-quest', category: '遊び', main: 'quest', spare: 'daily-quest', hasState: true },
34  { name: 'danger-frame', category: '開発', hasState: false },
35  { name: 'dev-rpg', category: '遊び', main: 'rpg', spare: 'dev-rpg', hasState: true },
36  { name: 'devlog-pane', category: '状況', main: 'devlog', hasState: true },
37  { name: 'done-chime', category: '道具', main: 'chime', spare: 'done-chime', hasState: true },
38  { name: 'edit-log', category: '開発', main: 'edits', spare: 'edit-log', hasState: true },
39  { name: 'enc-guard', category: '開発', main: 'enc', spare: 'enc-guard', hasState: true },
40  { name: 'face-band', category: '遊び', main: 'face', spare: 'face-band', hasState: true },
41  { name: 'fail-log', category: '開発', main: 'errors', spare: 'fail-log', hasState: true },
42  { name: 'file-map', category: '状況', main: 'filemap', spare: 'file-map', hasState: true },
43  { name: 'focus-timer', category: '道具', main: 'focus', spare: 'focus-timer', hasState: true },
44  { name: 'git-pane', category: '開発', main: 'git', spare: 'gitpane', hasState: true },
45  { name: 'live-commentary', category: '遊び', main: 'jikkyo', spare: 'live-commentary', hasState: true },
46  { name: 'long-turn-toast', category: '状況', hasState: false },
47  { name: 'mod-check', category: '道具', main: 'mod-check', spare: 'modcheck', hasState: true },
48  { name: 'mod-hub', category: '道具', main: 'mod', spare: 'mod-hub', hasState: true },
49  { name: 'port-watch', category: '開発', main: 'ports', spare: 'port-watch', hasState: true },
50  { name: 'pre-check', category: '開発', main: 'precheck', spare: 'pre-check', hasState: true },
51  { name: 'project-board', category: '開発', main: 'projects', hasState: true },
52  { name: 'prompt-book', category: '道具', main: 'prompts', spare: 'prompt-book', hasState: true },
53  { name: 'release-draft', category: '開発', main: 'release', spare: 'release-draft', hasState: true },
54  { name: 'reply-frame', category: '道具', hasState: false },
55  { name: 'rule-switch', category: '道具', main: 'rules', spare: 'rule-switch', hasState: true },
56  { name: 'rule-watch', category: '開発', main: 'rulewatch', spare: 'rule-watch', hasState: true },
57  { name: 'run-guard', category: '開発', main: 'runguard', spare: 'run-guard', hasState: true },
58  { name: 'session-recap', category: '状況', main: 'recap', spare: 'session-recap', hasState: true },
59  { name: 'session-timeline', category: '状況', main: 'timeline', spare: 'session-timeline', hasState: true },
60  { name: 'ship-guard', category: '開発', main: 'guard', spare: 'ship-guard', hasState: true },
61  { name: 'spec-board', category: '開発', main: 'specs', spare: 'spec-board', hasState: true },
62  { name: 'test-runner', category: '開発', main: 'testrun', spare: 'test-runner', hasState: true },
63  { name: 'toast-inbox', category: '道具', main: 'inbox', spare: 'toast-inbox', hasState: true },
64  { name: 'todo-board', category: '開発', main: 'todos', spare: 'todo-board', hasState: true },
65  { name: 'token-meter', category: '状況', main: 'tokens', spare: 'token-meter', hasState: true },
66  { name: 'trophy-shelf', category: '遊び', main: 'trophies', spare: 'trophy-shelf', hasState: true },
67  { name: 'turn-gacha', category: '遊び', main: 'gacha', spare: 'turn-gacha', hasState: true },
68  { name: 'turn-haiku', category: '遊び', main: 'haiku', spare: 'turn-haiku', hasState: true },
69  { name: 'usage-history', category: '状況', main: 'history', spare: 'usage-history', hasState: true },
70  { name: 'usage-pane', category: '状況', main: 'usage-pane', hasState: true },
71  { name: 'wait-games', category: '遊び', main: 'games', spare: 'wait-games', hasState: true },
72  { name: 'week-card', category: '遊び', main: 'week', spare: 'week-card', hasState: true },
73  { name: 'work-bar', category: '状況', main: 'work-bar', hasState: true },
74]
75
76/** $.command.list() の 1 行のうち、見る所だけ */
77export type CommandLike = { name: string; source: string; plugin?: string }
78
79/** 'name@market' の name */
80export const baseName = (plugin: string) => plugin.split('@')[0] ?? plugin
81
82/** 本来の名前を別の誰かが持っている時の添え書き */
83const takenBy = (name: string, c: CommandLike) =>
84  c.source !== 'plugin'
85    ? `/${name} は組み込みのコマンド`
86    : `/${name} は ${c.plugin === undefined ? '別の plugin' : clean(baseName(c.plugin))} が使っている`
87
88/**
89 * mod 1 本の判定。
90 * - コマンドは plugin 欄で持ち主を見る。plugin 欄の無い plugin のコマンドは、その mod の物とみなして添え書きを付ける
91 * - state の version 0 は「入っていない」とは言えない(開いた時にしか書かない mod もある)ので、書かれていた時だけ根拠にする
92 * - plugin.register は mod-check より後に読み込まれた mod しか見えないので、見えた時だけ添える
93 */
94export function classify(mod: ModEntry, commands: readonly CommandLike[], stateSeen: boolean, seen?: CheckSeen): CheckRow {
95  const row = (kind: CheckKind, command = '', note = ''): CheckRow => ({
96    name: mod.name,
97    category: mod.category,
98    kind,
99    command,
100    hasState: mod.hasState,
101    stateSeen: mod.hasState && stateSeen,
102    version: seen ? clean(seen.version) : '',
103    note,
104  })
105  if (seen && seen.refused !== '') return row('refused', '', short(`読み込みを拒まれた: ${clean(seen.refused)}`, 60))
106
107  const find = (name?: string) => (name === undefined ? undefined : commands.find(c => c.name === name))
108  const isMine = (c?: CommandLike) => c !== undefined && c.source === 'plugin' && (c.plugin === undefined || baseName(c.plugin) === mod.name)
109  const unsure = (c: CommandLike) => (c.plugin === undefined ? '持ち主が分からない' : '')
110  const main = find(mod.main)
111  const spare = find(mod.spare)
112  // 本来の名前を別の誰かが持っている時の添え書き
113  const taken = main && !isMine(main) ? takenBy(main.name, main) : ''
114
115  if (main && isMine(main)) return row('ok', main.name, unsure(main))
116  if (spare && isMine(spare)) return row('spare', spare.name, [taken || `/${mod.main} は登録できなかった`, unsure(spare)].filter(s => s !== '').join(' '))
117  const alive = (mod.hasState && stateSeen) || seen !== undefined
118  if (mod.main === undefined) return alive ? row('ok') : row('unknown', '', 'コマンドも state も無い')
119  if (alive) return row('nocmd', '', taken || 'コマンドが登録されていない')
120  return row('missing', '', taken)
121}
122
123export const KIND_LABEL: Record<CheckKind, string> = {
124  ok: '入っている',
125  spare: '予備名',
126  nocmd: 'コマンドなし',
127  refused: '拒まれた',
128  missing: '見つからない',
129  unknown: '確かめられない',
130}
131
132/** 問題のある物を先に */
133const KIND_ORDER: CheckKind[] = ['refused', 'missing', 'nocmd', 'spare', 'unknown', 'ok']
134
135export const sortRows = (rows: readonly CheckRow[]) =>
136  [...rows].sort(
137    (a, b) =>
138      KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) ||
139      CATEGORIES.indexOf(a.category as Category) - CATEGORIES.indexOf(b.category as Category) ||
140      a.name.localeCompare(b.name),
141  )
142
143/** 見つかった物から全 mod の判定を作る(問題のある物が先) */
144export function buildRows(commands: readonly CommandLike[], stateSeen: Readonly<Record<string, boolean>>, seen: readonly CheckSeen[]): CheckRow[] {
145  return sortRows(MODS.map(m => classify(m, commands, stateSeen[m.name] === true, seen.find(s => s.name === m.name))))
146}
147
148export type Counts = { total: number; installed: number; withCommand: number; spare: number; missing: number; unknown: number }
149
150export function countRows(rows: readonly CheckRow[]): Counts {
151  const n = (f: (r: CheckRow) => boolean) => rows.filter(f).length
152  return {
153    total: rows.length,
154    installed: n(r => r.kind === 'ok' || r.kind === 'spare' || r.kind === 'nocmd'),
155    withCommand: n(r => r.command !== ''),
156    spare: n(r => r.kind === 'spare'),
157    missing: n(r => r.kind === 'missing' || r.kind === 'refused'),
158    unknown: n(r => r.kind === 'unknown'),
159  }
160}
161
162/** JST の YYYY-MM-DD HH:MM */
163export const jstStamp = (at: number) => new Date(at + 9 * 3_600_000).toISOString().slice(0, 16).replace('T', ' ')
164
165export const themeLabel = (theme: string) => (theme === '' ? '分からない' : `${theme.startsWith('light') ? '明るい' : '暗い'} (${clean(theme)})`)
166
167/** 見つからない・拒まれた行では state が無いのは当たり前なので書かない */
168const stateWord = (r: CheckRow) =>
169  !r.hasState || r.kind === 'missing' || r.kind === 'refused' ? '' : r.stateSeen ? 'state あり' : 'state まだ'
170
171/** 1 行 1 mod の短い表(貼ってもらう文) */
172export function rowLine(r: CheckRow): string {
173  return [r.name.padEnd(16), KIND_LABEL[r.kind], r.command === '' ? '' : `/${r.command}`, stateWord(r), r.version === '' ? '' : `v${r.version}`, r.note]
174    .filter(s => s !== '')
175    .join(' ')
176}
177
178export function envLine(env: CheckEnv): string {
179  return [
180    `画面 ${env.surface === '' ? '分からない' : clean(env.surface)}`,
181    `テーマ ${themeLabel(env.theme)}`,
182    `Claude Code ${env.engine === '' ? '分からない' : clean(env.engine)}`,
183    `ペイン ${env.panes.length === 0 ? 'なし' : env.panes.map(clean).join(' ')}`,
184  ].join('  ')
185}
186
187export function countLine(c: Counts): string {
188  return `全 ${c.total} 本  入っている ${c.installed}  コマンドあり ${c.withCommand}  予備名 ${c.spare}  見つからない ${c.missing}  確かめられない ${c.unknown}`
189}
190
191/** /mod-check の返事。コピーしてそのまま貼れる形 */
192export function reportText(report: CheckReport): string {
193  const updated = report.updated ?? []
194  return [
195    `mod-check の結果 ${jstStamp(report.at)} (日本時間)`,
196    envLine(report.env),
197    countLine(countRows(report.rows)),
198    ...(updated.length === 0 ? [] : [updatedLine(updated)]),
199    ...report.rows.map(rowLine),
200  ].join('\n')
201}
202
203// ---- 版の見張り ----
204
205/** フォルダの 1 つ上(末尾の区切りは無視)。上が無ければ空 */
206export const parentDir = (dir: string) => {
207  const d = dir.replace(/[\\/]+$/, '')
208  const at = Math.max(d.lastIndexOf('/'), d.lastIndexOf('\\'))
209  return at <= 0 ? '' : d.slice(0, at)
210}
211
212/** 隣に並ぶ mod の plugin.json(依頼者の mods/<name> の並び。マーケットプレイスの形では無い) */
213export const siblingManifest = (root: string, name: string) => `${parentDir(root)}/${name}/.claude-plugin/plugin.json`
214
215/** plugin.json の中身から版。読めなければ undefined */
216export function versionOf(text: string): string | undefined {
217  try {
218    const v = (JSON.parse(text) as { version?: unknown }).version
219    return typeof v === 'string' && v !== '' ? clean(v) : undefined
220  } catch {
221    return undefined
222  }
223}
224
225/** 覚えた版と今の版を比べ、変わった物(表の順)。どちらかが読めなかった mod は飛ばす */
226export const diffVersions = (loaded: Readonly<Record<string, string>>, now: Readonly<Record<string, string>>): ModUpdate[] =>
227  MODS.flatMap(m => {
228    const from = loaded[m.name]
229    const to = now[m.name]
230    return from !== undefined && to !== undefined && from !== to ? [{ name: m.name, from, to }] : []
231  })
232
233const updateWord = (u: ModUpdate) => `${u.name} ${u.from}→${u.to}`
234
235/** 帯とトーストの文 */
236export const updateBand = (updated: readonly ModUpdate[]) =>
237  `mod が新しくなりました(${updated[0] ? updateWord(updated[0]) : ''}${updated.length > 1 ? ` ほか ${updated.length - 1} 本` : ''})。VS Code を開き直してください`
238
239/** 貼る文とペインの 1 行 */
240export const updatedLine = (updated: readonly ModUpdate[]) => `開き直し待ち ${updated.map(updateWord).join('  ')}`
241
242// ---- 色 ----
243
244export const kindColor = (k: CheckKind) =>
245  k === 'ok' ? C.green : k === 'spare' ? C.yellow : k === 'nocmd' ? C.orange : k === 'unknown' ? C.dim : C.red
246
247/** terminal の Text に渡すテーマの名前 */
248export const kindTheme = (k: CheckKind) =>
249  k === 'ok' ? 'success' : k === 'spare' || k === 'nocmd' ? 'warning' : k === 'unknown' ? 'subtle' : 'error'
250
251// ---- SVG ----
252
253/** 上の札: 4 つの数と、画面・テーマ・版 */
254export function summarySvg(report: CheckReport, surface = report.env.surface): string {
255  const c = countRows(report.rows)
256  const tiles: [string, number, string][] = [
257    ['入っている', c.installed, C.green],
258    ['コマンドあり', c.withCommand, C.blue],
259    ['予備名', c.spare, C.yellow],
260    ['見つからない', c.missing, C.red],
261  ]
262  const tw = 82
263  const gap = 8
264  const x0 = 14
265  const ty = HEADER_TOP
266  const tileSvg = tiles
267    .map(([label, n, color], i) => {
268      const x = x0 + i * (tw + gap)
269      return (
270        panel(x, ty, tw, 58) +
271        `<rect x="${x}" y="${ty}" width="${tw}" height="4" rx="2" fill="${color}"/>` +
272        countUp(n, `x="${x + tw / 2}" y="${ty + 32}" font-size="22" font-weight="bold" fill="${color}" text-anchor="middle"`, String, i * 0.08) +
273        `<text x="${x + tw / 2}" y="${ty + 50}" font-size="12" fill="${C.dim}" text-anchor="middle">${esc(label)}</text>`
274      )
275    })
276    .join('')
277  const env = [
278    ['画面', surface === '' ? '分からない' : surface],
279    ['テーマ', themeLabel(report.env.theme)],
280    ['Claude Code', report.env.engine === '' ? '分からない' : report.env.engine],
281    ['確かめられない', `${c.unknown} 本 (コマンドも state も無い mod)`],
282  ]
283  const ey = ty + 58 + 24
284  const envSvg = env
285    .map(
286      ([k, v], i) =>
287        `<text x="${x0}" y="${ey + i * 20}" font-size="12" fill="${C.dim}">${esc(k ?? '')}</text>` +
288        `<text x="${x0 + 100}" y="${ey + i * 20}" font-size="12" fill="${C.text}">${esc(short(v ?? '', 34))}</text>`,
289    )
290    .join('')
291  const h = ey + (env.length - 1) * 20 + 14
292  const ok = c.missing === 0
293  return frame(h, header('mod の点検', `全 ${c.total} 本  ${jstStamp(report.at)}`, ok ? C.green : C.red) + tileSvg + fadeIn(envSvg, 0.3))
294}
295
296const HEADER_TOP = HEADER_H + 12
297const ROW_H = 24
298const NOTE_H = 18
299
300/** mod ごとの行。問題のある物が上。添え書きのある行は 2 段 */
301export function rowsSvg(rows: readonly CheckRow[]): string {
302  let y = 10
303  const body = rows
304    .map((r, i) => {
305      const color = kindColor(r.kind)
306      const top = y
307      const h = ROW_H + (r.note === '' ? 0 : NOTE_H)
308      y += h + 4
309      const right = [r.command === '' ? '' : `/${r.command}`, stateWord(r) === 'state まだ' ? 'state まだ' : ''].filter(s => s !== '').join('  ')
310      return fadeIn(
311        panel(10, top, 360, h) +
312          growBar(10, top + 4, 4, h - 8, color, 0) +
313          `<text x="22" y="${top + 16}" font-size="13" fill="${C.text}">${esc(short(r.name, 18))}</text>` +
314          `<text x="152" y="${top + 16}" font-size="12" font-weight="bold" fill="${color}">${esc(KIND_LABEL[r.kind])}</text>` +
315          `<text x="362" y="${top + 16}" font-size="12" fill="${C.dim}" text-anchor="end">${esc(short(right, 26))}</text>` +
316          (r.note === '' ? '' : `<text x="22" y="${top + 33}" font-size="12" fill="${C.dim}">${esc(short(r.note, 44))}</text>`),
317        Math.min(i, 20) * 0.02,
318      )
319    })
320    .join('')
321  return frame(Math.max(y + 6, 40), body)
322}
323
hooks/checklist.ts 92 lines
1// 実画面の点検表。項目は手で書く(足す時は ITEMS に 1 行足すだけ。id は変えない: 付けた印の鍵になる)。どれも純関数
2import type { CheckMark } from '../types'
3import { clean } from './style'
4
5export type ChecklistItem = {
6  /** 印を残す鍵。変えない */
7  id: string
8  /** どの mod か(全体に効く物は「全体」) */
9  mod: string
10  /** 開くコマンド。無ければ空 */
11  command: string
12  /** 何を見るかを 1 文で */
13  look: string
14}
15
16export const ITEMS: readonly ChecklistItem[] = [
17  { id: 'trophies', mod: 'trophy-shelf', command: '/trophies', look: 'トロフィーの棚が崩れずに並び、取った物と取っていない物の見分けが付く' },
18  { id: 'quest', mod: 'daily-quest', command: '/quest', look: '今日のお題と連続日数が読め、済んだ物に印が付く' },
19  { id: 'errors', mod: 'fail-log', command: '/errors', look: '失敗の一覧が新しい順に並び、行の文字が切れすぎない' },
20  { id: 'dash-sort', mod: 'cc-dash', command: '/dash', look: '並べ替えのボタンが狭いペイン(29 桁)でも崩れず、押すと順が変わる' },
21  { id: 'inbox-open', mod: 'toast-inbox', command: '/inbox', look: '行を押すと、そのトーストを出した元の mod のペインが開く' },
22  { id: 'face-night-winter', mod: 'face-band', command: '/face', look: '夜と冬の見た目(色・空)が昼や夏と違って見える' },
23  { id: 'buttons', mod: '全体', command: '', look: 'ボタンの主と脇(0.9.1): 主は 1 つだけ普通の見た目で、ほかは枠だけの控えめなボタン' },
24  { id: 'test-card', mod: 'test-runner', command: '/testrun', look: '色枠のカードに見出しと説明とボタンが収まり、結果で枠の色が変わる' },
25  { id: 'cue-band', mod: 'compact-cue', command: '/cue', look: '入力欄の上に区切りの帯が出て、文が 1 行に収まる' },
26  { id: 'ship-confirm', mod: 'ship-guard', command: '/guard', look: 'push や公開の前に確かめが出て、選んだ通りに進む・止まる' },
27  { id: 'rule-band', mod: 'rule-watch', command: '/rulewatch', look: '約束から外れた時に入力欄の上の帯が出て、次のターンで下りる' },
28  { id: 'run-guard', mod: 'run-guard', command: '/runguard', look: '窓を出す・音を鳴らす・隔離なしの headless を、実行の前に止める' },
29  { id: 'week-card', mod: 'week-card', command: '/week', look: '1 週間のカードに増えた物が並び、週報に貼る文が出る' },
30  { id: 'tank-deco', mod: 'aqua-pet', command: '/aquarium', look: '水槽のかざりを置く・外す・入れ替えるができ、底に描かれる' },
31  { id: 'rpg-dungeon', mod: 'dev-rpg', command: '/rpg', look: '冒険の書のダンジョンで階を潜り、帰るとごほうびが入る' },
32  { id: 'restore', mod: '控えあり', command: '/rpg など', look: '「控えから戻す」で日付が並び、確かめてから戻すと記録が控えの値になる' },
33  { id: 'stale-band', mod: 'mod-check', command: '/mod-check', look: 'mod の版が変わると「開き直してください」の帯とトーストが出る' },
34]
35
36export const MARK_LABEL: Record<CheckMark, string> = { bad: 'だめ', todo: 'まだ', good: 'よい' }
37/** 並べる順(だめ・まだを先に) */
38export const MARK_ORDER: readonly CheckMark[] = ['bad', 'todo', 'good']
39
40/** 印。付いていない物は「まだ」 */
41export const markOf = (marks: Readonly<Record<string, CheckMark>>, id: string): CheckMark => {
42  const m = marks[id]
43  return m === 'good' || m === 'bad' ? m : 'todo'
44}
45
46/** store の生の値から、知っている形の印だけを取り出す */
47export function marksFrom(raw: unknown): Record<string, CheckMark> {
48  const marks = typeof raw === 'object' && raw !== null ? (raw as { marks?: unknown }).marks : undefined
49  if (typeof marks !== 'object' || marks === null || Array.isArray(marks)) return {}
50  return Object.fromEntries(Object.entries(marks).filter((e): e is [string, CheckMark] => e[1] === 'good' || e[1] === 'bad'))
51}
52
53/**
54 * 書く直前に読み直した生の値に、1 項目の印だけを当てた物。知らない欄(上の欄も marks の中の知らない id も)は残す。
55 * 「まだ」は印を消す
56 */
57export function withMark(raw: unknown, id: string, mark: CheckMark): Record<string, unknown> {
58  const base = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {}
59  const old = typeof base.marks === 'object' && base.marks !== null && !Array.isArray(base.marks) ? (base.marks as Record<string, unknown>) : {}
60  const marks = { ...old }
61  if (mark === 'todo') delete marks[id]
62  else marks[id] = mark
63  return { ...base, marks }
64}
65
66export type MarkCounts = Record<CheckMark, number>
67
68export const countMarks = (marks: Readonly<Record<string, CheckMark>>, items = ITEMS): MarkCounts => {
69  const c: MarkCounts = { good: 0, bad: 0, todo: 0 }
70  for (const it of items) c[markOf(marks, it.id)] += 1
71  return c
72}
73
74export const markCountLine = (c: MarkCounts, total: number) => `全 ${total} 項目  だめ ${c.bad}  まだ ${c.todo}  よい ${c.good}`
75
76/** だめ・まだを先に、同じ印の中は表の順 */
77export const sortItems = (marks: Readonly<Record<string, CheckMark>>, items = ITEMS) =>
78  [...items].sort((a, b) => MARK_ORDER.indexOf(markOf(marks, a.id)) - MARK_ORDER.indexOf(markOf(marks, b.id)))
79
80/** 1 行 1 項目(/mod-check の貼る文と同じ形) */
81export const itemLine = (it: ChecklistItem, mark: CheckMark) =>
82  [clean(it.mod).padEnd(16), MARK_LABEL[mark], it.command, clean(it.look)].filter(s => s !== '').join(' ')
83
84/** 「点検の結果を貼る文」。stamp は jstStamp の値 */
85export function checklistText(marks: Readonly<Record<string, CheckMark>>, stamp: string, items = ITEMS): string {
86  return [
87    `mod-check の点検表 ${stamp} (日本時間)`,
88    markCountLine(countMarks(marks, items), items.length),
89    ...sortItems(marks, items).map(it => itemLine(it, markOf(marks, it.id))),
90  ].join('\n')
91}
92
hooks/probe.ts 73 lines
1// 他の mod の $.state を読み、どれか 1 つでも書かれていたか(version > 0)を mod ごとに返す。
2// plugin と key はソースに文字で書く決まり(validate が読む。文字でないと mod が読み込まれない)ので、表は手で並べる。
3// key は各 mod の types/index.d.ts の PluginState から写した。描画の中では呼ばない(170 本ほどの購読になる)
4import type { EngineInterface } from 'claude-code'
5
6type Read = () => Promise<{ version: number }>
7
8// $ を import の先へ渡せるのは function の宣言だけ(validate の決まり)
9function reads($: EngineInterface): Record<string, Read[]> {
10  return {
11  'agent-watch': [() => $.state.get({ plugin: 'agent-watch', key: 'agents' }), () => $.state.get({ plugin: 'agent-watch', key: 'now' }), () => $.state.get({ plugin: 'agent-watch', key: 'hideDone' }), () => $.state.get({ plugin: 'agent-watch', key: 'note' })],
12  'aqua-pet': [() => $.state.get({ plugin: 'aqua-pet', key: 'tank' })],
13  'canvas-pane': [() => $.state.get({ plugin: 'canvas-pane', key: 'board' })],
14  'cc-dash': [() => $.state.get({ plugin: 'cc-dash', key: 'tab' }), () => $.state.get({ plugin: 'cc-dash', key: 'startedAt' }), () => $.state.get({ plugin: 'cc-dash', key: 'turnCount' }), () => $.state.get({ plugin: 'cc-dash', key: 'turnDurations' }), () => $.state.get({ plugin: 'cc-dash', key: 'runningSince' }), () => $.state.get({ plugin: 'cc-dash', key: 'calls' }), () => $.state.get({ plugin: 'cc-dash', key: 'toolCounts' }), () => $.state.get({ plugin: 'cc-dash', key: 'toolTotal' }), () => $.state.get({ plugin: 'cc-dash', key: 'errorCount' }), () => $.state.get({ plugin: 'cc-dash', key: 'usage' }), () => $.state.get({ plugin: 'cc-dash', key: 'layout' }), () => $.state.get({ plugin: 'cc-dash', key: 'isArranging' })],
15  'ci-pane': [() => $.state.get({ plugin: 'ci-pane', key: 'snap' }), () => $.state.get({ plugin: 'ci-pane', key: 'isLoading' })],
16  'code-omikuji': [() => $.state.get({ plugin: 'code-omikuji', key: 'today' }), () => $.state.get({ plugin: 'code-omikuji', key: 'play' }), () => $.state.get({ plugin: 'code-omikuji', key: 'plays' }), () => $.state.get({ plugin: 'code-omikuji', key: 'history' }), () => $.state.get({ plugin: 'code-omikuji', key: 'mode' }), () => $.state.get({ plugin: 'code-omikuji', key: 'isOpened' }), () => $.state.get({ plugin: 'code-omikuji', key: 'note' })],
17  'compact-cue': [() => $.state.get({ plugin: 'compact-cue', key: 'context' }), () => $.state.get({ plugin: 'compact-cue', key: 'phase' }), () => $.state.get({ plugin: 'compact-cue', key: 'lastBreak' }), () => $.state.get({ plugin: 'compact-cue', key: 'told' })],
18  'daily-quest': [() => $.state.get({ plugin: 'daily-quest', key: 'today' }), () => $.state.get({ plugin: 'daily-quest', key: 'streak' }), () => $.state.get({ plugin: 'daily-quest', key: 'totalDone' }), () => $.state.get({ plugin: 'daily-quest', key: 'saved' }), () => $.state.get({ plugin: 'daily-quest', key: 'pending' }), () => $.state.get({ plugin: 'daily-quest', key: 'turnErrors' }), () => $.state.get({ plugin: 'daily-quest', key: 'note' })],
19  'dev-rpg': [() => $.state.get({ plugin: 'dev-rpg', key: 'hero' }), () => $.state.get({ plugin: 'dev-rpg', key: 'tab' }), () => $.state.get({ plugin: 'dev-rpg', key: 'isConfirmingReset' }), () => $.state.get({ plugin: 'dev-rpg', key: 'news' }), () => $.state.get({ plugin: 'dev-rpg', key: 'battle' }), () => $.state.get({ plugin: 'dev-rpg', key: 'restore' })],
20  'devlog-pane': [() => $.state.get({ plugin: 'devlog-pane', key: 'log' })],
21  'done-chime': [() => $.state.get({ plugin: 'done-chime', key: 'settings' }), () => $.state.get({ plugin: 'done-chime', key: 'note' })],
22  'edit-log': [() => $.state.get({ plugin: 'edit-log', key: 'entries' }), () => $.state.get({ plugin: 'edit-log', key: 'selected' }), () => $.state.get({ plugin: 'edit-log', key: 'detail' }), () => $.state.get({ plugin: 'edit-log', key: 'note' })],
23  'enc-guard': [() => $.state.get({ plugin: 'enc-guard', key: 'log' }), () => $.state.get({ plugin: 'enc-guard', key: 'counts' })],
24  'face-band': [() => $.state.get({ plugin: 'face-band', key: 'now' }), () => $.state.get({ plugin: 'face-band', key: 'isHidden' }), () => $.state.get({ plugin: 'face-band', key: 'look' })],
25  'fail-log': [() => $.state.get({ plugin: 'fail-log', key: 'entries' }), () => $.state.get({ plugin: 'fail-log', key: 'total' }), () => $.state.get({ plugin: 'fail-log', key: 'confirming' })],
26  'file-map': [() => $.state.get({ plugin: 'file-map', key: 'files' }), () => $.state.get({ plugin: 'file-map', key: 'filter' })],
27  'focus-timer': [() => $.state.get({ plugin: 'focus-timer', key: 'run' }), () => $.state.get({ plugin: 'focus-timer', key: 'done' })],
28  'git-pane': [() => $.state.get({ plugin: 'git-pane', key: 'snap' }), () => $.state.get({ plugin: 'git-pane', key: 'note' })],
29  'live-commentary': [() => $.state.get({ plugin: 'live-commentary', key: 'log' }), () => $.state.get({ plugin: 'live-commentary', key: 'score' }), () => $.state.get({ plugin: 'live-commentary', key: 'clock' }), () => $.state.get({ plugin: 'live-commentary', key: 'isOn' })],
30  'mod-hub': [() => $.state.get({ plugin: 'mod-hub', key: 'mods' }), () => $.state.get({ plugin: 'mod-hub', key: 'commands' }), () => $.state.get({ plugin: 'mod-hub', key: 'notice' }), () => $.state.get({ plugin: 'mod-hub', key: 'sort' }), () => $.state.get({ plugin: 'mod-hub', key: 'filter' }), () => $.state.get({ plugin: 'mod-hub', key: 'pins' }), () => $.state.get({ plugin: 'mod-hub', key: 'recent' })],
31  'port-watch': [() => $.state.get({ plugin: 'port-watch', key: 'list' }), () => $.state.get({ plugin: 'port-watch', key: 'baseline' }), () => $.state.get({ plugin: 'port-watch', key: 'armed' }), () => $.state.get({ plugin: 'port-watch', key: 'isNarrow' }), () => $.state.get({ plugin: 'port-watch', key: 'note' })],
32  'pre-check': [() => $.state.get({ plugin: 'pre-check', key: 'report' }), () => $.state.get({ plugin: 'pre-check', key: 'test' }), () => $.state.get({ plugin: 'pre-check', key: 'open' })],
33  'project-board': [() => $.state.get({ plugin: 'project-board', key: 'projects' }), () => $.state.get({ plugin: 'project-board', key: 'loadedAt' }), () => $.state.get({ plugin: 'project-board', key: 'error' }), () => $.state.get({ plugin: 'project-board', key: 'sort' }), () => $.state.get({ plugin: 'project-board', key: 'filter' }), () => $.state.get({ plugin: 'project-board', key: 'detail' })],
34  'prompt-book': [() => $.state.get({ plugin: 'prompt-book', key: 'items' }), () => $.state.get({ plugin: 'prompt-book', key: 'draft' }), () => $.state.get({ plugin: 'prompt-book', key: 'tag' }), () => $.state.get({ plugin: 'prompt-book', key: 'sort' }), () => $.state.get({ plugin: 'prompt-book', key: 'armed' }), () => $.state.get({ plugin: 'prompt-book', key: 'notice' }), () => $.state.get({ plugin: 'prompt-book', key: 'isFlashing' })],
35  'release-draft': [() => $.state.get({ plugin: 'release-draft', key: 'report' }), () => $.state.get({ plugin: 'release-draft', key: 'repo' })],
36  'rule-switch': [() => $.state.get({ plugin: 'rule-switch', key: 'save' }), () => $.state.get({ plugin: 'rule-switch', key: 'armed' }), () => $.state.get({ plugin: 'rule-switch', key: 'note' })],
37  'rule-watch': [() => $.state.get({ plugin: 'rule-watch', key: 'misses' }), () => $.state.get({ plugin: 'rule-watch', key: 'counts' }), () => $.state.get({ plugin: 'rule-watch', key: 'history' }), () => $.state.get({ plugin: 'rule-watch', key: 'asks' }), () => $.state.get({ plugin: 'rule-watch', key: 'memoPending' }), () => $.state.get({ plugin: 'rule-watch', key: 'log' })],
38  'run-guard': [() => $.state.get({ plugin: 'run-guard', key: 'saved' })],
39  'session-recap': [() => $.state.get({ plugin: 'session-recap', key: 'turns' }), () => $.state.get({ plugin: 'session-recap', key: 'startedAt' }), () => $.state.get({ plugin: 'session-recap', key: 'pending' }), () => $.state.get({ plugin: 'session-recap', key: 'selected' }), () => $.state.get({ plugin: 'session-recap', key: 'summary' }), () => $.state.get({ plugin: 'session-recap', key: 'notice' }), () => $.state.get({ plugin: 'session-recap', key: 'command' })],
40  'session-timeline': [() => $.state.get({ plugin: 'session-timeline', key: 'turns' })],
41  'ship-guard': [() => $.state.get({ plugin: 'ship-guard', key: 'isOff' }), () => $.state.get({ plugin: 'ship-guard', key: 'log' }), () => $.state.get({ plugin: 'ship-guard', key: 'permit' }), () => $.state.get({ plugin: 'ship-guard', key: 'turn' })],
42  'spec-board': [() => $.state.get({ plugin: 'spec-board', key: 'snap' }), () => $.state.get({ plugin: 'spec-board', key: 'isLoading' }), () => $.state.get({ plugin: 'spec-board', key: 'detail' })],
43  'test-runner': [() => $.state.get({ plugin: 'test-runner', key: 'run' }), () => $.state.get({ plugin: 'test-runner', key: 'history' }), () => $.state.get({ plugin: 'test-runner', key: 'note' })],
44  'toast-inbox': [() => $.state.get({ plugin: 'toast-inbox', key: 'items' }), () => $.state.get({ plugin: 'toast-inbox', key: 'readAt' }), () => $.state.get({ plugin: 'toast-inbox', key: 'filter' }), () => $.state.get({ plugin: 'toast-inbox', key: 'confirmClear' }), () => $.state.get({ plugin: 'toast-inbox', key: 'commands' }), () => $.state.get({ plugin: 'toast-inbox', key: 'notice' })],
45  'todo-board': [() => $.state.get({ plugin: 'todo-board', key: 'snap' }), () => $.state.get({ plugin: 'todo-board', key: 'history' }), () => $.state.get({ plugin: 'todo-board', key: 'running' }), () => $.state.get({ plugin: 'todo-board', key: 'filter' }), () => $.state.get({ plugin: 'todo-board', key: 'selected' }), () => $.state.get({ plugin: 'todo-board', key: 'note' })],
46  'token-meter': [() => $.state.get({ plugin: 'token-meter', key: 'turns' }), () => $.state.get({ plugin: 'token-meter', key: 'total' }), () => $.state.get({ plugin: 'token-meter', key: 'sub' }), () => $.state.get({ plugin: 'token-meter', key: 'count' }), () => $.state.get({ plugin: 'token-meter', key: 'heaviest' })],
47  'trophy-shelf': [() => $.state.get({ plugin: 'trophy-shelf', key: 'firstAt' })],
48  'turn-gacha': [() => $.state.get({ plugin: 'turn-gacha', key: 'save' }), () => $.state.get({ plugin: 'turn-gacha', key: 'cards' }), () => $.state.get({ plugin: 'turn-gacha', key: 'view' }), () => $.state.get({ plugin: 'turn-gacha', key: 'note' }), () => $.state.get({ plugin: 'turn-gacha', key: 'turnErrors' })],
49  'turn-haiku': [() => $.state.get({ plugin: 'turn-haiku', key: 'poems' }), () => $.state.get({ plugin: 'turn-haiku', key: 'isAuto' }), () => $.state.get({ plugin: 'turn-haiku', key: 'isComposing' }), () => $.state.get({ plugin: 'turn-haiku', key: 'lastTurn' }), () => $.state.get({ plugin: 'turn-haiku', key: 'note' })],
50  'usage-history': [() => $.state.get({ plugin: 'usage-history', key: 'days' }), () => $.state.get({ plugin: 'usage-history', key: 'heat' }), () => $.state.get({ plugin: 'usage-history', key: 'week' }), () => $.state.get({ plugin: 'usage-history', key: 'range' }), () => $.state.get({ plugin: 'usage-history', key: 'metric' }), () => $.state.get({ plugin: 'usage-history', key: 'restore' }), () => $.state.get({ plugin: 'usage-history', key: 'note' })],
51  'usage-pane': [() => $.state.get({ plugin: 'usage-pane', key: 'usage' })],
52  'wait-games': [() => $.state.get({ plugin: 'wait-games', key: 'game' }), () => $.state.get({ plugin: 'wait-games', key: 'g2048' }), () => $.state.get({ plugin: 'wait-games', key: 'mines' }), () => $.state.get({ plugin: 'wait-games', key: 'flagMode' }), () => $.state.get({ plugin: 'wait-games', key: 'fifteen' }), () => $.state.get({ plugin: 'wait-games', key: 'lights' }), () => $.state.get({ plugin: 'wait-games', key: 'sudoku' }), () => $.state.get({ plugin: 'wait-games', key: 'memory' }), () => $.state.get({ plugin: 'wait-games', key: 'stats' }), () => $.state.get({ plugin: 'wait-games', key: 'isPlaying' }), () => $.state.get({ plugin: 'wait-games', key: 'turnNote' }), () => $.state.get({ plugin: 'wait-games', key: 'note' })],
53  'week-card': [() => $.state.get({ plugin: 'week-card', key: 'snaps' })],
54  'work-bar': [() => $.state.get({ plugin: 'work-bar', key: 'run' }), () => $.state.get({ plugin: 'work-bar', key: 'last' }), () => $.state.get({ plugin: 'work-bar', key: 'isHidden' }), () => $.state.get({ plugin: 'work-bar', key: 'pick' })],
55  }
56}
57
58/** state を読む mod の名前(試験で表と突き合わせる) */
59export const PROBED = Object.keys(reads({} as EngineInterface))
60
61/** mod ごとに、state のどれかが書かれていたか。読めない物(入っていない・答えが無い)は書かれていない扱い */
62export async function probeStates($: EngineInterface): Promise<Record<string, boolean>> {
63  const seen = async (r: Read) => {
64    try {
65      return (await r()).version > 0
66    } catch {
67      return false
68    }
69  }
70  const entries = await Promise.all(Object.entries(reads($)).map(async ([name, list]) => [name, (await Promise.all(list.map(seen))).some(Boolean)] as const))
71  return Object.fromEntries(entries)
72}
73
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 130 lines
1// mod-check の $.state の契約と、読むだけの他の mod の state の写し。
2// 他の mod の型は dependencies を使わず、ここに写す(見本: mods/dev-rpg/types/index.d.ts の 'turn-gacha')。
3// mod-check が見るのは「書かれたか(version)」だけなので、中身はどれも unknown にしている
4
5/** mod ごとの判定 */
6export type CheckKind =
7  /** 本来の名前のコマンドがある(コマンドの無い mod は state が書かれている) */
8  | 'ok'
9  /** 本来の名前が取られていて、予備の名前で入っている */
10  | 'spare'
11  /** state は書かれているのに、コマンドが見つからない */
12  | 'nocmd'
13  /** 読み込みを拒まれた(mod-check より後に読み込まれた mod だけ分かる) */
14  | 'refused'
15  /** コマンドも state も見つからない */
16  | 'missing'
17  /** コマンドも state も持たない mod で、確かめようが無い */
18  | 'unknown'
19
20export type CheckRow = {
21  name: string
22  category: string
23  kind: CheckKind
24  /** 見つかったコマンド(スラッシュ無し)。無ければ空 */
25  command: string
26  /** 外に見せる state を持つ mod か */
27  hasState: boolean
28  /** state のどれかが書かれていたか(持たない mod は false) */
29  stateSeen: boolean
30  /** 読み込みで見えた版(mod-check より後に読み込まれた mod だけ)。無ければ空 */
31  version: string
32  /** 添え書き(本来の名前の持ち主、拒まれた理由など)。無ければ空 */
33  note: string
34}
35
36export type CheckEnv = {
37  /** 描く画面(vscode・terminal など)。分からなければ空 */
38  surface: string
39  /** /config の theme の値。分からなければ空 */
40  theme: string
41  /** エンジンの版。分からなければ空 */
42  engine: string
43  /** mod-check 自身の開いているペイン(id) */
44  panes: string[]
45}
46
47export type CheckReport = {
48  /** 調べた時刻 (ms) */
49  at: number
50  env: CheckEnv
51  rows: CheckRow[]
52  /** 調べた時に版が変わっていた mod。無ければ空か無し */
53  updated?: ModUpdate[]
54}
55
56/** plugin.register で見えた mod(mod-check より後に読み込まれた物だけ) */
57export type CheckSeen = { name: string; version: string; refused: string }
58
59/** セッションの途中で plugin.json の版が変わった mod(このセッションは古い版のまま動いている) */
60export type ModUpdate = { name: string; from: string; to: string }
61
62/** 点検表の 1 項目の印。good はよい、bad はだめ、todo はまだ(store には残さない) */
63export type CheckMark = 'good' | 'bad' | 'todo'
64
65declare module 'claude-code' {
66  interface PluginState {
67    'mod-check': {
68      /** 最後に調べた結果。まだなら null */
69      report: CheckReport | null
70      /** 読み込みで見えた mod */
71      seen: CheckSeen[]
72      /** session.start で受け取った画面の種類 */
73      surface: string
74      /** 版が変わった mod(帯・ペイン・貼る文に出す) */
75      updated: ModUpdate[]
76      /** ペインのタブ。check は点検、list は点検表 */
77      tab: 'check' | 'list'
78      /** 点検表の印(id ごと。まだの物は無い) */
79      marks: Record<string, CheckMark>
80      /** 「点検の結果を貼る文」で出した文。まだなら空 */
81      listText: string
82    }
83    // ここから下は、書かれたかどうか(version)だけを見る他の mod の state
84    'agent-watch': { agents: unknown; now: unknown; hideDone: unknown; note: unknown }
85    'aqua-pet': { tank: unknown }
86    'canvas-pane': { board: unknown }
87    'cc-dash': { tab: unknown; startedAt: unknown; turnCount: unknown; turnDurations: unknown; runningSince: unknown; calls: unknown; toolCounts: unknown; toolTotal: unknown; errorCount: unknown; usage: unknown; layout: unknown; isArranging: unknown }
88    'ci-pane': { snap: unknown; isLoading: unknown }
89    'code-omikuji': { today: unknown; play: unknown; plays: unknown; history: unknown; mode: unknown; isOpened: unknown; note: unknown }
90    'compact-cue': { context: unknown; phase: unknown; lastBreak: unknown; told: unknown }
91    'daily-quest': { today: unknown; streak: unknown; totalDone: unknown; saved: unknown; pending: unknown; turnErrors: unknown; note: unknown }
92    'dev-rpg': { hero: unknown; tab: unknown; isConfirmingReset: unknown; news: unknown; battle: unknown; restore: unknown }
93    'devlog-pane': { log: unknown }
94    'done-chime': { settings: unknown; note: unknown }
95    'edit-log': { entries: unknown; selected: unknown; detail: unknown; note: unknown }
96    'enc-guard': { log: unknown; counts: unknown }
97    'face-band': { now: unknown; isHidden: unknown; look: unknown }
98    'fail-log': { entries: unknown; total: unknown; confirming: unknown }
99    'file-map': { files: unknown; filter: unknown }
100    'focus-timer': { run: unknown; done: unknown }
101    'git-pane': { snap: unknown; note: unknown }
102    'live-commentary': { log: unknown; score: unknown; clock: unknown; isOn: unknown }
103    'mod-hub': { mods: unknown; commands: unknown; notice: unknown; sort: unknown; filter: unknown; pins: unknown; recent: unknown }
104    'port-watch': { list: unknown; baseline: unknown; armed: unknown; isNarrow: unknown; note: unknown }
105    'pre-check': { report: unknown; test: unknown; open: unknown }
106    'project-board': { projects: unknown; loadedAt: unknown; error: unknown; sort: unknown; filter: unknown; detail: unknown }
107    'prompt-book': { items: unknown; draft: unknown; tag: unknown; sort: unknown; armed: unknown; notice: unknown; isFlashing: unknown }
108    'release-draft': { report: unknown; repo: unknown }
109    'rule-switch': { save: unknown; armed: unknown; note: unknown }
110    'rule-watch': { misses: unknown; counts: unknown; history: unknown; asks: unknown; memoPending: unknown; log: unknown }
111    'run-guard': { saved: unknown }
112    'session-recap': { turns: unknown; startedAt: unknown; pending: unknown; selected: unknown; summary: unknown; notice: unknown; command: unknown }
113    'session-timeline': { turns: unknown }
114    'ship-guard': { isOff: unknown; log: unknown; permit: unknown; turn: unknown }
115    'spec-board': { snap: unknown; isLoading: unknown; detail: unknown }
116    'test-runner': { run: unknown; history: unknown; note: unknown }
117    'toast-inbox': { items: unknown; readAt: unknown; filter: unknown; confirmClear: unknown; commands: unknown; notice: unknown }
118    'todo-board': { snap: unknown; history: unknown; running: unknown; filter: unknown; selected: unknown; note: unknown }
119    'token-meter': { turns: unknown; total: unknown; sub: unknown; count: unknown; heaviest: unknown }
120    'trophy-shelf': { firstAt: unknown }
121    'turn-gacha': { save: unknown; cards: unknown; view: unknown; note: unknown; turnErrors: unknown }
122    'turn-haiku': { poems: unknown; isAuto: unknown; isComposing: unknown; lastTurn: unknown; note: unknown }
123    'usage-history': { days: unknown; heat: unknown; week: unknown; range: unknown; metric: unknown; restore: unknown; note: unknown }
124    'usage-pane': { usage: unknown }
125    'wait-games': { game: unknown; g2048: unknown; mines: unknown; flagMode: unknown; fifteen: unknown; lights: unknown; sudoku: unknown; memory: unknown; stats: unknown; isPlaying: unknown; turnNote: unknown; note: unknown }
126    'week-card': { snaps: unknown }
127    'work-bar': { run: unknown; last: unknown; isHidden: unknown; pick: unknown }
128  }
129}
130