/mod で画面のある mod をアイコン付きで並べ、検索・固定・最近開いた順で開く。見出しに季節の飾り

Claude Code の関数フック mod を集めたリポジトリです。 ペイン・入力欄の上の帯・枠・トーストを使い、VS Code 拡張でもターミナルでも見た目が崩れないように作っています。 VS Code では SVG のグラフやアニメ(SMIL / CSS)も描けます。
Anthropic とは関係のない、個人が作った非公式の 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 | /jikkyo | Claude の動きをスポーツ実況風にステータス行へ。口調は熱血・冷静・関西 |
| 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 | /canvas | Claude が呼べるツール show を足す。「売上をグラフにしてペインに出して」と頼むと、棒・折れ線・円・表・Markdown・SVG をペインに描く。直近 20 枚をめくれる |
| rule-switch | /rules | 「日本語で答える」「commit しない」などの約束ごとをボタンでオン/オフし、システムプロンプトに入れる。自分の約束も足せる |
| wait-games | /games | 長いターンの間に遊ぶ 2048・マインスイーパー・15 パズル・ライツアウト・数独(4x4 / 9x9)・神経衰弱。ゲームごとに勝ち数・最短手数・最速を残す。ターンが終わったら知らせる。記録は毎日控えを取り、「控えから戻す」で戻せる |
| spec-board | /specs | SpecStatus の確認待ち・途中の仕様書と次のおすすめ。押すと詳細(読むだけ) |
| 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 | /trophies | dev-rpg・turn-gacha・aqua-pet・wait-games・daily-quest の記録を 1 枚の棚にまとめ、トロフィーを初めて取った日付つきで集める。ダンジョンの深さのトロフィー 3 つ(番人越え・地下三十階・百階の主)も |
| mod-check | /mod-check | mod がちゃんと動いているかを 1 回で確かめる。入っている・コマンドあり・予備名・見つからないの数と、mod ごとの判定(本来の名前か予備名か、本来の名前を使っている相手)、画面・テーマ・Claude Code の版をペインに出し、同じ結果を 1 行 1 mod の貼れる文で返す。隣の mod の版がセッション中に変わったら、帯とトーストで開き直しを頼む。「点検表」タブで実画面の見え方に よい・だめ・まだ を付け、結果を貼る文にする |
| compact-cue | 入力欄の上に自動で出る・/cue | コンテキストの使用量が多くなった時、テストが通った・コミットした・引き継ぎメモを書いた直後なら「今が /compact のよい時」、編集の途中なら「区切りまで待って」と帯とトーストで知らせる。ペインは使用量のリングと作業の様子。知らせるだけで compact はしない |
| ship-guard | 常時・/guard | Claude が git push・gh release・gh repo create・npm publish を打つ時、直前に AskUserQuestion で公開してよいと答えていなければ、その場で「実行しますか?」と聞き、断られたら止める。ペインは記録とオン/オフ。git push の前は、送るコミットの作者が noreply か、足した行に個人のメール・ユーザー名入りのパスが無いかも確かめ、引っかかったら確認済みでも聞き直す |
| rule-watch | 入力欄の上に自動で出る・/rulewatch | Claude の返事が約束ごとから外れた時に帯で知らせる。英語で答えた・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 | 常時・/runguard | Claude が確かめのために窓のあるアプリを起動する・音を鳴らす・claude -p を隔離なしで走らせるのを、実行の前に止めて直し方(-WindowStyle Hidden・--headless・一時の設定フォルダ)を返す。組ごとにオン/オフでき、記録も残る |
| week-card | /week | 1 週間の使い方と、お題・ガチャの図鑑・冒険の書(レベルとダンジョン)・水槽の増え方を 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>
好きな 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 して、そのフォルダから読ませることもできます。
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;...
/haiku の「自動で詠む」で止められますtaskkill /T /F)。node で動く MCP サーバーも開発用に見えるので、止める前に名前とポートを確かめてください。macOS / Linux(lsof)の実機では未確認ですgit config --global --add safe.directory <フォルダ> を足してください$.ui.toast / $.ui.status を呼んだ時だけです。「空にする」はほかのセッションの記録も消しますgit grep で読みます(.gitignore の物は読みません)。git の外では作業フォルダを歩いて読み(2000 ファイル・5000 件・8 秒で止め、1 MiB を超えるファイルと . で始まるフォルダ・node_modules・dist などは読みません)、この時はファイルを変えても自動では読み直さないので「再読込」を押してくださいnpm test・pytest など)で見ます。ごほうびは dev-rpg と turn-gacha がターンの終わりに 1 回だけ受け取りますhooks/register.tsx 222 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ModHubCommand, ModHubRecent, ModHubSort } from '../types'
5import { applyFilter, asPins, asRecent, byCategory, clean, headerSvg, launchers, noteMod, pushRecent, sortRecent, splitPins } from './hub'
6import type { Launcher } from './hub'
7import { bandSvg, categoryColor, modIcon } from './icons'
8import { C, followTheme, mayWrite, readTheme, rememberVersion, season } from './style'
9
10const PANE = 'mod-hub'
11const TITLE = 'mod'
12
13const MODS = atom({ plugin: 'mod-hub', key: 'mods' } as const, [])
14const COMMANDS = atom({ plugin: 'mod-hub', key: 'commands' } as const, [])
15const NOTICE = atom({ plugin: 'mod-hub', key: 'notice' } as const, '')
16const SORT = atom({ plugin: 'mod-hub', key: 'sort' } as const, 'category' as ModHubSort)
17const FILTER = atom({ plugin: 'mod-hub', key: 'filter' } as const, '')
18const PINS = atom({ plugin: 'mod-hub', key: 'pins' } as const, [] as string[])
19const RECENT = atom({ plugin: 'mod-hub', key: 'recent' } as const, [] as ModHubRecent[])
20
21const SORTS: ModHubSort[] = ['category', 'name', 'recent']
22const SORT_LABEL: Record<ModHubSort, string> = { category: '並び: カテゴリー別', name: '並び: 名前順', recent: '並び: 最近開いた順' }
23
24const BROKEN = '保存した記録を読めなかったので、保存していません(上書きしないよう止めています)'
25const STALE = '新しい版が入ったので保存していません。VS Code を開き直してください'
26
27/** store のキーを読む。キーが無ければ empty、読めない時 (例外・形が違う) は undefined */
28async function readKey<T>($: EngineInterface, key: string, as: (v: unknown) => T | undefined, empty: T): Promise<T | undefined> {
29 try {
30 const raw = await $.store.get(key)
31 return raw === undefined ? empty : as(raw)
32 } catch {
33 return undefined
34 }
35}
36
37/** 書いてよいか。古い版のまま動いている時は書かずに知らせる (このセッションの中では効かせる) */
38async function canWrite($: EngineInterface) {
39 if (await mayWrite($, await $.clock.now())) return true
40 await update($, NOTICE, () => STALE)
41 return false
42}
43
44// 並べ方を順に回し、次のセッションにも残す(残せなくてもこのセッションでは効く)
45async function toggleSort($: EngineInterface) {
46 let next: ModHubSort = 'category'
47 await update($, SORT, s => (next = SORTS[(SORTS.indexOf(s) + 1) % SORTS.length] ?? 'category'))
48 if (await canWrite($)) await $.store.set('sort', next).catch(() => {})
49}
50
51// 固定する・外す。$.store は他のセッションと共有なので、書く直前に読み直して 1 本だけ足す・抜く。
52// 読めない時は、このセッションの中だけで固定する (store は上書きしない)
53async function setPin($: EngineInterface, name: string, on: boolean) {
54 const pin = (list: readonly string[]) => (on ? [...list.filter(n => n !== name), name] : list.filter(n => n !== name))
55 const saved = await readKey($, 'pins', asPins, [])
56 if (!saved) {
57 await update($, PINS, pin)
58 return void (await update($, NOTICE, () => BROKEN))
59 }
60 const next = pin(saved)
61 await update($, PINS, () => next)
62 if (await canWrite($)) await $.store.set('pins', next).catch(() => {})
63}
64
65// 開いた記録を残す(同じく書く直前に読み直す)
66async function noteRecent($: EngineInterface, name: string) {
67 const at = await $.clock.now()
68 const saved = await readKey($, 'recent', asRecent, [])
69 if (!saved) return void (await update($, RECENT, list => pushRecent(list, name, at)))
70 const next = pushRecent(saved, name, at)
71 await update($, RECENT, () => next)
72 if (await canWrite($)) await $.store.set('recent', next).catch(() => {})
73}
74
75async function loadCommands($: EngineInterface) {
76 const list = await $.command.list()
77 const mine: ModHubCommand[] = list
78 .filter(c => c.source === 'plugin' && c.plugin !== undefined)
79 .map(c => ({ name: c.name, description: clean(c.description), plugin: c.plugin ?? '' }))
80 await update($, COMMANDS, () => mine)
81}
82
83async function runCommand($: EngineInterface, mod: string, command: string) {
84 await noteRecent($, mod).catch(() => {})
85 await update($, NOTICE, () => `/${command} を送りました(セッションが空いたら開きます)`)
86 try {
87 await $.command.run({ command })
88 } catch {
89 await update($, NOTICE, () => `/${command} を送れませんでした`)
90 }
91}
92
93// /mod (取れなければ /mod-hub) の答え。起動直後はまだ無いコマンドがあるので開くたびに取り直す
94async function openPane($: EngineInterface) {
95 await loadCommands($).catch(() => {})
96 await $.ui.open({ id: PANE, title: TITLE })
97 return { text: 'mod の一覧を開きました。' }
98}
99
100const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
101
102// /mod を持てたか。session.start で決まる(リロードでも session.start が来るのでモジュール変数で足りる)
103let ownsMain = false
104
105export const register: Register = on => {
106 followTheme(on)
107
108 // 自分より後に読み込まれる mod を 1 本ずつ見て、画面を持つかを覚える (止めはしない)
109 // 後ろの mod に拒まれた mod は並べない(next(e) の結果が allow の時だけ記録)
110 on('plugin.register', async ($, e, next) => {
111 const result = await next(e)
112 const mod = noteMod(e, result)
113 await update($, MODS, list => [...list.filter(m => m.name !== e.name), ...(mod ? [mod] : [])]).catch(() => {})
114 return result
115 })
116
117 on('session.start', async ($, e, next) => {
118 await rememberVersion($).catch(() => {})
119 // /mod が拒まれたら (組み込みと被ったら) /mod-hub にする
120 const description = '画面のある mod を開くボタンを並べる'
121 const got = await $.command.register({ name: 'mod', description }).catch(() => undefined)
122 ownsMain = got?.command === 'mod'
123 if (!ownsMain) await $.command.register({ name: 'mod-hub', description }).catch(() => {})
124 await loadCommands($).catch(() => {})
125 await readTheme($)
126 const saved = await $.store.get('sort').catch(() => undefined)
127 if (SORTS.includes(saved as ModHubSort)) await update($, SORT, () => saved as ModHubSort)
128 const pins = asPins(await $.store.get('pins').catch(() => undefined))
129 if (pins) await update($, PINS, () => pins)
130 const recent = asRecent(await $.store.get('recent').catch(() => undefined))
131 if (recent) await update($, RECENT, () => recent)
132 return next(e)
133 })
134
135 // /mod を持てなかった時は元の /mod を横取りしない
136 on('command.run', { command: 'mod' }, ($, e, next) => (ownsMain ? openPane($) : next(e))).catch(($, e, next) => next(e))
137 // /mod を持てた時は /mod-hub を登録していないので、その名前の持ち主に回す
138 on('command.run', { command: 'mod-hub' }, ($, e, next) => (ownsMain ? next(e) : openPane($))).catch(($, e, next) => next(e))
139
140 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
141 const t = $.ui.resolve(e)
142 const { Box, Text, Button } = t
143 // terminal の表にも Svg の名前があるので、画面の種類でも分ける
144 const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : undefined
145 const Input = 'Input' in t ? t.Input : undefined
146
147 const all = launchers(await read($, MODS), await read($, COMMANDS))
148 const notice = await read($, NOTICE)
149 const sort = await read($, SORT)
150 const pins = await read($, PINS)
151 const filter = Input ? await read($, FILTER) : ''
152 // 見出しの季節の飾り (JST の月から。時計が読めなければ飾り無し)
153 const seasonNow = Svg ? await $.clock.now().then(season, () => undefined) : undefined
154
155 const shown = applyFilter(all, filter)
156 const { pinned, rest } = splitPins(sort === 'recent' ? sortRecent(shown, await read($, RECENT)) : shown, pins)
157
158 const card = (l: Launcher) => {
159 const isPinned = pins.includes(l.name)
160 return (
161 <Box key={`card:${l.name}`} flexDirection="column">
162 <Box key={`head:${l.name}`} flexDirection="row" gap={1} alignItems="center">
163 {Svg && <Svg source={modIcon(l.name)} alt={`${l.name} のアイコン`} width={28} />}
164 <Text bold>{l.name}</Text>
165 {l.version !== '' && <Text dimColor>{`v${l.version}`}</Text>}
166 </Box>
167 {l.commands.map(c => (
168 <Button key={`run:${l.name}:${c.name}`} onPress={() => void runCommand($, l.name, c.name)}>
169 {`/${c.name}`} <Text dimColor>{short(c.description, 22)}</Text>
170 </Button>
171 ))}
172 <Button key={`pin:${l.name}`} dimColor onPress={() => void setPin($, l.name, !isPinned)}>
173 <Text dimColor>{isPinned ? '外す' : '固定'}</Text>
174 </Button>
175 </Box>
176 )
177 }
178
179 // 色の帯の見出し (terminal は色付きの文字)
180 const group = (label: Parameters<typeof categoryColor>[0], items: Launcher[]) => (
181 <Box key={`group:${label}`} flexDirection="column" gap={1}>
182 {Svg ? (
183 <Svg source={bandSvg(label, items.length)} alt={`${label} ${items.length} 本`} />
184 ) : (
185 <Text bold color={categoryColor(label)}>{`${label} (${items.length})`}</Text>
186 )}
187 {items.map(card)}
188 </Box>
189 )
190
191 return (
192 <Box flexDirection="column" gap={1}>
193 {Svg ? (
194 <Svg source={headerSvg(all.length, seasonNow)} alt={`画面のある mod ${all.length} 本`} />
195 ) : (
196 <Text bold>{`画面のある mod ${all.length} 本`}</Text>
197 )}
198 {notice !== '' && <Text color={C.yellow}>{notice}</Text>}
199 {all.length === 0 && <Text dimColor>開ける mod が見つかりません(mod-hub より後に読み込まれる mod だけが並びます)</Text>}
200 {all.length > 0 && Input && (
201 <Input
202 key="filter"
203 placeholder="名前や説明で絞る"
204 value={filter}
205 submitLabel="絞る"
206 onInput={v => void update($, FILTER, () => v)}
207 onSubmit={v => void update($, FILTER, () => v)}
208 />
209 )}
210 {all.length > 0 && (
211 <Button key="sort" dimColor onPress={() => void toggleSort($)}>
212 <Text>{SORT_LABEL[sort]}</Text> <Text dimColor>押して切り替え</Text>
213 </Button>
214 )}
215 {all.length > 0 && shown.length === 0 && <Text dimColor>一致する mod がありません</Text>}
216 {pinned.length > 0 && group('固定', pinned)}
217 {sort === 'category' ? byCategory(rest).map(g => group(g.category, g.items)) : rest.map(card)}
218 </Box>
219 )
220 })
221}
222hooks/hub.ts 147 lines1// 起動ボタンの一覧とヘッダーの SVG。$ に触らない純関数だけ置く
2import type { ModHubCommand, ModHubMod, ModHubRecent } from '../types'
3import { C, clean, frame, header } from './style'
4import type { Season } from './style'
5
6export { clean }
7
8export const SELF = 'mod-hub'
9
10/** 画面を持つ mod: ペインを開くか、何かを描く */
11export const isGui = (events: readonly string[], calls: readonly string[]) => calls.includes('ui.open') || events.includes('ui.render')
12
13/** plugin.register の next(e) の結果が allow の時だけ記録する物を返す(後ろの mod に拒まれた mod は並べない) */
14export function noteMod(
15 e: { name: string; version?: string; uses: { events: readonly string[]; calls: readonly string[] } },
16 result: { allow?: true; refuse?: string },
17): ModHubMod | undefined {
18 if (result.allow !== true) return undefined
19 return { name: e.name, version: clean(e.version ?? ''), isGui: isGui(e.uses.events, e.uses.calls) }
20}
21
22/** 並べるカテゴリーの順。表に無い mod (よそで作られた物) は最後の「その他」 */
23export const CATEGORIES = ['開発', '状況', '道具', '遊び', 'その他'] as const
24export type Category = (typeof CATEGORIES)[number]
25
26// ponytail: このリポジトリの mod だけを名前で引く。よその mod にも分けたくなったら plugin.json から読む
27const CATEGORY: Record<string, Category> = {
28 'git-pane': '開発', 'edit-log': '開発', 'todo-board': '開発', 'test-runner': '開発', 'pre-check': '開発',
29 'ci-pane': '開発', 'port-watch': '開発', 'spec-board': '開発', 'project-board': '開発', 'fail-log': '開発',
30 'ship-guard': '開発', 'rule-watch': '開発', 'enc-guard': '開発', 'release-draft': '開発',
31 'cc-dash': '状況', 'token-meter': '状況', 'usage-pane': '状況', 'agent-watch': '状況', 'session-recap': '状況',
32 'devlog-pane': '状況', 'work-bar': '状況', 'session-timeline': '状況', 'file-map': '状況', 'usage-history': '状況',
33 'compact-cue': '状況',
34 'focus-timer': '道具', 'prompt-book': '道具', 'rule-switch': '道具', 'canvas-pane': '道具', 'done-chime': '道具',
35 'toast-inbox': '道具', 'mod-check': '道具',
36 'aqua-pet': '遊び', 'face-band': '遊び', 'turn-haiku': '遊び', 'live-commentary': '遊び', 'turn-gacha': '遊び',
37 'dev-rpg': '遊び', 'code-omikuji': '遊び', 'wait-games': '遊び', 'daily-quest': '遊び', 'trophy-shelf': '遊び',
38 'run-guard': '開発',
39 'week-card': '遊び',
40}
41export const categoryOf = (name: string): Category => CATEGORY[name] ?? 'その他'
42/** 表に載っている mod の名前 (アイコンが揃っているかの試験で回す) */
43export const KNOWN_MODS = Object.keys(CATEGORY)
44
45export type Launcher = { name: string; version: string; category: Category; commands: ModHubCommand[] }
46
47const baseName = (plugin: string) => plugin.split('@')[0] ?? plugin
48
49/** 画面を持ち、コマンドで開ける mod だけを名前順に (自分は除く) */
50export function launchers(mods: readonly ModHubMod[], commands: readonly ModHubCommand[]): Launcher[] {
51 return mods
52 .filter(m => m.isGui && m.name !== SELF)
53 .map(m => ({
54 name: m.name,
55 version: m.version,
56 category: categoryOf(m.name),
57 commands: commands.filter(c => baseName(c.plugin) === m.name),
58 }))
59 .filter(l => l.commands.length > 0)
60 .sort((a, b) => a.name.localeCompare(b.name))
61}
62
63/** カテゴリーの順に分ける (中は名前順のまま)。空のカテゴリーは出さない */
64export const byCategory = (list: readonly Launcher[]) =>
65 CATEGORIES.map(category => ({ category, items: list.filter(l => l.category === category) })).filter(g => g.items.length > 0)
66
67/** 名前・コマンド名・説明の部分一致で絞る (大文字小文字は見ない)。空なら全部 */
68export function applyFilter(list: readonly Launcher[], query: string): Launcher[] {
69 const q = query.trim().toLowerCase()
70 if (q === '') return [...list]
71 const hit = (s: string) => s.toLowerCase().includes(q)
72 return list.filter(l => hit(l.name) || l.commands.some(c => hit(c.name) || hit(c.description)))
73}
74
75/** 最近開いた順。開いたことの無い mod は後ろに名前順 (list は名前順で来る) */
76export function sortRecent(list: readonly Launcher[], recent: readonly ModHubRecent[]): Launcher[] {
77 const at = new Map(recent.map(r => [r.name, r.at]))
78 return [...list].sort((a, b) => (at.get(b.name) ?? -Infinity) - (at.get(a.name) ?? -Infinity) || a.name.localeCompare(b.name))
79}
80
81/** 固定した物と残りに分ける (並びは list のまま) */
82export const splitPins = (list: readonly Launcher[], pins: readonly string[]) => ({
83 pinned: list.filter(l => pins.includes(l.name)),
84 rest: list.filter(l => !pins.includes(l.name)),
85})
86
87export const RECENT_MAX = 20
88
89/** 開いた記録を先頭に足す (同じ名前の古い記録は抜く。その知らない欄は新しい記録に残す) */
90export const pushRecent = (recent: readonly ModHubRecent[], name: string, at: number): ModHubRecent[] =>
91 [{ ...recent.find(r => r.name === name), name, at }, ...recent.filter(r => r.name !== name)].slice(0, RECENT_MAX)
92
93/** $.store から読んだ物の形を確かめる。違えば undefined */
94export const asPins = (v: unknown): string[] | undefined =>
95 Array.isArray(v) && v.every(x => typeof x === 'string') ? (v as string[]) : undefined
96
97/** 読んだ記録はそのまま (知らない欄も残す)。数で切るのは足す時 (pushRecent) だけ */
98export const asRecent = (v: unknown): ModHubRecent[] | undefined =>
99 Array.isArray(v) && v.every(x => typeof x?.name === 'string' && typeof x?.at === 'number') ? (v as ModHubRecent[]) : undefined
100
101/** 見出しの右下に置く季節の小さな飾り (中心 346,66・大きさ 30 ほど) */
102// 色は C から読むので、描くたびに作る (テーマに付いていく)
103const seasonDeco = (): Record<Season, string> => ({
104 // 桜の花がゆっくり回り、花びらが 1 枚落ちる
105 春:
106 `<g id="deco-spring"><g transform="translate(346 64)"><animateTransform attributeName="transform" type="rotate" values="0;72" additive="sum" dur="8s" repeatCount="indefinite"/>` +
107 [0, 72, 144, 216, 288].map(a => `<ellipse cx="0" cy="-6.5" rx="4.6" ry="6.5" fill="#f9a8c0" transform="rotate(${a})"/>`).join('') +
108 `<circle r="2.6" fill="#f2cc60"/></g>` +
109 `<ellipse cx="326" cy="60" rx="2.6" ry="1.6" fill="#f9a8c0"><animate attributeName="cy" values="56;84" dur="4s" repeatCount="indefinite"/><animate attributeName="opacity" values="1;0" dur="4s" repeatCount="indefinite"/></ellipse></g>`,
110 // 日の光がゆっくり回る
111 夏:
112 `<g id="deco-summer"><g><animateTransform attributeName="transform" type="rotate" values="0 346 66;45 346 66" dur="6s" repeatCount="indefinite"/>` +
113 [0, 45, 90, 135, 180, 225, 270, 315].map(a => `<path d="M346 52 V47" stroke="#f2cc60" stroke-width="2.4" stroke-linecap="round" transform="rotate(${a} 346 66)"/>`).join('') +
114 `</g><circle cx="346" cy="66" r="9" fill="#f2cc60"/></g>`,
115 // もみじが揺れる
116 秋:
117 `<g id="deco-autumn"><g><animateTransform attributeName="transform" type="rotate" values="-12 346 74;12 346 74;-12 346 74" dur="3s" repeatCount="indefinite"/>` +
118 `<path d="M346 50 L349 58 L356 54 L354 62 L362 63 L355 68 L358 73 L349 71 L347 78 L345 78 L343 71 L334 73 L337 68 L330 63 L338 62 L336 54 L343 58 Z" fill="#d9822b"/>` +
119 `<path d="M346 58 V82" stroke="#a0522d" stroke-width="1.4" stroke-linecap="round"/></g></g>`,
120 // 雪の結晶が回り、雪が降る
121 冬:
122 `<g id="deco-winter"><g><animateTransform attributeName="transform" type="rotate" values="0 346 66;60 346 66" dur="6s" repeatCount="indefinite"/>` +
123 [0, 60, 120].map(a => `<path d="M346 54 V78 M342 57 L346 60 L350 57 M342 75 L346 72 L350 75" stroke="${C.blue}" stroke-width="1.8" fill="none" stroke-linecap="round" transform="rotate(${a} 346 66)"/>`).join('') +
124 `</g>` +
125 [[318, 0], [370, 1.3], [330, 2.4]].map(([x, d]) => `<circle cx="${x}" cy="52" r="1.6" fill="${C.blue}"><animate attributeName="cy" values="50;84" dur="3.6s" begin="${d}s" repeatCount="indefinite"/></circle>`).join('') +
126 `</g>`,
127})
128
129/**
130 * ヘッダー: 見出しと本数、mod ごとの色の点が順に灯る。
131 * season を渡すと右下に季節の飾り (描く側が JST の今から決めて渡す。渡さなければ飾り無し)
132 */
133export function headerSvg(count: number, season?: Season): string {
134 const pad = 16
135 const colors = [C.blue, C.green, C.yellow, C.purple, C.orange, C.red]
136 const dots = Array.from({ length: Math.min(count, 12) }, (_, i) => {
137 const color = colors[i % colors.length]
138 return `<circle cx="${pad + 6 + i * 22}" cy="66" r="6" fill="${color}" opacity="0.25"><animate attributeName="opacity" values="0.25;1;0.25" dur="2.4s" begin="${(i * 0.2).toFixed(1)}s" repeatCount="indefinite"/></circle>`
139 }).join('')
140 return frame(
141 88,
142 header('mod ハブ', `画面のある mod ${count} 本`) +
143 (count > 0 ? dots : `<text x="${pad}" y="70" font-size="12" fill="${C.dim}">まだ見つかっていません</text>`) +
144 (season ? seasonDeco()[season] : ''),
145 )
146}
147hooks/icons.ts 129 lines1// mod ごとの小さなアイコンと、カテゴリーの色の帯。$ に触らない純関数だけ置く
2// 色は描く時に C から読む(テーマを切り替えると C の中身が入れ替わるので、文字列を先に作って持たない)
3import { categoryOf } from './hub'
4import type { Category } from './hub'
5import { C, esc, frame, growBar } from './style'
6
7type Group = Category | '固定'
8
9/** カテゴリーの色 [主, 差し色] */
10const tones = (g: Group): [string, string] =>
11 ({
12 開発: [C.blue, C.green],
13 状況: [C.green, C.blue],
14 道具: [C.yellow, C.blue],
15 遊び: [C.purple, C.orange],
16 その他: [C.dim, C.blue],
17 固定: [C.orange, C.yellow],
18 })[g] as [string, string]
19
20/** 見出しの色 (Text の color にも使う) */
21export const categoryColor = (g: Group) => tones(g)[0]
22
23const line = (c: string, w = 2) => `fill="none" stroke="${c}" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"`
24
25// mod 名 → 絵の中身 (32x32、c = 主の色、a = 差し色)。文字は使わない
26const ICONS: Record<string, (c: string, a: string) => string> = {
27 // 開発
28 'git-pane': (c, a) =>
29 `<path d="M11 11.5v9 M21 15.5c0 4-10 2-10 5" ${line(c)}/><circle cx="11" cy="9" r="2.5" ${line(c)}/><circle cx="11" cy="23" r="2.5" ${line(c)}/><circle cx="21" cy="13" r="2.5" fill="${a}"/>`,
30 'edit-log': (c, a) => `<path d="M9 6h10l4 4v16H9z M19 6v4h4" ${line(c)}/><path d="M12 15h8 M12 19h8 M12 23h5" ${line(a)}/>`,
31 'todo-board': (c, a) =>
32 `<rect x="7" y="7" width="7" height="7" rx="1.5" ${line(c)}/><path d="M8.8 10.5l1.6 1.6 2.6-3.2" ${line(a)}/><rect x="7" y="18" width="7" height="7" rx="1.5" ${line(c)}/><path d="M18 10.5h7 M18 21.5h7" ${line(c)}/>`,
33 'test-runner': (c, a) => `<path d="M12 6h8 M14 6v8l-5.5 9.5a2 2 0 0 0 1.7 3h11.6a2 2 0 0 0 1.7-3L18 14V6" ${line(c)}/><path d="M11 20h10" ${line(a)}/>`,
34 'pre-check': (c, a) => `<path d="M16 5l9 3.5v6.5c0 6-4 9.5-9 12c-5-2.5-9-6-9-12V8.5z" ${line(c)}/><path d="M12 16l3 3 5-6" ${line(a)}/>`,
35 'ci-pane': (c, a) => `<path d="M23.5 13a8 8 0 0 0-14.5-2.5 M8.5 19a8 8 0 0 0 14.5 2.5" ${line(c)}/><path d="M9 6v4.5h4.5 M23 26v-4.5h-4.5" ${line(a)}/>`,
36 'port-watch': (c, a) => `<path d="M12 5v6 M20 5v6" ${line(a)}/><path d="M8.5 11h15v4a7.5 7.5 0 0 1-15 0z M16 22.5V27" ${line(c)}/>`,
37 'spec-board': (c, a) =>
38 `<rect x="8" y="7" width="16" height="20" rx="2" ${line(c)}/><rect x="12" y="5" width="8" height="4" rx="1" fill="${c}"/><path d="M12 15h8 M12 19h8 M12 23h5" ${line(a)}/>`,
39 'project-board': (c, a) =>
40 `<rect x="6" y="7" width="5.5" height="18" rx="1.5" fill="${c}"/><rect x="13.25" y="7" width="5.5" height="12" rx="1.5" fill="${c}" opacity="0.65"/><rect x="20.5" y="7" width="5.5" height="7" rx="1.5" fill="${a}"/>`,
41 'fail-log': (c, a) => `<path d="M16 5L28 26H4z" ${line(c)}/><path d="M16 12v7" ${line(a, 2.5)}/><circle cx="16" cy="22.5" r="1.6" fill="${a}"/>`,
42 'ship-guard': (c, a) => `<path d="M16 26V10 M10 15.5l6-6 6 6" ${line(c, 2.5)}/><path d="M7 26h18" ${line(a, 2.5)}/>`,
43 'rule-watch': (c, a) => `<path d="M6 7h20v14H14l-6 5v-5H6z" ${line(c)}/><path d="M11 14l3.5 3.5 6.5-6.5" ${line(a, 2.5)}/>`,
44 'enc-guard': (c, a) => `<path d="M12 8l-6 8 6 8 M20 8l6 8-6 8" ${line(c, 2.5)}/><circle cx="16" cy="16" r="2.4" fill="${a}"/>`,
45 'release-draft': (c, a) => `<path d="M6 6h9l11 11-9 9L6 15z" ${line(c)}/><circle cx="11" cy="11" r="2" fill="${a}"/>`,
46 // 状況
47 'compact-cue': (c, a) => `<path d="M16 5a11 11 0 1 1-10.4 7.4" ${line(c, 2.5)}/><path d="M12 13l4 4 4-4 M16 17V9" ${line(a)}/>`,
48 'cc-dash': (c, a) => `<path d="M6 22a10 10 0 0 1 20 0" ${line(c, 2.5)}/><path d="M16 22l5.5-6.5" ${line(a)}/><circle cx="16" cy="22" r="2.2" fill="${c}"/>`,
49 'token-meter': (c, a) => `<path d="M8 10h16 M8 16h11" ${line(c, 3)}/><path d="M8 22h6" ${line(a, 3)}/>`,
50 'usage-pane': (c, a) => `<circle cx="16" cy="16" r="9.5" ${line(c)}/><path d="M16 16V6.5a9.5 9.5 0 0 1 9.5 9.5z" fill="${a}"/>`,
51 'agent-watch': (c, a) => `<path d="M4.5 16c3.5-7 19.5-7 23 0c-3.5 7-19.5 7-23 0z" ${line(c)}/><circle cx="16" cy="16" r="3.8" fill="${a}"/>`,
52 'session-recap': (c, a) => `<path d="M8 16a8.5 8.5 0 1 0 2.5-6" ${line(c)}/><path d="M10 5.5v4.5h4.5" ${line(c)}/><path d="M16.5 12v4.5l3 2" ${line(a)}/>`,
53 'devlog-pane': (c, a) => `<path d="M16 10c-3-2-6.5-2-10-1v15c3.5-1 7-1 10 1c3-2 6.5-2 10-1V9c-3.5-1-7-1-10 1z" ${line(c)}/><path d="M16 10v15" ${line(a)}/>`,
54 'work-bar': (c, a) => `<rect x="5" y="12" width="22" height="8" rx="4" ${line(c)}/><rect x="8" y="15" width="11" height="2" rx="1" fill="${a}"/>`,
55 'session-timeline': (c, a) =>
56 `<path d="M5 18h22" ${line(c)}/><circle cx="9" cy="18" r="2.6" fill="${c}"/><circle cx="16" cy="18" r="2.6" fill="${a}"/><circle cx="23" cy="18" r="2.6" fill="${c}"/><path d="M16 8v6" ${line(a)}/>`,
57 'file-map': (c, a) =>
58 `<rect x="6" y="6" width="10.5" height="20" rx="1.5" fill="${c}"/><rect x="18" y="6" width="8" height="9" rx="1.5" fill="${a}"/><rect x="18" y="16.5" width="8" height="9.5" rx="1.5" fill="${c}" opacity="0.6"/>`,
59 'usage-history': (c, a) => `<path d="M6 6v20h20" ${line(c)}/><path d="M10 21l4.5-5.5 4 3.5 6-8" ${line(a)}/>`,
60 // 道具
61 'focus-timer': (c, a) => `<circle cx="16" cy="18" r="8.5" ${line(c)}/><path d="M13 5h6 M16 5v4.5" ${line(c)}/><path d="M16 18l3.5-3.5" ${line(a)}/>`,
62 'prompt-book': (c, a) =>
63 `<path d="M9 6h13a2 2 0 0 1 2 2v18H11a2 2 0 0 1-2-2z M9 24a2 2 0 0 1 2-2h13" ${line(c)}/><path d="M15 6v8l2-1.5 2 1.5V6z" fill="${a}"/>`,
64 'rule-switch': (c, a) => `<rect x="5" y="10.5" width="22" height="11" rx="5.5" ${line(c)}/><circle cx="21.5" cy="16" r="3.2" fill="${a}"/>`,
65 'canvas-pane': (c, a) => `<rect x="5" y="7" width="22" height="18" rx="2" ${line(c)}/><path d="M8 22l5-6 4 4 3-3 4 5" ${line(a)}/><circle cx="20.5" cy="12" r="2" fill="${c}"/>`,
66 'done-chime': (c, a) => `<path d="M10 21v-6a6 6 0 0 1 12 0v6l2 2H8z" ${line(c)}/><path d="M14 26.5h4" ${line(a, 2.5)}/>`,
67 'toast-inbox': (c, a) => `<path d="M5 17l3.5-10h15L27 17v8H5z M5 17h6.5l1.5 3h6l1.5-3H27" ${line(c)}/><path d="M12.5 11.5h7" ${line(a)}/>`,
68 'mod-check': (c, a) =>
69 `<rect x="7" y="6" width="18" height="21" rx="2" ${line(c)}/><rect x="12" y="4" width="8" height="4" rx="1" fill="${c}"/><path d="M11.5 17l3.5 3.5 6-7" ${line(a, 2.5)}/>`,
70 // 遊び
71 'aqua-pet': (c, a) => `<path d="M6 16c3.5-6.5 11.5-6.5 15.5 0c-4 6.5-12 6.5-15.5 0z M21.5 16l5-4.5v9z" ${line(c)}/><circle cx="11" cy="15" r="1.5" fill="${a}"/>`,
72 'face-band': (c, a) =>
73 `<circle cx="16" cy="16" r="10" ${line(c)}/><circle cx="12.5" cy="13.5" r="1.5" fill="${c}"/><circle cx="19.5" cy="13.5" r="1.5" fill="${c}"/><path d="M11.5 19c2.5 3 6.5 3 9 0" ${line(a)}/>`,
74 'turn-haiku': (c, a) => `<rect x="11" y="6" width="10" height="21" rx="1.5" ${line(c)}/><path d="M14 10v13 M18 10v8" ${line(a)}/><path d="M16 3v3" ${line(c)}/>`,
75 'live-commentary': (c, a) => `<rect x="12" y="4.5" width="8" height="13" rx="4" ${line(c)}/><path d="M8 14.5a8 8 0 0 0 16 0 M16 22.5v4 M12 27h8" ${line(c)}/><path d="M14.5 9h3" ${line(a)}/>`,
76 'turn-gacha': (c, a) =>
77 `<circle cx="16" cy="12" r="7.5" ${line(c)}/><path d="M9 19.5h14v7H9z" ${line(c)}/><circle cx="13.5" cy="11" r="2.2" fill="${a}"/><circle cx="18.5" cy="13.5" r="2.2" fill="${c}"/>`,
78 'dev-rpg': (c, a) => `<path d="M23 5.5h3.5V9L15 20.5l-3.5-3.5z" ${line(c)}/><path d="M9.5 15l7.5 7.5 M12 20l-5 5" ${line(a)}/>`,
79 'code-omikuji': (c, a) => `<path d="M10.5 12.5l5.5-3.5 5.5 3.5V26h-11z" ${line(c)}/><path d="M18 9l3.5-5.5" ${line(a, 2.5)}/><path d="M13.5 17.5h5" ${line(c)}/>`,
80 'wait-games': (c, a) =>
81 `<path d="M9.5 10.5h13a5.5 5.5 0 0 1 5.5 5.5v2.5a4 4 0 0 1-7 2.5l-1.2-1.5h-7.6L11 21a4 4 0 0 1-7-2.5V16a5.5 5.5 0 0 1 5.5-5.5z" ${line(c)}/><path d="M10 14v5 M7.5 16.5h5" ${line(c)}/><circle cx="21" cy="15" r="1.5" fill="${a}"/><circle cx="23.5" cy="18" r="1.5" fill="${a}"/>`,
82 'daily-quest': (c, a) =>
83 `<rect x="5" y="8" width="22" height="18" rx="2" ${line(c)}/><path d="M5 13h22 M10 5v5 M22 5v5" ${line(c)}/><circle cx="20.5" cy="20" r="2.8" fill="${a}"/>`,
84 'trophy-shelf': (c, a) =>
85 `<path d="M10 6h12v6a6 6 0 0 1-12 0z" ${line(c)}/><path d="M10 8H6.5a3.5 3.5 0 0 0 4 6 M22 8h3.5a3.5 3.5 0 0 1-4 6" ${line(c)}/><path d="M16 18v4 M11 26h10 M13 22h6" ${line(a)}/>`,
86 'run-guard': (c, a) => `<rect x="4" y="6" width="19" height="15" rx="2" ${line(c)}/><path d="M4 10.5h19" ${line(c)}/><path d="M18 22h3l4-3v10l-4-3h-3z" fill="${c}"/><path d="M6 27L27 6" ${line(a, 2.5)}/>`,
87 'week-card': (c, a) => `<rect x="5" y="6" width="22" height="20" rx="3" ${line(c)}/>${[9, 7, 11, 6, 10, 8, 12].map((h, i) => `<rect x="${8 + i * 2.6}" y="${23 - h}" width="1.8" height="${h}" rx="0.9" fill="${i === 6 ? a : c}"/>`).join('')}`,
88}
89
90/** 表に無い mod: カテゴリーの色の 4 つの点 */
91const generic = (c: string) =>
92 `<circle cx="11" cy="11" r="3.2" fill="${c}"/><circle cx="21" cy="11" r="3.2" fill="${c}" opacity="0.55"/>` +
93 `<circle cx="11" cy="21" r="3.2" fill="${c}" opacity="0.55"/><circle cx="21" cy="21" r="3.2" fill="${c}"/>`
94
95export const hasIcon = (name: string) => name in ICONS
96
97/** mod のアイコン (32x32)。幅は Svg の width で決める */
98export function modIcon(name: string): string {
99 const [c, a] = tones(categoryOf(name))
100 const body = ICONS[name]?.(c, a) ?? generic(c)
101 return (
102 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">` +
103 `<rect x="1" y="1" width="30" height="30" rx="7" fill="${C.face}" stroke="${c}" stroke-opacity="0.45"/>${body}</svg>`
104 )
105}
106
107/** アイコンを 380 幅に 8 個ずつ並べた 1 枚 (README の絵用) */
108export function iconStrip(names: readonly string[]): string {
109 const size = 36
110 const gap = (380 - 16 * 2 - size * 8) / 7
111 const rows = Math.max(1, Math.ceil(names.length / 8))
112 const cells = names.map((n, i) =>
113 modIcon(n).replace('<svg ', `<svg x="${16 + (i % 8) * (size + gap)}" y="${12 + Math.floor(i / 8) * (size + 10)}" width="${size}" height="${size}" `),
114 )
115 return frame(24 + rows * size + (rows - 1) * 10, cells.join(''))
116}
117
118/** カテゴリーの見出しの帯: 色の縦線・名前・本数、下の線が伸びる */
119export function bandSvg(label: Group, count: number): string {
120 const color = categoryColor(label)
121 return frame(
122 30,
123 `<rect x="0" y="6" width="4" height="18" rx="2" fill="${color}"/>` +
124 `<text x="14" y="20" font-size="14" font-weight="bold" fill="${color}">${esc(label)}</text>` +
125 `<text x="366" y="20" font-size="12" fill="${C.dim}" text-anchor="end">${count} 本</text>` +
126 growBar(14, 26, 352, 2, color, 0, 0.35),
127 )
128}
129hooks/style.ts 315 lines1// 全 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
315types/index.d.ts 43 lines1// mod-hub の $.state の契約
2
3/** plugin.register で見えた mod 1 本 */
4export type ModHubMod = {
5 name: string
6 /** plugin.json の version。書いていなければ空 */
7 version: string
8 /** ペインを開くか、何かを描く */
9 isGui: boolean
10}
11
12/** $.command.list() のうち mod が足したコマンド */
13export type ModHubCommand = {
14 name: string
15 description: string
16 plugin: string
17}
18
19/** カテゴリー別か名前順か最近開いた順か */
20export type ModHubSort = 'category' | 'name' | 'recent'
21
22/** 起動ボタンを押した記録 (新しい順、最大 20) */
23export type ModHubRecent = { name: string; at: number }
24
25declare module 'claude-code' {
26 interface PluginState {
27 'mod-hub': {
28 mods: ModHubMod[]
29 commands: ModHubCommand[]
30 /** 最後の操作の結果 (空なら出さない) */
31 notice: string
32 /** 並べ方。$.store の 'sort' にも残す */
33 sort: ModHubSort
34 /** 検索欄の文字 (セッションの間だけ) */
35 filter: string
36 /** 固定した mod の名前。$.store の 'pins' にも残す */
37 pins: string[]
38 /** 最近開いた mod。$.store の 'recent' にも残す */
39 recent: ModHubRecent[]
40 }
41 }
42}
43