/omikuji で今日のコード運勢を引く。1 日 1 回の本番は同じ日なら同じ結果で、筒を振るアニメと和紙のおみくじをペインに SVG で表示する

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 187 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { addDay, fillText, fortune, isResult, jstDate, objOf, paneLines, paneSvg, toDays } from './omikuji'
5import { followTheme, mayWrite, readTheme, rememberVersion } from './style'
6
7const PANE = 'code-omikuji'
8const TITLE = 'おみくじ'
9const DESCRIPTION = '今日のコード運勢を引く (1 日 1 回の本番は同じ日なら同じ結果)'
10/** 振るアニメの長さ (紙が開き切るまで) */
11const SHAKE_MS = 3600
12
13const TODAY = atom({ plugin: 'code-omikuji', key: 'today' } as const, null)
14const PLAY = atom({ plugin: 'code-omikuji', key: 'play' } as const, null)
15const PLAYS = atom({ plugin: 'code-omikuji', key: 'plays' } as const, 0)
16const HISTORY = atom({ plugin: 'code-omikuji', key: 'history' } as const, [])
17const MODE = atom({ plugin: 'code-omikuji', key: 'mode' } as const, 'shake')
18const IS_OPENED = atom({ plugin: 'code-omikuji', key: 'isOpened' } as const, false)
19const NOTE = atom({ plugin: 'code-omikuji', key: 'note' } as const, '')
20
21// 主の名前 (/omikuji) を持てたか。session.start で決まる (リロードでも来る)
22let ownsMain = false
23
24/** 今日の本番を決めて store と state に置く。同じ日なら store の分をそのまま使う (形が合わなければ作り直す) */
25async function drawToday($: EngineInterface) {
26 const now = await $.clock.now()
27 const date = jstDate(now)
28 // 読めない時 (例外・形が違う) は引いて見せるだけで、store には書かない (上書きしない)
29 const BROKEN = Symbol('broken')
30 const saved: unknown = await $.store.get('today').catch(() => BROKEN)
31 const kept: unknown = await $.store.get('history').catch(() => BROKEN)
32 // 引き直す時も、知らない欄 (新しい版が足した欄) は残して書く
33 const today = isResult(saved) && saved.date === date ? saved : { ...objOf(saved), ...fortune(date) }
34 const history = addDay(toDays(kept), { date, rank: today.rank })
35 const isPlain = typeof saved === 'object' && saved !== null && !Array.isArray(saved)
36 const canWrite = saved !== BROKEN && kept !== BROKEN && (saved === undefined || isPlain) && (kept === undefined || Array.isArray(kept))
37 // 新しい版が入ったのに古い版のまま動いている時も書かない
38 if (canWrite && (await mayWrite($, now))) {
39 if (today !== saved) await $.store.set('today', today).catch(() => {})
40 await $.store.set('history', history).catch(() => {})
41 }
42 await update($, TODAY, () => today)
43 await update($, HISTORY, () => history)
44 return today
45}
46
47/** state の本番が今日の分ならそれ、日付が変わっていたら引き直す */
48async function currentToday($: EngineInterface) {
49 const today = await read($, TODAY)
50 return today && today.date === jstDate(await $.clock.now()) ? today : drawToday($)
51}
52
53/** store の分を state に写すだけ (開くまでは store に書かない) */
54async function loadSaved($: EngineInterface) {
55 const date = jstDate(await $.clock.now())
56 const saved = await $.store.get('today')
57 const kept = await $.store.get('history')
58 if (isResult(saved) && saved.date === date) await update($, TODAY, () => saved)
59 await update($, HISTORY, () => toDays(kept))
60}
61
62/**
63 * 日付が変わったら入れ替える (描画では書けないので 1 分ごとに見る)。
64 * ペインが開いていれば今日の分を引き、閉じていれば昨日の分を下ろすだけ (開くまでは store に書かない)
65 */
66async function rollover($: EngineInterface) {
67 const today = await read($, TODAY)
68 if (!today || today.date === jstDate(await $.clock.now())) return
69 await update($, PLAY, () => null)
70 if ((await $.ui.panes()).some(p => p.id === PANE)) {
71 await drawToday($)
72 await update($, MODE, () => 'paper')
73 await update($, NOTE, () => '')
74 } else await update($, TODAY, () => null)
75}
76
77// 振るアニメが終わったら紙だけの絵に替える (描き直しのたびにアニメが流れないように)
78async function settle($: EngineInterface) {
79 const n = await read($, PLAYS)
80 $.clock.after(SHAKE_MS, () => {
81 void (async () => {
82 // その間にもう一度振っていたら、そちらの分に任せる
83 if ((await read($, PLAYS)) === n) await update($, MODE, () => 'paper')
84 })().catch(() => {})
85 })
86}
87
88async function openPane($: EngineInterface) {
89 await drawToday($)
90 const wasOpened = await read($, IS_OPENED)
91 await update($, PLAY, () => null)
92 await update($, NOTE, () => '')
93 await update($, MODE, () => (wasOpened ? 'paper' : 'shake'))
94 await update($, IS_OPENED, () => true)
95 if (!wasOpened) await settle($)
96 await $.ui.open({ id: PANE, title: TITLE })
97 return { text: 'おみくじを開きました。' }
98}
99
100/** 遊びの一回。本番 (store) には触らない */
101async function shakeAgain($: EngineInterface) {
102 const today = await currentToday($)
103 const n = await update($, PLAYS, x => x + 1)
104 await update($, PLAY, () => fortune(today.date, n))
105 await update($, MODE, () => 'shake')
106 await update($, NOTE, () => '遊びの一回です。本番の運勢は変わりません')
107 await settle($)
108}
109
110async function backToToday($: EngineInterface) {
111 await update($, PLAY, () => null)
112 await update($, MODE, () => 'paper')
113 await update($, NOTE, () => '')
114}
115
116async function fill($: EngineInterface) {
117 const today = await currentToday($)
118 // replace は入力欄の下書きを消すので、カーソルの位置に入れる
119 const r = await $.prompt.fill({ text: fillText(today), mode: 'insert' }).catch(() => ({ isFilled: false, refusal: undefined }))
120 const why = r.refusal === 'dialog' ? '(ダイアログが開いています)' : r.refusal === 'no_composer' ? '(この画面には入力欄がありません)' : ''
121 await update($, NOTE, () => (r.isFilled ? '入力欄に入れました(送信はしていません)' : `入力欄に入れられませんでした${why}`))
122}
123
124export const register: Register = on => {
125 followTheme(on)
126
127 on('session.start', async ($, e, next) => {
128 const started = await next(e)
129 await readTheme($)
130 await rememberVersion($).catch(() => {})
131 // /omikuji が拒まれたら (組み込みと被ったら) /code-omikuji にする
132 const got = await $.command.register({ name: 'omikuji', description: DESCRIPTION }).catch(() => undefined)
133 ownsMain = got?.command === 'omikuji'
134 if (!ownsMain) await $.command.register({ name: 'code-omikuji', description: DESCRIPTION }).catch(() => {})
135 await loadSaved($).catch(() => {})
136 $.clock.every(60_000, () => void rollover($).catch(() => {}))
137 return started
138 })
139
140 // /omikuji を持てなかった時は、元の /omikuji に回す
141 on('command.run', { command: 'omikuji' }, ($, e, next) => (ownsMain ? openPane($) : next(e))).catch(($, e, next) => next(e))
142 // /omikuji を持てた時は /code-omikuji を登録していないので、その名前の持ち主に回す
143 on('command.run', { command: 'code-omikuji' }, ($, e, next) => (ownsMain ? next(e) : openPane($))).catch(($, e, next) => next(e))
144
145 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
146 const t = $.ui.resolve(e)
147 const { Box, Text, Button } = t
148 // terminal の表にも Svg の名前があるので、画面の種類でも分ける
149 const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : undefined
150 const today = await read($, TODAY)
151 const play = await read($, PLAY)
152 const history = await read($, HISTORY)
153 const mode = await read($, MODE)
154 const note = await read($, NOTE)
155
156 // 日付が変わった後の 1 分足らずも、昨日の分を「今日の運勢」として出さない
157 if (!today || today.date !== jstDate(await $.clock.now())) return <Text dimColor>/omikuji で今日の運勢を引きます</Text>
158 const shown = play ?? today
159 const lines = paneLines(shown, today, history, play !== null)
160
161 return (
162 <Box flexDirection="column" gap={1}>
163 {Svg ? (
164 <Svg
165 source={paneSvg(shown, today.date, history, { isShake: mode === 'shake', isPlay: play !== null })}
166 alt={lines.map(l => l.text).join('\n')}
167 />
168 ) : (
169 <Box key="lines" flexDirection="column">
170 {lines.map(l => (
171 <Text color={l.color} dimColor={l.isDim} bold={l.isBold}>
172 {l.text}
173 </Text>
174 ))}
175 </Box>
176 )}
177 <Box flexDirection="column">
178 <Button key="shake" label="もう一度振る" variant="primary" onPress={() => void shakeAgain($)} />
179 {play !== null && <Button key="back" dimColor label="本番の結果に戻す" onPress={() => void backToToday($)} />}
180 <Button key="fill" dimColor label="今日の運勢を入力欄へ" onPress={() => void fill($)} />
181 </Box>
182 {note !== '' && <Text dimColor>{note}</Text>}
183 </Box>
184 )
185 })
186}
187hooks/omikuji.ts 675 lines1// 運勢を決める乱数・言葉・SVG と terminal 用の行。どれも純関数
2import type { OmikujiDay, OmikujiRank, OmikujiResult } from '../types'
3import { C, HEADER_H, countUp, fadeIn, frame, header } from './style'
4
5export type Line = { text: string; color?: string; isDim?: boolean; isBold?: boolean }
6
7/** 日付と混ぜる固定の値。変えると全員の運勢が変わる */
8const SALT = 'code-omikuji/1'
9
10// ── 言葉 (どれも自作。実在の人物・商品名は使わない)
11const RANKS: readonly [OmikujiRank, number][] = [
12 ['大吉', 16],
13 ['中吉', 18],
14 ['小吉', 18],
15 ['吉', 22],
16 ['末吉', 12],
17 ['凶', 10],
18 ['大凶', 4],
19]
20export const RANK_NAMES = RANKS.map(r => r[0])
21const isGood = (r: OmikujiRank) => r === '大吉' || r === '中吉' || r === '小吉' || r === '吉'
22
23export const WORDS = {
24 good: [
25 '書いたコードが一度で通る日。迷わず進め',
26 '小さなコミットが大きな前進になる',
27 '長年のバグが向こうから名乗り出てくる',
28 'テストが味方につく。赤から緑へ一直線',
29 '思いつきの設計がそのまま正解になる',
30 '久しぶりに読んだ自分のコードが分かりやすい',
31 '依存の更新が何事もなく終わる',
32 '名前付けが冴える。変数名に迷わない',
33 '午前中のひらめきを大切に',
34 'リファクタリングの好機。今日なら壊れない',
35 'エラーメッセージが親切に原因を教えてくれる',
36 'ビルドが速い。待ち時間に茶でも一杯',
37 '型の誤りが先に教えてくれる。走らせる前に直せる',
38 '手が止まった所で、ちょうど良い見本が見つかる',
39 '消した行の数が書いた行の数を上回る。良い日',
40 '設定ファイルが一度で読み込まれる',
41 '質問を書いている途中で、自分で答えが分かる',
42 '差分が小さくまとまる。読む人も喜ぶ',
43 '前に書いた手順書が今日の自分を助ける',
44 '遅かった処理の原因がすぐに見つかる',
45 'ずっと赤かった検査がようやく緑になる',
46 '区切りの良い所で手が止まる。早めに休める',
47 '思い切った削除がすべて正しい',
48 '迷ったら単純な方を選べ。それが当たり',
49 ],
50 bad: [
51 '焦りは禁物。一行ずつ確かめよ',
52 '動いているコードには触れぬが吉',
53 '「ちょっとだけ」の修正が長引く兆し',
54 '本番の前にもう一度テストを回すべし',
55 '疲れたら手を止めて休むが勝ち',
56 '古いコメントを信じすぎぬこと',
57 '貼り付けたコードの出どころを確かめよ',
58 '締め切り前の大改修は控えよ',
59 '原因は思わぬ所にあり。足元を見よ',
60 '再起動で直るものは、また壊れる',
61 'ログを読む手間を惜しむべからず',
62 '今日の失敗は明日の知恵。記録を残せ',
63 '同じ名前の関数が二つある気配。確かめよ',
64 '写してきた設定を見直すべし',
65 '「後で直す」は今日も来ない',
66 '空き容量に気を配れ',
67 '一度にあれこれ変えると、原因が分からなくなる',
68 'エラーを握りつぶすと後で泣く',
69 '疲れた目で読む差分は信用するな',
70 '日付の計算は二度確かめよ',
71 '思い込みの仕様に注意。書いた物を読み直せ',
72 '待ち時間に別の作業を始めると、両方遅れる',
73 '大きな依存を足す前に、手元の物で足りぬか見よ',
74 '画面を閉じる前に保存を確かめよ',
75 ],
76 // 規格や昔からの名前の言語だけにする
77 language: [
78 'C',
79 'C++',
80 'Lisp',
81 'Scheme',
82 'Haskell',
83 'OCaml',
84 'Fortran',
85 'COBOL',
86 'SQL',
87 'Prolog',
88 'アセンブリ',
89 'シェルスクリプト',
90 '正規表現',
91 'Markdown',
92 'YAML',
93 'TOML',
94 'Pascal',
95 'Ada',
96 'ALGOL',
97 'Simula',
98 'Smalltalk',
99 'Forth',
100 'APL',
101 'BASIC',
102 'Erlang',
103 'Tcl',
104 'AWK',
105 'sed',
106 'Standard ML',
107 'Common Lisp',
108 'Modula-2',
109 'Datalog',
110 'VHDL',
111 '擬似コード',
112 ],
113 command: [
114 'git status',
115 'git diff --stat',
116 'git log --oneline -5',
117 'git stash list',
118 'git blame',
119 'ls -la',
120 'grep -rn',
121 'make test',
122 'cd ..',
123 'pwd',
124 'du -sh *',
125 'tree -L 2',
126 'diff -u',
127 'echo $?',
128 'history | tail',
129 'clear',
130 'git switch -',
131 'git show --stat',
132 'git log --graph',
133 'git branch -vv',
134 'git remote -v',
135 'git grep -n',
136 'git tag -l',
137 'wc -l',
138 'head -20',
139 'tail -f',
140 'sort | uniq -c',
141 'find . -name',
142 'env | sort',
143 'date',
144 'cat -n',
145 'less',
146 'mkdir -p',
147 'uptime',
148 ],
149 bug: [
150 '一つ直すと二つ見つかる。ただし全部浅い',
151 '原因は一文字の打ち間違い',
152 '再現手順が自然と見つかる',
153 '境界値に潜む。ゼロと空を疑え',
154 '夕方に出たバグは翌朝の自分に任せよ',
155 'タイムゾーンが犯人の気配',
156 '改行コードにご用心',
157 'キャッシュを消すと消える',
158 '気配なし。静かな一日',
159 '古いバグが新しい顔で戻ってくる',
160 '人の書いた所ではなく、自分の書いた所にあり',
161 '小さな警告を見逃すと大きくなる',
162 'ログに一行足すだけで見つかる',
163 '型を付けると隠れる所がなくなる',
164 '並び順の思い込みが原因',
165 '一つずれの誤り。始まりと終わりを数えよ',
166 '空白が二つ並んでいる',
167 '大文字と小文字の違いに潜む',
168 '古い版の依存が残っている',
169 '非同期の待ち忘れ。await を探せ',
170 '同じ処理が二度走っている',
171 '手元では出ず、本番だけで出る兆し',
172 '見つけた時にはもう直っている',
173 '丸めの誤差にご用心',
174 ],
175 review: [
176 '一発で承認。指摘ゼロの予感',
177 '鋭い指摘が一つ。それが宝になる',
178 '待ち人来たる。返事は午後',
179 '説明を一行足すだけで通る',
180 '差分は小さく分けると吉',
181 '名前付けの議論が長引く兆し',
182 '褒め言葉をもらえる日',
183 '出す前に読み返すと見落としが見つかる',
184 '返事が遅くとも焦らず待て',
185 '人の差分を読むと学びあり',
186 'テストを添えれば話が早い',
187 '夜のレビューは見落とし多し',
188 '質問を一つされる。答えると話が進む',
189 '読みやすいと言われる日',
190 '返事は短く、承認は早い',
191 '図を一枚添えると伝わる',
192 '名前を一つ変えるだけで済む',
193 '思わぬ人から助言が届く',
194 '大きな差分は二つに割ると吉',
195 '気になる所を先に自分で書いておくと早い',
196 '意見が割れたら小さく試すべし',
197 '昔の自分の差分を読むと赤面する',
198 '急ぎの頼みにこそ丁寧に応えよ',
199 '承認の前に手元で動かすと安心',
200 ],
201 lost: [
202 'stash の奥にあり',
203 '昨日の reflog に眠る',
204 '別のブランチで見つかる',
205 '閉じたタブの中',
206 'ゴミ箱を探せ',
207 '設定ファイルの末尾',
208 '古い課題票のコメント欄',
209 '手元のメモの三ページ目',
210 '環境変数の中に隠れている',
211 '出てこない。書き直すが早い',
212 '検索で一発。ただし綴りに注意',
213 '二つ前のコミットの中',
214 '下書きのまま残した文書',
215 '別の作業フォルダに置いたまま',
216 '写した物の履歴',
217 '端末の、ずっと上の方',
218 '消したと思ったブランチの先',
219 '同じ名前の別のファイル',
220 '机の上の付箋',
221 '昨日の自分の作業の記録',
222 '試験用のフォルダの奥',
223 '長い関数の真ん中あたり',
224 ],
225 warning: [
226 '今日は force push 厄日',
227 '金曜の本番反映は凶',
228 'rm -rf の前に二度見よ',
229 '「とりあえず」で入れた依存は残る',
230 'コメントアウトのまま放置するべからず',
231 'main への直コミットは控えよ',
232 '秘密の鍵をコミットに混ぜるな',
233 '確かめずに一括置換するな',
234 '理由を知らずに「動いた」で進むな',
235 'テストを消して通すのは厄の元',
236 '寝不足のマージは後悔のもと',
237 'TODO を増やすだけの日にするな',
238 '夜中の思いつきは朝に見直せ',
239 'エラーを読まずに検索するな',
240 '理由の書いてない無効化は残すな',
241 '本番の設定を手元で試すな',
242 '確かめないまま「直った」と言うな',
243 '同じ修正を三か所に書くな',
244 '消す前に写しを取れ',
245 '警告をまとめて黙らせるな',
246 '長すぎる関数をさらに伸ばすな',
247 '一つのコミットに全部詰めるな',
248 '人のブランチに黙って書くな',
249 '休みの前日の大きな反映は避けよ',
250 ],
251 commit: [
252 'fix: ',
253 'feat: ',
254 'refactor: ',
255 'docs: ',
256 'test: ',
257 'perf: ',
258 'chore: ',
259 '直す: ',
260 '足す: ',
261 '片付ける: ',
262 '整える: ',
263 '名前を変える: ',
264 '分ける: ',
265 '元に戻す: ',
266 'build: ',
267 'ci: ',
268 'style: ',
269 'revert: ',
270 '試す: ',
271 '消す: ',
272 '速くする: ',
273 '守る: ',
274 '書き足す: ',
275 'まとめる: ',
276 'ほどく: ',
277 '移す: ',
278 '言い換える: ',
279 '確かめる: ',
280 ],
281} as const
282
283export type Season = '春' | '夏' | '秋' | '冬'
284
285/** 日付 `YYYY-MM-DD` (JST) の季節。3〜5 月が春、6〜8 月が夏、9〜11 月が秋、ほかは冬 */
286export const seasonOf = (date: string): Season => {
287 const m = Number(date.slice(5, 7))
288 return m >= 3 && m <= 5 ? '春' : m >= 6 && m <= 8 ? '夏' : m >= 9 && m <= 11 ? '秋' : '冬'
289}
290
291/** 季節の一言。その季節の日だけ、総合の一言の候補に混ざる */
292export const SEASON_WORDS: Record<Season, { good: readonly string[]; bad: readonly string[] }> = {
293 春: {
294 good: [
295 '芽吹きの季節。始めた試みがしっかり根付く',
296 'つぼみがほころぶように、小さな差分が次々通る',
297 '春風のように依存の更新が通り抜ける',
298 '新しく来た人に教えると、自分の理解も深まる',
299 '日が長くなる。夕方の調べ物がはかどる',
300 ],
301 bad: [
302 '花粉のように警告も積もる。早めに払え',
303 '年度替わりの設定変更に気を付けよ',
304 '陽気につられて確かめが甘くなる',
305 '名前の変わった物に古い名前が残っている',
306 '春の夜の眠気に負けて差分を出すな',
307 ],
308 },
309 夏: {
310 good: [
311 '夕立のあとのように、ログがすっきり晴れる',
312 '暑さに負けず、短い関数が涼しく並ぶ',
313 '時間をかけた調べ物が実を結ぶ',
314 '冷えた麦茶のように、はっきりした答えが出る',
315 '朝の涼しいうちに難所を越えられる',
316 ],
317 bad: [
318 '暑さで機械も人も遅くなる。休みを挟め',
319 '日が長いと作業も長引く。区切りを決めよ',
320 '蒸し暑い日の大改修は控えよ',
321 '時刻を夏の時間で数える国に注意',
322 '機械の熱に気を配れ。ファンの音を聞け',
323 ],
324 },
325 秋: {
326 good: [
327 '実りの秋。積み重ねたテストが報われる',
328 '月の明るい夜は、設計の筋がよく見える',
329 '落ち葉を掃くように古いコードが片付く',
330 '読書の秋。仕様書を読むと近道が見つかる',
331 '澄んだ空のように、頭の中が整う',
332 ],
333 bad: [
334 '日が短くなる。早めに保存せよ',
335 '食べ過ぎと同じく、機能の詰め込み過ぎに注意',
336 '長雨のようにビルドが続く気配',
337 '衣替えのつもりの書き換えが長引く',
338 '夜長に任せて寝る時間を削るな',
339 ],
340 },
341 冬: {
342 good: [
343 '静かな冬の朝は集中がよく続く',
344 '雪の原のように、白紙から書くと早い',
345 'こたつで思いついた案が当たる',
346 '年の終わりに古い課題が片付く',
347 '温かい飲み物を手に読むと、難しい所も分かる',
348 ],
349 bad: [
350 '寒さで指がかじかむ。打ち間違いに注意',
351 '年の瀬の駆け込みの反映は避けよ',
352 '暖かい部屋の眠気に負けるな',
353 '休み明けは手順を思い出すまで慎重に',
354 '乾いた空気のように、言葉が足りず誤解を招く',
355 ],
356 },
357}
358
359// ── 乱数 (FNV-1a で種を作り、mulberry32 で回す)
360function hash(s: string) {
361 let h = 0x811c9dc5
362 for (let i = 0; i < s.length; i++) {
363 h ^= s.charCodeAt(i)
364 h = Math.imul(h, 0x01000193)
365 }
366 return h >>> 0
367}
368
369function rng(seed: number) {
370 let a = seed
371 return () => {
372 a = (a + 0x6d2b79f5) | 0
373 let t = Math.imul(a ^ (a >>> 15), 1 | a)
374 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
375 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
376 }
377}
378
379/**
380 * その日の運勢。同じ日と同じ n なら必ず同じ結果。
381 * n = 0 が本番、1 以上は「遊びの一回」
382 */
383export function fortune(date: string, n = 0): OmikujiResult {
384 const r = rng(hash(`${date}|${SALT}|${n}`))
385 const pick = <T>(list: readonly T[]) => list[Math.floor(r() * list.length)] as T
386 let x = r() * 100
387 let rank: OmikujiRank = '吉'
388 for (const [name, w] of RANKS) {
389 if (x < w) {
390 rank = name
391 break
392 }
393 x -= w
394 }
395 const season = SEASON_WORDS[seasonOf(date)]
396 return {
397 date,
398 number: 1 + Math.floor(r() * 100),
399 rank,
400 words: pick(isGood(rank) ? [...WORDS.good, ...season.good] : [...WORDS.bad, ...season.bad]),
401 language: pick(WORDS.language),
402 command: pick(WORDS.command),
403 bug: pick(WORDS.bug),
404 review: pick(WORDS.review),
405 lost: pick(WORDS.lost),
406 warning: pick(WORDS.warning),
407 commit: pick(WORDS.commit),
408 }
409}
410
411// ── store の中身は信用しない (古い版・手で書き換えた値で「第undefined番」や描画失敗にしない)
412const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
413const TEXT_KEYS = ['words', 'language', 'command', 'bug', 'review', 'lost', 'warning', 'commit'] as const
414const isRank = (r: unknown): r is OmikujiRank => RANK_NAMES.includes(r as OmikujiRank)
415const isDate = (d: unknown): d is string => typeof d === 'string' && DATE_RE.test(d)
416
417/** 運勢として形が揃っているか (rank は 7 つの名前、number は 1〜100 の整数、文字の項目は全部 string) */
418export function isResult(v: unknown): v is OmikujiResult {
419 if (typeof v !== 'object' || v === null) return false
420 const o = v as Record<string, unknown>
421 const n = o.number
422 return isDate(o.date) && isRank(o.rank) && Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 100 && TEXT_KEYS.every(k => typeof o[k] === 'string')
423}
424
425/** store の直近の日のうち、形の合う物だけ (知らない欄は残す。新しい版が足した欄を消さない) */
426export const toDays = (v: unknown): OmikujiDay[] =>
427 Array.isArray(v) ? v.filter((d): d is OmikujiDay => typeof d === 'object' && d !== null && isDate(d.date) && isRank(d.rank)) : []
428
429/** store の生の値が物なら中身、そうでなければ空 (知らない欄を残して書くための元) */
430export const objOf = (v: unknown): Record<string, unknown> =>
431 typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
432
433/** JST の日付 `YYYY-MM-DD` (Date のタイムゾーンに頼らない) */
434export const jstDate = (ms: number) => new Date(ms + 9 * 3600_000).toISOString().slice(0, 10)
435
436/** date で終わる直近 n 日 (古い順) */
437export function lastDays(date: string, n = 7) {
438 const t = Date.parse(`${date}T00:00:00Z`)
439 return Array.from({ length: n }, (_, i) => new Date(t - (n - 1 - i) * 86_400_000).toISOString().slice(0, 10))
440}
441
442/** 今日の分を足して、直近 7 日より古い分を落とす。今日の分が既にあれば、その知らない欄 (新しい版が足した欄) は残す */
443export function addDay(history: readonly OmikujiDay[], day: OmikujiDay): OmikujiDay[] {
444 const keep = new Set(lastDays(day.date))
445 const had = history.find(d => d.date === day.date)
446 return [...history.filter(d => d.date !== day.date && keep.has(d.date)), { ...had, ...day }].sort((p, q) => p.date.localeCompare(q.date))
447}
448
449const md = (date: string) => `${Number(date.slice(5, 7))}/${Number(date.slice(8, 10))}`
450
451/** 入力欄に入れる 1 行 */
452export const fillText = (f: OmikujiResult) =>
453 `今日の運勢は${f.rank}。ラッキー言語は ${f.language}、ラッキーなコマンドは ${f.command}、` +
454 `バグ運は「${f.bug}」、レビュー運は「${f.review}」、戒めは「${f.warning}」。`
455
456// ── 描画
457const KD = '〇一二三四五六七八九'
458const kd = (d: number) => KD[d] ?? ''
459/** 100 までの漢数字 */
460export function kanji(n: number): string {
461 if (n === 100) return '百'
462 if (n < 10) return kd(n)
463 const ten = Math.floor(n / 10)
464 return `${ten === 1 ? '' : kd(ten)}十${n % 10 ? kd(n % 10) : ''}`
465}
466const kanjiDate = (date: string) => `${kanji(Number(date.slice(5, 7)))}月${kanji(Number(date.slice(8, 10)))}日`
467
468// 紙と筒は絵の中身なので、テーマによらず同じ色 (和紙・墨・朱・木)
469const PAPER = {
470 washi: '#f3ead6',
471 fiber: '#e0d2b4',
472 sumi: '#2b2118',
473 shu: '#d9381e',
474 wood: '#8a5530',
475 woodDark: '#5e3519',
476}
477/** 運勢の色 (今のテーマの色を呼ぶたびに読む。SVG と terminal の Text の両方で使う) */
478export const rankColor = (rank: OmikujiRank) =>
479 ({ 大吉: C.red, 中吉: C.orange, 小吉: C.yellow, 吉: C.green, 末吉: C.blue, 凶: C.purple, 大凶: C.dim })[rank]
480
481/** 見出しの動きを抜く。紙だけの絵は描き直しのたびに動かさない */
482const still = (s: string) => s.replace(/<animate\b[^>]*\/>/g, '')
483
484const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
485const wide = (ch: string) => /[⺀-가-힣豈-︰-﹏-⦆¢-₩]/.test(ch)
486
487/** 幅 (全角 2・半角 1) で折り返す */
488export function wrap(s: string, max: number) {
489 const out: string[] = []
490 let cur = ''
491 let n = 0
492 for (const ch of s) {
493 const w = wide(ch) ? 2 : 1
494 if (n + w > max) {
495 out.push(cur)
496 cur = ''
497 n = 0
498 }
499 cur += ch
500 n += w
501 }
502 if (cur) out.push(cur)
503 return out
504}
505
506/** 縦に 1 文字ずつ並べる */
507const vertical = (s: string, x: number, y: number, step: number, attrs: string) =>
508 [...s]
509 .map((ch, i) => {
510 const cy = y + i * step
511 // 長音は縦書きでは縦に向ける
512 const turn = ch === 'ー' ? ` transform="rotate(90 ${x} ${cy - step * 0.35})"` : ''
513 return `<text x="${x}" y="${cy}" text-anchor="middle"${turn} ${attrs}>${esc(ch)}</text>`
514 })
515 .join('')
516
517const commitText = (f: OmikujiResult) => `「${f.commit}…」`
518
519const ITEMS: [keyof OmikujiResult, string][] = [
520 ['language', '言語'],
521 ['command', 'コマンド'],
522 ['bug', 'バグ運'],
523 ['review', 'レビュー運'],
524 ['lost', '失せ物'],
525 ['warning', '戒め'],
526 ['commit', '書き出し'],
527]
528
529/** 開いた紙。y=12 から始まり、下の端の y を返す */
530function paper(f: OmikujiResult, isPlay: boolean): { svg: string; bottom: number } {
531 // 中身を先に組んで高さを決める
532 let body = ''
533 let y = 226
534 for (const line of wrap(f.words, 40)) {
535 body += `<text x="190" y="${y}" font-size="14" font-weight="bold" fill="${PAPER.sumi}" text-anchor="middle">${esc(line)}</text>`
536 y += 20
537 }
538 y += 6
539 body += `<line x1="52" y1="${y}" x2="328" y2="${y}" stroke="${PAPER.shu}" stroke-width="0.8" stroke-dasharray="3 3"/>`
540 y += 24
541 for (const [key, label] of ITEMS) {
542 const value = key === 'commit' ? commitText(f) : String(f[key])
543 body +=
544 `<rect x="46" y="${y - 13}" width="74" height="18" rx="2" fill="none" stroke="${PAPER.shu}" stroke-width="0.8"/>` +
545 `<text x="83" y="${y}" font-size="12" font-weight="bold" fill="${PAPER.shu}" text-anchor="middle">${label}</text>`
546 for (const line of wrap(value, 30)) {
547 body += `<text x="130" y="${y}" font-size="13" fill="${PAPER.sumi}">${esc(line)}</text>`
548 y += 19
549 }
550 y += 9
551 }
552 const bottom = y + 6
553
554 // 和紙の繊維 (結果から決まるので毎回同じ)
555 const r = rng(hash(`${f.date}|${f.number}|fiber`))
556 let fibers = ''
557 for (let i = 0; i < 26; i++) {
558 const x = 34 + r() * 300
559 const fy = 22 + r() * (bottom - 40)
560 fibers += `<path d="M${x.toFixed(1)} ${fy.toFixed(1)} q ${(4 + r() * 8).toFixed(1)} ${(r() * 4 - 2).toFixed(1)} ${(10 + r() * 14).toFixed(1)} ${(r() * 6 - 3).toFixed(1)}" stroke="${PAPER.fiber}" stroke-width="0.8" fill="none"/>`
561 }
562
563 const rankChars = [...f.rank]
564 const rankSize = rankChars.length === 1 ? 72 : 58
565 const rankTop = rankChars.length === 1 ? 140 : 102
566 let s =
567 `<rect x="24" y="12" width="332" height="${bottom - 12}" rx="3" fill="${PAPER.washi}"/>` +
568 fibers +
569 `<rect x="32" y="20" width="316" height="${bottom - 28}" fill="none" stroke="${PAPER.shu}" stroke-width="1.6"/>` +
570 `<rect x="36" y="24" width="308" height="${bottom - 36}" fill="none" stroke="${PAPER.shu}" stroke-width="0.6"/>` +
571 // 右の列: 題と番号
572 vertical('コード運勢', 316, 56, 22, `font-size="17" font-weight="bold" fill="${PAPER.sumi}"`) +
573 vertical(`第${kanji(f.number)}番`, 288, 56, 17, `font-size="13" fill="${PAPER.sumi}"`) +
574 // 左の列: 日付
575 vertical(kanjiDate(f.date), 64, 56, 16, `font-size="13" fill="${PAPER.sumi}"`) +
576 // 真ん中: 朱の運勢と丸い印
577 `<circle cx="190" cy="${rankTop + (rankChars.length - 1) * (rankSize / 2) - rankSize * 0.35}" r="${rankChars.length === 1 ? 54 : 70}" fill="none" stroke="${PAPER.shu}" stroke-width="1" opacity="0.35"/>` +
578 vertical(f.rank, 190, rankTop, rankSize + 2, `font-size="${rankSize}" font-weight="bold" fill="${PAPER.shu}"`) +
579 `<line x1="52" y1="200" x2="328" y2="200" stroke="${PAPER.shu}" stroke-width="1.2"/>` +
580 body
581 if (isPlay)
582 s +=
583 `<g transform="rotate(-12 96 168)"><rect x="58" y="152" width="78" height="30" rx="4" fill="none" stroke="${PAPER.shu}" stroke-width="2" opacity="0.8"/>` +
584 `<text x="97" y="173" font-size="15" font-weight="bold" fill="${PAPER.shu}" text-anchor="middle" opacity="0.8">遊び</text></g>`
585 return { svg: s, bottom }
586}
587
588/** 筒を振って棒が出てくるまで (おおよそ 2.4 秒)。最後に消える */
589function shaker(f: OmikujiResult) {
590 const pivot = '190 360'
591 const swing = ['0', '-16', '14', '-12', '10', '-6', '3', '0'].map(a => `${a} ${pivot}`).join(';')
592 return (
593 `<g><animate attributeName="opacity" from="1" to="0" begin="2.5s" dur="0.4s" fill="freeze"/>` +
594 `<ellipse cx="190" cy="372" rx="70" ry="9" fill="#000" opacity="0.35"/>` +
595 `<g><animateTransform attributeName="transform" type="rotate" values="${swing}" dur="1.5s" fill="freeze"/>` +
596 `<path d="M148 210 L232 210 L226 360 L154 360 Z" fill="${PAPER.wood}" stroke="${PAPER.woodDark}" stroke-width="2"/>` +
597 `<ellipse cx="190" cy="210" rx="42" ry="9" fill="${PAPER.woodDark}"/>` +
598 `<ellipse cx="190" cy="210" rx="9" ry="3.5" fill="#1a0f08"/>` +
599 // 棒は穴より上だけ見せる (筒の中に隠しておき、振り終わったら押し上げる)
600 `<clipPath id="ok-hole"><rect x="150" y="0" width="80" height="211"/></clipPath>` +
601 `<g clip-path="url(#ok-hole)"><g><animateTransform attributeName="transform" type="translate" from="0 0" to="0 -84" begin="1.5s" dur="0.6s" calcMode="spline" keySplines="0.2 0.8 0.2 1" keyTimes="0;1" fill="freeze"/>` +
602 `<rect x="185" y="214" width="10" height="90" rx="2" fill="#e9dbb4" stroke="#b49a64" stroke-width="0.8"/>` +
603 `<rect x="185" y="214" width="10" height="12" rx="2" fill="${PAPER.shu}"/></g></g>` +
604 `<rect x="151" y="232" width="78" height="7" fill="${PAPER.woodDark}" opacity="0.7"/>` +
605 `<rect x="154" y="334" width="72" height="7" fill="${PAPER.woodDark}" opacity="0.7"/>` +
606 `<rect x="174" y="248" width="32" height="80" fill="${PAPER.washi}"/>` +
607 vertical('御神籤', 190, 270, 24, `font-size="19" font-weight="bold" fill="${PAPER.shu}"`) +
608 `</g>` +
609 // 振っている間の音
610 `<g font-size="16" font-weight="bold" fill="${C.text}" opacity="0"><animate attributeName="opacity" values="0;1;0;1;0;1;0" dur="1.5s" fill="freeze"/>` +
611 `<text x="96" y="250" text-anchor="middle">シャカ</text><text x="284" y="300" text-anchor="middle">シャカ</text></g>` +
612 // 棒の番号。棒が上がる間に数が回って止まる
613 countUp(f.number, `x="214" y="130" font-size="16" font-weight="bold" fill="${C.text}"`, n => `第${kanji(n)}番`, 1.6) +
614 `</g>`
615 )
616}
617
618/** 直近 7 日の丸の列 */
619function week(today: string, history: readonly OmikujiDay[], y: number) {
620 const by = new Map(history.map(d => [d.date, d.rank]))
621 let s = `<text x="12" y="${y}" font-size="13" font-weight="bold" fill="${C.text}">この 7 日</text>`
622 y += 16
623 lastDays(today).forEach((date, i) => {
624 const cx = 40 + i * 50
625 const rank = by.get(date)
626 const isToday = date === today
627 s += `<text x="${cx}" y="${y + 12}" font-size="12" fill="${isToday ? C.text : C.dim}" text-anchor="middle">${md(date)}</text>`
628 if (isToday) s += `<circle cx="${cx}" cy="${y + 34}" r="16" fill="none" stroke="${C.text}" stroke-width="1"/>`
629 s += rank
630 ? `<circle cx="${cx}" cy="${y + 34}" r="12" fill="${rankColor(rank)}"/>` +
631 `<text x="${cx}" y="${y + 66}" font-size="12" font-weight="bold" fill="${rankColor(rank)}" text-anchor="middle">${rank}</text>`
632 : `<circle cx="${cx}" cy="${y + 34}" r="11" fill="none" stroke="${C.line}" stroke-width="2" stroke-dasharray="3 3"/>` +
633 `<text x="${cx}" y="${y + 66}" font-size="12" fill="${C.dim}" text-anchor="middle">─</text>`
634 })
635 return { svg: s, bottom: y + 74 }
636}
637
638/**
639 * ペインに出す SVG 1 枚。isShake なら筒を振るアニメから紙が開くまで、
640 * そうでなければ開いた紙だけ (紙だけの絵は振り終わりに差し替わるので、何も動かさない)
641 */
642export function paneSvg(shown: OmikujiResult, today: string, history: readonly OmikujiDay[], opts: { isShake: boolean; isPlay: boolean }) {
643 const p = paper(shown, opts.isPlay)
644 const paperSvg = opts.isShake
645 ? // 紙は上の端から開く
646 `<g opacity="0"><animate attributeName="opacity" from="0" to="1" begin="2.6s" dur="0.5s" fill="freeze"/>` +
647 `<g transform="translate(0 12)"><g><animateTransform attributeName="transform" type="scale" from="1 0.04" to="1 1" begin="2.6s" dur="0.7s" calcMode="spline" keySplines="0.2 0.8 0.2 1" keyTimes="0;1" fill="freeze"/>` +
648 `<g transform="translate(0 -12)">${p.svg}</g></g></g></g>`
649 : p.svg
650 const w = week(today, history, HEADER_H + p.bottom + 28)
651 const head = header('コード運勢', opts.isPlay ? '遊びの一回' : `${md(shown.date)} の本番`, C.red)
652 return frame(
653 w.bottom + 8,
654 (opts.isShake ? head : still(head)) +
655 // 紙と筒は y=0 から描いてあるので、見出しの下へ下ろす
656 `<g transform="translate(0 ${HEADER_H})">${paperSvg}${opts.isShake ? shaker(shown) : ''}</g>` +
657 // 今日の丸は紙が開いてから出す (振っている間に運勢が見えないように)
658 (opts.isShake ? fadeIn(w.svg, 3.0) : w.svg),
659 )
660}
661
662/** Svg の alt と terminal で使う文の行 */
663export function paneLines(shown: OmikujiResult, today: OmikujiResult, history: readonly OmikujiDay[], isPlay: boolean): Line[] {
664 const lines: Line[] = [
665 { text: `${md(shown.date)} のコード運勢 第${shown.number}番`, isBold: true },
666 { text: shown.rank, color: rankColor(shown.rank), isBold: true },
667 { text: shown.words },
668 ]
669 if (isPlay) lines.push({ text: `遊びの一回です(本番は${today.rank})`, color: 'yellow' })
670 for (const [key, label] of ITEMS) lines.push({ text: `${label}: ${key === 'commit' ? commitText(shown) : String(shown[key])}` })
671 const by = new Map(history.map(d => [d.date, d.rank]))
672 lines.push({ text: `この 7 日: ${lastDays(today.date).map(d => `${md(d)} ${by.get(d) ?? '─'}`).join(' / ')}`, isDim: true })
673 return lines
674}
675hooks/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 45 lines1// code-omikuji の $.state の契約
2
3export type OmikujiRank = '大吉' | '中吉' | '小吉' | '吉' | '末吉' | '凶' | '大凶'
4
5/** 1 回分の運勢。date は JST の `YYYY-MM-DD` */
6export type OmikujiResult = {
7 date: string
8 /** 第 N 番 (1〜100) */
9 number: number
10 rank: OmikujiRank
11 /** 総合の一言 */
12 words: string
13 language: string
14 command: string
15 bug: string
16 review: string
17 lost: string
18 warning: string
19 commit: string
20}
21
22/** 直近の日の運勢 (store に残す) */
23export type OmikujiDay = { date: string; rank: OmikujiRank }
24
25declare module 'claude-code' {
26 interface PluginState {
27 'code-omikuji': {
28 /** 今日の本番の結果 (store の写し)。開くまでは null */
29 today: OmikujiResult | null
30 /** 遊びの一回の結果。null なら本番を見せる */
31 play: OmikujiResult | null
32 /** 遊びで振った回数 (乱数の種に混ぜる) */
33 plays: number
34 /** 直近の日の運勢 (store の写し、古い順) */
35 history: OmikujiDay[]
36 /** shake は筒を振るアニメから、paper は開いた紙だけ */
37 mode: 'shake' | 'paper'
38 /** このセッションで一度開いたか */
39 isOpened: boolean
40 /** ボタンの結果などの一言。空なら出さない */
41 note: string
42 }
43 }
44}
45