SLOPSHOPPER

trophy-shelf

/trophies で dev-rpg・turn-gacha・aqua-pet・wait-games・daily-quest の記録を 1 枚の棚にまとめ、全体のトロフィーを集める

newpanecommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · trophy-shelf
│ ┃ 実績帳 ✕ › fix the failing auth test and add an audit log call │ ┃ 実績帳 トロフィー 0 / 23 個 │ ┃ dev-rpg: 入っていません ⏺ Read(src/auth.ts) │ ┃ turn-gacha: 入っていません ⎿ Read 6 lines │ ┃ aqua-pet: 入っていません ⏺ Update(src/auth.ts) │ ┃ wait-games: 入っていません ⎿ Added 2 lines, removed 1 line │ ┃ daily-quest: 入っていません ⏺ Bash(bun test) │ ┃ 未取得 冒険の始まり 入っていません (dev-rpg ⎿ 3 pass, 1 fail │ ┃ で Lv 5) │ ┃ 未取得 二桁の勇者 入っていません (dev-rpg で ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Lv 10) │ ┃ 未取得 歴戦の勇者 入っていません (dev-rpg で ✻ Worked for 42s · done 4:20 PM │ ┃ Lv 30) │ ┃ 未取得 実績の山 入っていません (dev-rpg › /trophies │ ┃ の実績を 10 個) │ ┃ 未取得 竜を倒す 入っていません (dev-rpg │ ┃ のボスを 1 体倒す) │ ┃ 未取得 番人越え 入っていません (ダンジョン │ ┃ 10 階の番人を倒す) │ ┃ 未取得 地下三十階 入っていません │ ┃ (ダンジョンの 30 階を勝ち抜く) │ ┃ 未取得 百階の主 入っていません (ダンジョンの │ ┃ 100 階を勝ち抜く) │ ┃ 未取得 集め始め 入っていません │ ┃ (ガチャの図鑑を 10 種) │ ┃ 未取得 図鑑の半分 入っていません ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 実績帳
実績帳 トロフィー 0 / 23 個 dev-rpg: 入っていません turn-gacha: 入っていません aqua-pet: 入っていません wait-games: 入っていません daily-quest: 入っていません 未取得 冒険の始まり 入っていません (dev-rpg で Lv 5) 未取得 二桁の勇者 入っていません (dev-rpg で Lv 10) 未取得 歴戦の勇者 入っていません (dev-rpg で Lv 30) 未取得 実績の山 入っていません (dev-rpg の実績を 10 個) 未取得 竜を倒す 入っていません (dev-rpg のボスを 1 体倒す) 未取得 番人越え 入っていません (ダンジョン 10 階の番人を倒す) 未取得 地下三十階 入っていません (ダンジョンの 30 階を勝ち抜く) 未取得 百階の主 入っていません (ダンジョンの 100 階を勝ち抜く) 未取得 集め始め 入っていません (ガチャの図鑑を 10 種) 未取得 図鑑の半分 入っていません (ガチャの図鑑を半分うめる) 未取得 図鑑の完成 入っていません (ガチャの図鑑を全部うめる) 未取得 虹の一枚 入っていません (SSR のカードを引く) 未取得 にぎやかな水槽 入っていません (水槽の魚を 3 種) 未取得 水槽の図鑑 入っていません (水槽の魚を 6 種) 未取得 水槽の全種 入っていません (水槽の魚を 10 種) 未取得 長い付き合い 入っていません (水槽を 30 日育てる) 未取得 ひと休み 入っていません (待ちゲームで初めて記録) 未取得 遊びの達人 入っていません (待ちゲーム 4 種で記録) 未取得 2048 に届く 入っていません (2048 のタイルを作る) 未取得 初めてのお題 入っていません (お題を 1 つこなす) 未取得 七日の皆勤 入っていません (お題を 7 日続けて全部こなす) 未取得 お題の常連 入っていません (お題を通算 50 個) 未取得 棚がそろう 0 / 5 (5 つの棚でトロフィーを 1 つずつ)
README

claude-code-mods

English

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

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

入っている mod

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

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

画面

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

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

動く絵

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

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

明るいテーマ

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

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

動作環境

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

入れ方

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

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

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

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

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

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

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

まとめて入れる

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

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

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

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

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

PowerShell:

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

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

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

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

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

気を付けること

  • rule-switch の約束ごとは、サブエージェントのシステムプロンプトにも入ります。また、組織が Claude Code を管理している環境では、利用者が入れたプラグインのシステムプロンプトのフックは動きません
  • rule-switch を切り替えると、プロンプトのキャッシュが作り直されます
  • canvas-pane のツールに渡された生の SVG は、スクリプト・イベント属性・外部の参照を含む物を断ります(ペインでは画像として描かれ、スクリプトは動きません)
  • turn-haiku は、答えて終わったターンごと(ツールを使ったか 20 秒以上かかった時)に Haiku のモデルを 1 回呼び、トークンを使います。/haiku の「自動で詠む」で止められます
  • port-watch の「止める」は子プロセスごと止めます(Windows は taskkill /T /F)。node で動く MCP サーバーも開発用に見えるので、止める前に名前とポートを確かめてください。macOS / Linux(lsof)の実機では未確認です
  • pre-check の秘密情報の検出は手がかり程度です。見つからなくても安全とは限りません
  • done-chime は Windows では PowerShell の SoundPlayer で鳴らします。それ以外は Claude Code の音の再生を使い、Linux のターミナルでは鳴らないことがあります
  • spec-board の一覧は SpecStatus が GitHub の API を使うため、回数の上限に当たることがあります。読み直しは 5 分ごとです
  • ci-pane と git を読む mod は、所有者の違うフォルダ(別ドライブなど)では git が止まります。git config --global --add safe.directory <フォルダ> を足してください
  • aqua-pet の背景は 15 分ごとに描き直します。描き直すと泳ぎや泡のアニメが頭から始まります
  • aqua-pet 0.2 の記録は、開いた時に今の匹数の魚を作って引き継ぎます。0.2 と 0.3 のセッションを同時に開いていると、魚の来た日が作り直されることがあります
  • dev-rpg の装備は turn-gacha 0.2.0 以降と一緒に入れた時だけ使えます。ガチャの図鑑から消えたカードは外れた扱いになりますが、記録は残ります
  • toast-inbox が拾うのは、ほかの mod が $.ui.toast / $.ui.status を呼んだ時だけです。「空にする」はほかのセッションの記録も消します
  • usage-history は入れた後のターンから数えます。日付・曜日・時刻は日本時間です
  • file-map は失敗した呼び出しを数えません。Grep と Glob は path を渡した時だけ数えます
  • 明るいテーマでも、水槽・俳句の短冊・おみくじの紙・実況のスタジアム・RPG の窓は絵の色のままです
  • mod の版を上げたら、開いているほかのセッションも開き直してください。0.7.0 から記録は知らない欄を残して書くので、新しい版の記録は消えなくなりましたが、0.6 以前の mod を読んだまま動いているセッションは、新しい版で増えた欄(魚の種類・装備・SP の割り振りなど)を消して書き戻します
  • fail-log の記録は、同時に開いた全セッションで共有します。「今日」は日本時間の日付で、残している 200 件から数えます
  • todo-board は git の中では git grep で読みます(.gitignore の物は読みません)。git の外では作業フォルダを歩いて読み(2000 ファイル・5000 件・8 秒で止め、1 MiB を超えるファイルと . で始まるフォルダ・node_modules・dist などは読みません)、この時はファイルを変えても自動では読み直さないので「再読込」を押してください
  • daily-quest のお題は日本時間の日付だけで決まります。数えるのはメインのターンだけで、「テスト」はコマンドの名前の形(npm test・pytest など)で見ます。ごほうびは dev-rpg と turn-gacha がターンの終わりに 1 回だけ受け取ります
  • trophy-shelf はほかの mod の記録を読むだけです。入っていない mod の棚は「入っていません」と出ます。一度取ったトロフィーは、その mod を外しても残ります
  • cc-dash の「自分の並び」は記録に残るので、全セッションで共有されます
  • mod-check が版と「読み込みを拒まれた」を出せるのは、mod-check より後に読み込まれた mod だけです。「state まだ」は壊れているという意味ではありません(開
Source 4 files
hooks/register.tsx 149 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ShelfFirstAt } from '../types'
5import type { ShelfRaw } from './shelf'
6import { addFirst, asFirstAt, countText, freshIds, judge, shelfLines, shelfSvg, snapshotOf, withUnknown } from './shelf'
7import { followTheme, mayWrite, readTheme, rememberVersion } from './style'
8
9type $ = EngineInterface
10
11const PANE = 'trophy-shelf'
12const TITLE = '実績帳'
13const DESCRIPTION = 'ほかの mod の記録を 1 枚の棚にまとめ、トロフィーを集める'
14
15const FIRST_AT = atom({ plugin: 'trophy-shelf', key: 'firstAt' } as const, {} as ShelfFirstAt)
16
17// 他の mod の state (読むだけ)。plugin と key はソースに文字で書く (validate が読む)
18const RPG_HERO = { plugin: 'dev-rpg', key: 'hero' } as const
19const GACHA_SAVE = { plugin: 'turn-gacha', key: 'save' } as const
20const GACHA_CARDS = { plugin: 'turn-gacha', key: 'cards' } as const
21const AQUA_TANK = { plugin: 'aqua-pet', key: 'tank' } as const
22const GAMES_STATS = { plugin: 'wait-games', key: 'stats' } as const
23const QUEST_STREAK = { plugin: 'daily-quest', key: 'streak' } as const
24const QUEST_DONE = { plugin: 'daily-quest', key: 'totalDone' } as const
25
26/** 1 つ読む。その mod が入っていない・読めない時は undefined */
27const get = async (f: () => Promise<{ value: unknown }>) => {
28  try {
29    return (await f()).value
30  } catch {
31    return undefined
32  }
33}
34
35/** 5 つの mod の state を読む。描画の中で呼ぶと、どれかが変わった時に描き直る */
36const readRaw = async ($: $): Promise<ShelfRaw> => ({
37  hero: await get(() => $.state.get(RPG_HERO)),
38  save: await get(() => $.state.get(GACHA_SAVE)),
39  cards: await get(() => $.state.get(GACHA_CARDS)),
40  tank: await get(() => $.state.get(AQUA_TANK)),
41  stats: await get(() => $.state.get(GAMES_STATS)),
42  streak: await get(() => $.state.get(QUEST_STREAK)),
43  totalDone: await get(() => $.state.get(QUEST_DONE)),
44})
45
46// store への書き込みは 1 本ずつ
47let chain: Promise<unknown> = Promise.resolve()
48const serial = <T,>(f: () => Promise<T>) => {
49  const p = chain.then(f, f)
50  chain = p.catch(() => {})
51  return p
52}
53
54const BROKEN = 'trophy-shelf: 記録を読めなかったので、取った時刻はまだ書いていません。次のターンの終わりにもう一度読みます'
55/** 読めない時のトーストを出したか。読めたら戻す (続けて読めない間は 1 回だけ出す) */
56let toldBroken = false
57
58/**
59 * 今取れているのに時刻の無いトロフィーに、今の時刻を付ける。store は書く直前に読み直して足す。
60 * 読めない・書けない時は state も変えない (時刻の無いまま残るので、次のターンの終わりにもう一度)
61 */
62const record = ($: $) =>
63  serial(async () => {
64    const fresh = freshIds(judge(snapshotOf(await readRaw($)), await read($, FIRST_AT)))
65    if (fresh.length === 0) return
66    const now = await $.clock.now()
67    // 古い版のまま動いている時は書かない (新しい版のトロフィーを消さないように)
68    if (!(await mayWrite($, now))) return
69    // 読めない時 (reject・形が違う) は書かない。state の写しで書くと、新しい版が足した物が消える
70    const got = await $.store.get('firstAt').then(
71      v => ({ v }),
72      () => undefined,
73    )
74    const saved = got === undefined ? undefined : got.v === undefined ? {} : asFirstAt(got.v)
75    if (got === undefined || saved === undefined) {
76      if (!toldBroken) $.ui.toast(BROKEN)
77      toldBroken = true
78      return
79    }
80    toldBroken = false
81    const next = addFirst(saved, fresh, now)
82    // この版の知らない欄 (新しい版が足したトロフィー) は読んだまま残す
83    const isSaved = await $.store.set('firstAt', withUnknown(got.v, next)).then(
84      () => true,
85      () => false,
86    )
87    if (isSaved) await update($, FIRST_AT, () => next)
88  })
89
90// /trophies を自分で取れたか (取れなければ /trophy-shelf にして、/trophies は横取りしない)
91let ownsMain = false
92
93const openPane = async ($: $) => {
94  await record($).catch(() => {})
95  await $.ui.open({ id: PANE, title: TITLE })
96  return { text: '実績帳を開きました。' }
97}
98
99export const register: Register = on => {
100  followTheme(on)
101
102  on('session.start', async ($, e, next) => {
103    const started = await next(e)
104    await rememberVersion($)
105    const got = await $.command.register({ name: 'trophies', description: DESCRIPTION }).catch(() => undefined)
106    ownsMain = got?.command === 'trophies'
107    if (!ownsMain) await $.command.register({ name: 'trophy-shelf', description: DESCRIPTION }).catch(() => {})
108    await readTheme($)
109    const saved = asFirstAt(await $.store.get('firstAt').catch(() => undefined))
110    if (saved) await update($, FIRST_AT, () => saved)
111    await record($).catch(() => {})
112    return started
113  })
114
115  on('command.run', { command: 'trophies' }, ($, e, next) => (ownsMain ? openPane($) : next(e))).catch(($, e, next) => next(e))
116  on('command.run', { command: 'trophy-shelf' }, openPane)
117
118  // メインのターンが答えて終わるたびに見る (他の mod が数を増やした後に拾えるよう、下を先に通す)
119  on('turn.complete', async ($, e, next) => {
120    const done = await next(e)
121    if (e.agentId === undefined && e.reason === 'answer') await record($).catch(() => {})
122    return done
123  }).catch(($, e, next) => next(e))
124
125  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
126    const t = $.ui.resolve(e)
127    const { Box, Text } = t
128    const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : undefined
129    const firstAt = await read($, FIRST_AT)
130    const snap = snapshotOf(await readRaw($))
131
132    return (
133      <Box flexDirection="column" gap={1}>
134        {Svg ? (
135          <Svg key="shelf" source={shelfSvg(snap, firstAt)} alt={`実績帳 ${countText(judge(snap, firstAt))}`} />
136        ) : (
137          <Box key="lines" flexDirection="column">
138            {shelfLines(snap, firstAt).map((l, i) => (
139              <Text key={`line-${i}`} color={l.color} dimColor={l.isDim} bold={l.isBold}>
140                {l.text}
141              </Text>
142            ))}
143          </Box>
144        )}
145      </Box>
146    )
147  })
148}
149
hooks/shelf.ts 292 lines
1// 実績帳の純関数(他の mod の state を確かめる・トロフィーの判定・SVG と terminal の行)
2import type { ShelfFirstAt, ShelfMod, ShelfSnapshot } from '../types'
3import { C, HEADER_H, W, esc, fadeIn, frame, growBar, header, panel, short } from './style'
4
5// ---------------------------------------------------------------- 他の mod の state を確かめる
6
7const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
8const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
9const keysOf = (v: unknown) => (isObj(v) ? Object.keys(v) : [])
10
11// dev-rpg の hooks/rpg.ts の xpToReach と MAX_LEVEL の写し。dev-rpg が式を変えたら合わせる
12const MAX_LEVEL = 99
13const xpToReach = (level: number) => 40 * (level - 1) + 10 * level * (level - 1)
14export function levelOf(xp: number) {
15  let level = 1
16  while (level < MAX_LEVEL && xp >= xpToReach(level + 1)) level++
17  return level
18}
19
20/** 他の mod から読んだ生の値。読めなかった物は undefined */
21export type ShelfRaw = {
22  hero?: unknown
23  save?: unknown
24  cards?: unknown
25  tank?: unknown
26  stats?: unknown
27  streak?: unknown
28  totalDone?: unknown
29}
30
31/** 生の値から判定に使う数を作る。形の合わない mod は「入っていない」と同じに扱う */
32export function snapshotOf(raw: ShelfRaw): ShelfSnapshot {
33  const s: ShelfSnapshot = {}
34  const hero = raw.hero
35  if (isObj(hero) && typeof hero.xp === 'number') {
36    // deepest は dev-rpg 0.6.0 から (古い版なら 0)
37    s.rpg = { level: levelOf(num(hero.xp)), achievements: keysOf(hero.achievements).length, wins: num(hero.wins), bosses: keysOf(hero.defeated).length, deepest: num(hero.deepest) }
38  }
39  const save = raw.save
40  if (isObj(save) && isObj(save.book)) {
41    const cards = Array.isArray(raw.cards) ? raw.cards.filter(isObj) : []
42    const rarity = new Map(cards.map(c => [String(c.id), c.rarity]))
43    const book = Object.keys(save.book)
44    // カードの表が読めれば表にある物だけを数える(消えたカードは数えない)
45    const owned = cards.length > 0 ? book.filter(id => rarity.has(id)) : book
46    s.gacha = { owned: owned.length, total: cards.length, ssr: owned.filter(id => rarity.get(id) === 'SSR').length, pulls: num(save.total) }
47  }
48  const tank = raw.tank
49  if (isObj(tank)) {
50    const fish = Array.isArray(tank.fish) ? tank.fish.filter(isObj) : []
51    s.aqua = { kinds: new Set(fish.map(f => String(f.kind))).size, fish: fish.length, days: isObj(tank.view) ? num(tank.view.days) : 0 }
52  }
53  const stats = raw.stats
54  if (isObj(stats)) {
55    const puzzles = isObj(stats.puzzles) ? stats.puzzles : {}
56    const won = Object.entries(puzzles).filter(([, p]) => isObj(p) && num(p.wins) > 0)
57    // 数独は大きさ別に記録があるが、ゲームとしては 1 種
58    const kinds = new Set(won.map(([k]) => (k.startsWith('sudoku') ? 'sudoku' : k)))
59    if (num(stats.best2048) > 0 || num(stats.wins2048) > 0) kinds.add('2048')
60    if (num(stats.winsMines) > 0) kinds.add('mines')
61    s.games = {
62      kinds: kinds.size,
63      best2048: num(stats.best2048),
64      wins2048: num(stats.wins2048),
65      wins: num(stats.wins2048) + num(stats.winsMines) + won.reduce((n, [, p]) => n + num(isObj(p) ? p.wins : 0), 0),
66    }
67  }
68  if (typeof raw.streak === 'number' || typeof raw.totalDone === 'number') s.quest = { streak: num(raw.streak), totalDone: num(raw.totalDone) }
69  return s
70}
71
72// ---------------------------------------------------------------- トロフィー
73
74export type Trophy = {
75  id: string
76  /** どの棚の物か。'all' は棚をまたぐ物 */
77  mod: ShelfMod | 'all'
78  name: string
79  /** 丸の中の 1 文字 */
80  glyph: string
81  hint: string
82  /** [今の数, 目標]。その mod が入っていなければ undefined */
83  progress: (s: ShelfSnapshot) => [number, number] | undefined
84}
85
86const at = <T,>(v: T | undefined, f: (v: T) => [number, number]) => (v === undefined ? undefined : f(v))
87
88export const TROPHIES: Trophy[] = [
89  { id: 'rpg-lv5', mod: 'dev-rpg', name: '冒険の始まり', glyph: '始', hint: 'dev-rpg で Lv 5', progress: s => at(s.rpg, r => [r.level, 5]) },
90  { id: 'rpg-lv10', mod: 'dev-rpg', name: '二桁の勇者', glyph: '十', hint: 'dev-rpg で Lv 10', progress: s => at(s.rpg, r => [r.level, 10]) },
91  { id: 'rpg-lv30', mod: 'dev-rpg', name: '歴戦の勇者', glyph: '勇', hint: 'dev-rpg で Lv 30', progress: s => at(s.rpg, r => [r.level, 30]) },
92  { id: 'rpg-ach10', mod: 'dev-rpg', name: '実績の山', glyph: '績', hint: 'dev-rpg の実績を 10 個', progress: s => at(s.rpg, r => [r.achievements, 10]) },
93  { id: 'rpg-boss', mod: 'dev-rpg', name: '竜を倒す', glyph: '竜', hint: 'dev-rpg のボスを 1 体倒す', progress: s => at(s.rpg, r => [r.bosses, 1]) },
94  // dev-rpg 0.6.0 のダンジョン (勝ち抜いた一番深い階)。10 階ごとに番人がいる
95  { id: 'rpg-deep10', mod: 'dev-rpg', name: '番人越え', glyph: '番', hint: 'ダンジョン 10 階の番人を倒す', progress: s => at(s.rpg, r => [r.deepest, 10]) },
96  { id: 'rpg-deep30', mod: 'dev-rpg', name: '地下三十階', glyph: '潜', hint: 'ダンジョンの 30 階を勝ち抜く', progress: s => at(s.rpg, r => [r.deepest, 30]) },
97  { id: 'rpg-deep100', mod: 'dev-rpg', name: '百階の主', glyph: '淵', hint: 'ダンジョンの 100 階を勝ち抜く', progress: s => at(s.rpg, r => [r.deepest, 100]) },
98  { id: 'gacha-10', mod: 'turn-gacha', name: '集め始め', glyph: '集', hint: 'ガチャの図鑑を 10 種', progress: s => at(s.gacha, g => [g.owned, 10]) },
99  {
100    id: 'gacha-half',
101    mod: 'turn-gacha',
102    name: '図鑑の半分',
103    glyph: '半',
104    hint: 'ガチャの図鑑を半分うめる',
105    progress: s => at(s.gacha, g => (g.total > 0 ? [g.owned, Math.ceil(g.total / 2)] : [0, 1])),
106  },
107  {
108    id: 'gacha-all',
109    mod: 'turn-gacha',
110    name: '図鑑の完成',
111    glyph: '完',
112    hint: 'ガチャの図鑑を全部うめる',
113    progress: s => at(s.gacha, g => (g.total > 0 ? [g.owned, g.total] : [0, 1])),
114  },
115  { id: 'gacha-ssr', mod: 'turn-gacha', name: '虹の一枚', glyph: '虹', hint: 'SSR のカードを引く', progress: s => at(s.gacha, g => [g.ssr, 1]) },
116  { id: 'aqua-3', mod: 'aqua-pet', name: 'にぎやかな水槽', glyph: '魚', hint: '水槽の魚を 3 種', progress: s => at(s.aqua, a => [a.kinds, 3]) },
117  { id: 'aqua-6', mod: 'aqua-pet', name: '水槽の図鑑', glyph: '鱗', hint: '水槽の魚を 6 種', progress: s => at(s.aqua, a => [a.kinds, 6]) },
118  { id: 'aqua-10', mod: 'aqua-pet', name: '水槽の全種', glyph: '海', hint: '水槽の魚を 10 種', progress: s => at(s.aqua, a => [a.kinds, 10]) },
119  { id: 'aqua-30d', mod: 'aqua-pet', name: '長い付き合い', glyph: '月', hint: '水槽を 30 日育てる', progress: s => at(s.aqua, a => [a.days, 30]) },
120  { id: 'games-1', mod: 'wait-games', name: 'ひと休み', glyph: '休', hint: '待ちゲームで初めて記録', progress: s => at(s.games, g => [g.kinds, 1]) },
121  { id: 'games-4', mod: 'wait-games', name: '遊びの達人', glyph: '遊', hint: '待ちゲーム 4 種で記録', progress: s => at(s.games, g => [g.kinds, 4]) },
122  { id: 'games-2048', mod: 'wait-games', name: '2048 に届く', glyph: '二', hint: '2048 のタイルを作る', progress: s => at(s.games, g => [g.wins2048, 1]) },
123  { id: 'quest-1', mod: 'daily-quest', name: '初めてのお題', glyph: '題', hint: 'お題を 1 つこなす', progress: s => at(s.quest, q => [q.totalDone, 1]) },
124  { id: 'quest-7', mod: 'daily-quest', name: '七日の皆勤', glyph: '七', hint: 'お題を 7 日続けて全部こなす', progress: s => at(s.quest, q => [q.streak, 7]) },
125  { id: 'quest-50', mod: 'daily-quest', name: 'お題の常連', glyph: '常', hint: 'お題を通算 50 個', progress: s => at(s.quest, q => [q.totalDone, 50]) },
126  // 棚をまたぐ物。judge で、他のトロフィーを取った棚の数から決める
127  { id: 'all-shelves', mod: 'all', name: '棚がそろう', glyph: '棚', hint: '5 つの棚でトロフィーを 1 つずつ', progress: () => undefined },
128]
129
130export const SHELVES: ShelfMod[] = ['dev-rpg', 'turn-gacha', 'aqua-pet', 'wait-games', 'daily-quest']
131
132export type Judged = {
133  trophy: Trophy
134  cur: number
135  goal: number
136  /** その mod が入っていて数が読めた */
137  isKnown: boolean
138  isDone: boolean
139  /** 初めて取った時刻。まだ記録していなければ undefined */
140  firstAt?: number
141}
142
143/** 全トロフィーの今の進み。一度取った物 (firstAt にある物) は、mod を外しても取ったまま */
144export function judge(s: ShelfSnapshot, firstAt: ShelfFirstAt): Judged[] {
145  const list = TROPHIES.filter(t => t.mod !== 'all').map(trophy => {
146    const p = trophy.progress(s)
147    const [cur, goal] = p ?? [0, 1]
148    const first = firstAt[trophy.id]
149    return { trophy, cur: Math.min(cur, goal), goal, isKnown: p !== undefined, isDone: first !== undefined || (p !== undefined && cur >= goal), firstAt: first }
150  })
151  const filled = SHELVES.filter(m => list.some(j => j.trophy.mod === m && j.isDone)).length
152  const all = TROPHIES.filter(t => t.mod === 'all').map(trophy => {
153    const first = firstAt[trophy.id]
154    return { trophy, cur: filled, goal: SHELVES.length, isKnown: true, isDone: first !== undefined || filled >= SHELVES.length, firstAt: first }
155  })
156  return [...list, ...all]
157}
158
159/** 今取れている物のうち、まだ時刻を記録していない id */
160export const freshIds = (judged: readonly Judged[]) => judged.filter(j => j.isDone && j.firstAt === undefined).map(j => j.trophy.id)
161
162/** store から読んだ値を確かめる。形の合わない物は捨てる。オブジェクトでなければ undefined */
163export function asFirstAt(v: unknown): ShelfFirstAt | undefined {
164  if (!isObj(v)) return undefined
165  const known = new Set(TROPHIES.map(t => t.id))
166  return Object.fromEntries(Object.entries(v).filter(([id, t]) => known.has(id) && typeof t === 'number' && Number.isFinite(t))) as ShelfFirstAt
167}
168
169/**
170 * store に書く形。書く直前に読んだ raw のうち、この版の知らない欄 (新しい版のトロフィーの時刻など) を残して mine を重ねる。
171 * 古い版のまま動いているセッションが、新しい版の記録を消さないように
172 */
173export function withUnknown(raw: unknown, mine: ShelfFirstAt): Record<string, unknown> {
174  const known = new Set(TROPHIES.map(t => t.id))
175  return { ...(isObj(raw) ? Object.fromEntries(Object.entries(raw).filter(([id]) => !known.has(id))) : {}), ...mine }
176}
177
178/** 保存済みの物に新しく取った物を足す。先に記録した時刻を残す */
179export function addFirst(saved: ShelfFirstAt, ids: readonly string[], now: number): ShelfFirstAt {
180  const next = { ...saved }
181  for (const id of ids) if (next[id] === undefined) next[id] = now
182  return next
183}
184
185// ---------------------------------------------------------------- 棚の一言
186
187/** 棚ごとの一言。入っていなければ undefined */
188export function shelfText(mod: ShelfMod, s: ShelfSnapshot): string | undefined {
189  switch (mod) {
190    case 'dev-rpg':
191      return s.rpg && `Lv ${s.rpg.level} / 実績 ${s.rpg.achievements} / 勝ち ${s.rpg.wins}`
192    case 'turn-gacha':
193      return s.gacha && `図鑑 ${s.gacha.owned}${s.gacha.total > 0 ? ` / ${s.gacha.total}` : ''} 種 / SSR ${s.gacha.ssr}`
194    case 'aqua-pet':
195      return s.aqua && `魚 ${s.aqua.kinds} 種 ${s.aqua.fish} 匹 / ${s.aqua.days} 日目`
196    case 'wait-games':
197      return s.games && `記録 ${s.games.kinds} 種 / 2048 最高 ${s.games.best2048}`
198    case 'daily-quest':
199      return s.quest && `連続 ${s.quest.streak} 日 / 通算 ${s.quest.totalDone} 個`
200  }
201}
202
203const NOT_INSTALLED = '入っていません'
204
205const jst = (ms: number) => new Date(ms + 9 * 3600_000)
206/** 日本時間の M/D */
207export const dayOf = (ms: number) => `${jst(ms).getUTCMonth() + 1}/${jst(ms).getUTCDate()}`
208
209export const countText = (judged: readonly Judged[]) => `トロフィー ${judged.filter(j => j.isDone).length} / ${judged.length} 個`
210
211// ---------------------------------------------------------------- SVG
212
213const ROW = 40
214const CARD_W = 172
215const CARD_H = 44
216const CARD_GAP = 8
217
218/** 上に棚 5 段(mod ごとの数と、その棚のトロフィーの進み)、下にトロフィーのカード 2 列 */
219export function shelfSvg(s: ShelfSnapshot, firstAt: ShelfFirstAt) {
220  const judged = judge(s, firstAt)
221  let y = HEADER_H + 12
222  const out: string[] = [header('実績帳', countText(judged), C.yellow)]
223
224  SHELVES.forEach((mod, k) => {
225    const text = shelfText(mod, s)
226    const mine = judged.filter(j => j.trophy.mod === mod)
227    const done = mine.filter(j => j.isDone).length
228    out.push(`<text x="14" y="${y + 15}" font-size="12" font-weight="bold" fill="${text ? C.text : C.dim}">${esc(mod)}</text>`)
229    out.push(`<text x="${W - 14}" y="${y + 15}" font-size="12" fill="${C.dim}" text-anchor="end">${done} / ${mine.length}</text>`)
230    out.push(`<text x="120" y="${y + 15}" font-size="12" fill="${text ? C.text : C.dim}">${esc(short(text ?? NOT_INSTALLED, 24))}</text>`)
231    const barW = W - 28
232    out.push(`<rect x="14" y="${y + 24}" width="${barW}" height="6" rx="3" fill="${C.face}"/>`)
233    if (done > 0) out.push(growBar(14, y + 24, (barW * done) / mine.length, 6, C.yellow, k * 0.08))
234    y += ROW
235  })
236
237  y += 8
238  out.push(`<text x="14" y="${y + 12}" font-size="12" font-weight="bold" fill="${C.dim}">トロフィー</text>`)
239  y += 22
240  judged.forEach((j, k) => {
241    const x = 14 + (k % 2) * (CARD_W + CARD_GAP)
242    const cy = y + Math.floor(k / 2) * (CARD_H + CARD_GAP)
243    out.push(fadeIn(card(j, x, cy), 0.3 + k * 0.03))
244  })
245  const rows = Math.ceil(judged.length / 2)
246  const h = y + rows * (CARD_H + CARD_GAP) + 6
247  return frame(h, out.join(''))
248}
249
250function card(j: Judged, x: number, y: number) {
251  const cx = x + 22
252  const cy = y + CARD_H / 2
253  const parts = [panel(x, y, CARD_W, CARD_H)]
254  if (j.isDone) {
255    parts.push(`<circle cx="${cx}" cy="${cy}" r="15" fill="${C.yellow}"/>`)
256    parts.push(`<text x="${cx}" y="${cy + 5}" font-size="14" font-weight="bold" fill="${C.bg}" text-anchor="middle">${esc(j.trophy.glyph)}</text>`)
257  } else {
258    parts.push(`<circle cx="${cx}" cy="${cy}" r="14.5" fill="none" stroke="${C.line}" stroke-width="1.5" stroke-dasharray="3 3"/>`)
259    parts.push(`<text x="${cx}" y="${cy + 5}" font-size="14" fill="${C.dim}" text-anchor="middle">${esc(j.trophy.glyph)}</text>`)
260  }
261  parts.push(`<text x="${x + 44}" y="${y + 19}" font-size="12" font-weight="bold" fill="${j.isDone ? C.text : C.dim}">${esc(short(j.trophy.name, 10))}</text>`)
262  if (j.isDone) {
263    parts.push(`<text x="${x + 44}" y="${y + 36}" font-size="12" fill="${C.green}">${j.firstAt === undefined ? '取得' : `${dayOf(j.firstAt)} に取得`}</text>`)
264  } else if (!j.isKnown) {
265    parts.push(`<text x="${x + 44}" y="${y + 36}" font-size="12" fill="${C.dim}">${NOT_INSTALLED}</text>`)
266  } else {
267    const barW = 64
268    parts.push(`<rect x="${x + 44}" y="${y + 28}" width="${barW}" height="6" rx="3" fill="${C.line}"/>`)
269    if (j.cur > 0) parts.push(growBar(x + 44, y + 28, (barW * j.cur) / j.goal, 6, C.blue, 0.4))
270    parts.push(`<text x="${x + CARD_W - 10}" y="${y + 36}" font-size="12" fill="${C.dim}" text-anchor="end">${j.cur} / ${j.goal}</text>`)
271  }
272  return parts.join('')
273}
274
275// ---------------------------------------------------------------- terminal
276
277export type Line = { text: string; color?: 'success' | 'warning'; isDim?: boolean; isBold?: boolean }
278
279export function shelfLines(s: ShelfSnapshot, firstAt: ShelfFirstAt): Line[] {
280  const judged = judge(s, firstAt)
281  const lines: Line[] = [{ text: `実績帳 ${countText(judged)}`, isBold: true }]
282  for (const mod of SHELVES) {
283    const text = shelfText(mod, s)
284    lines.push({ text: `${mod}: ${text ?? NOT_INSTALLED}`, isDim: text === undefined })
285  }
286  for (const j of judged) {
287    if (j.isDone) lines.push({ text: `取得 ${j.trophy.name}${j.firstAt === undefined ? '' : ` (${dayOf(j.firstAt)})`}`, color: 'success' })
288    else lines.push({ text: `未取得 ${j.trophy.name} ${j.isKnown ? `${j.cur} / ${j.goal}` : NOT_INSTALLED} (${j.trophy.hint})`, isDim: true })
289  }
290  return lines
291}
292
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 53 lines
1// trophy-shelf の $.state の契約と、読むだけの他の mod の state の写し。
2// 他の mod の型は dependencies を使わず、要る欄だけをここに写す(見本: mods/dev-rpg/types/index.d.ts の 'turn-gacha')。
3// 読んだ値は hooks/shelf.ts の snapshotOf で形を確かめてから使う(その mod が入っていない時は undefined)
4
5/** 棚に並べる mod */
6export type ShelfMod = 'dev-rpg' | 'turn-gacha' | 'aqua-pet' | 'wait-games' | 'daily-quest'
7
8/** 他の mod の state から作る、トロフィーの判定に使う数。入っていない mod は undefined */
9export type ShelfSnapshot = {
10  rpg?: { level: number; achievements: number; wins: number; bosses: number; deepest: number }
11  gacha?: { owned: number; total: number; ssr: number; pulls: number }
12  aqua?: { kinds: number; fish: number; days: number }
13  games?: { kinds: number; best2048: number; wins2048: number; wins: number }
14  quest?: { streak: number; totalDone: number }
15}
16
17/** トロフィーの id → 初めて取った時刻 (ms) */
18export type ShelfFirstAt = Record<string, number>
19
20declare module 'claude-code' {
21  interface PluginState {
22    'trophy-shelf': {
23      /** $.store の 'firstAt' の写し。書く直前に store を読み直して足す */
24      firstAt: ShelfFirstAt
25    }
26    /** ここから下は他の mod の state の、trophy-shelf が読む欄だけ(書くのは各 mod) */
27    'dev-rpg': {
28      hero: { xp: number; achievements: Record<string, number>; wins: number; defeated: Record<string, number>; deepest?: number }
29    }
30    'turn-gacha': {
31      save: { book: Record<string, { count: number; firstAt: number }>; total: number }
32      cards: { id: string; rarity: 'N' | 'R' | 'SR' | 'SSR' }[]
33    }
34    'aqua-pet': {
35      tank: { fish: { kind: string }[]; view: { days: number } }
36    }
37    'wait-games': {
38      stats: {
39        best2048: number
40        wins2048: number
41        winsMines: number
42        puzzles?: Partial<Record<string, { wins: number }>>
43      }
44    }
45    'daily-quest': {
46      /** 3 つ全部こなした日が続いている日数 */
47      streak: number
48      /** 通算でこなしたお題の数 */
49      totalDone: number
50    }
51  }
52}
53