git push・gh release・gh repo create・npm publish を Claude が打つ時、直前に AskUserQuestion で公開してよいと答えていなければ、その場で聞いて断られたら止める。git push の前は作者が noreply…

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 157 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GuardEntry, GuardOutcome } from '../types'
5import { LOG_FORMAT, LOG_MAX, NO, YES, approvedIn, askText, denyText, hasPush, isValid, listOf, outwardOf, paneLines, paneSvg, rangeOf, repoDirOf, resolveDir, scanPush } from './guard'
6import { followTheme, readTheme } from './style'
7import type { Shell } from './words'
8
9const PANE = 'ship-guard'
10const TITLE = '外に出す操作の見張り'
11const DESCRIPTION = 'git push・gh release・npm publish の見張りの記録と、オン/オフのペインを開く'
12
13const OFF = atom({ plugin: 'ship-guard', key: 'isOff' } as const, false)
14const LOG = atom({ plugin: 'ship-guard', key: 'log' } as const, [])
15const PERMIT = atom({ plugin: 'ship-guard', key: 'permit' } as const, null)
16const TURN = atom({ plugin: 'ship-guard', key: 'turn' } as const, 0)
17
18type $ = EngineInterface
19
20let ownsMain = false
21// 設定は register で userConfig から入れる
22const cfg = { isChecking: true, allowed: [] as string[], words: [] as string[] }
23
24async function record($: $, label: string, command: string, outcome: GuardOutcome, findings: string[] = []) {
25 const at = await $.clock.now()
26 const entry: GuardEntry = { at, label, command: command.trim().slice(0, 200), outcome, ...(findings.length > 0 ? { findings } : {}) }
27 await update($, LOG, log => [...log, entry].slice(-LOG_MAX))
28}
29
30/**
31 * git push の前に、送るコミット (どのリモートにもまだ無い物、最大 200) の作者と足した行を読む。
32 * git は読むだけ。リポジトリの設定が指すコマンド (fsmonitor・textconv・外部 diff) は走らせない
33 */
34async function preflight($: $, command: string, shell: Shell): Promise<string[]> {
35 if (!cfg.isChecking) return []
36 const dir = resolveDir(repoDirOf(command, shell), await $.session.cwd())
37 const argv = ['git', '-c', 'core.quotePath=false', '-c', 'core.fsmonitor=false', 'log', '-p', '-U0', '--no-color', '--no-renames', '--no-textconv', '--no-ext-diff', LOG_FORMAT, '-n', '200', ...rangeOf(command, shell)]
38 try {
39 const r = await $.process.run(argv, { cwd: dir, env: { GIT_OPTIONAL_LOCKS: '0' }, timeoutMs: 20_000 })
40 if (r.exitCode !== 0) return [`送るコミットを読めませんでした (${dir}: ${(r.stderr.trim().split('\n')[0] ?? '').slice(0, 80) || `終了コード ${r.exitCode}`})`]
41 const found = scanPush(r.stdout, { allowed: cfg.allowed, words: cfg.words })
42 return r.isStdoutTruncated ? [...found, '差分が長いので途中までしか見ていません'] : found
43 } catch (err) {
44 return [`送るコミットを読めませんでした (${(err instanceof Error ? err.message : String(err)).slice(0, 80)})`]
45 }
46}
47
48async function grant($: $, about: string) {
49 const at = await $.clock.now()
50 const turn = await read($, TURN)
51 await update($, PERMIT, () => ({ turn, at, about }))
52}
53
54/**
55 * シェルのツールの見張り。外に出す操作で、直前 (このターンか前のターン) に確認が無ければ、その場で聞く。
56 * git push は先に公開前のチェックをして、気になる所があれば確認済みでも聞き直す (公開の許可は、漏れの許可ではない)。
57 * 聞けない・断られた時は止める。見張りなので .catch は付けない (失敗したら通さない)
58 */
59async function guard<R>($: $, command: string, shell: Shell, next: () => Promise<R>): Promise<R | { deny: string }> {
60 const label = outwardOf(command, shell)
61 if (label === undefined || (await read($, OFF))) return next()
62 const findings = hasPush(command, shell) ? await preflight($, command, shell) : []
63 if (findings.length === 0 && isValid(await read($, PERMIT), await read($, TURN))) {
64 await record($, label, command, 'confirmed')
65 return next()
66 }
67 let answer: string | undefined
68 try {
69 answer = await $.ui.ask(askText(label, command, findings), { options: [YES, NO], header: '公開の確認' })
70 } catch {
71 answer = undefined
72 }
73 if (answer === YES) {
74 await grant($, `その場で ${label} を許可`)
75 await record($, label, command, 'asked-yes', findings)
76 return next()
77 }
78 const outcome: GuardOutcome = answer === undefined ? 'no-ask' : 'asked-no'
79 await record($, label, command, outcome, findings)
80 return { deny: denyText(label, outcome, answer, findings) }
81}
82
83const openPane = async ($: $) => {
84 await $.ui.open({ id: PANE, title: TITLE })
85 return { text: '外に出す操作の見張りのペインを開きました。' }
86}
87
88export const register: Register = (on, options) => {
89 cfg.isChecking = options.checkBeforePush !== false
90 cfg.allowed = listOf(options.allowedEmails)
91 cfg.words = listOf(options.privateWords)
92
93 followTheme(on)
94 on('session.start', async ($, e, next) => {
95 const started = await next(e)
96 await readTheme($)
97 const got = await $.command.register({ name: 'guard', description: DESCRIPTION }).catch(() => undefined)
98 ownsMain = got?.command === 'guard'
99 if (!ownsMain) await $.command.register({ name: 'ship-guard', description: DESCRIPTION }).catch(() => {})
100 return started
101 })
102
103 on('command.run', { command: 'guard' }, ($, e, next) => (ownsMain ? openPane($) : next(e))).catch(($, e, next) => next(e))
104 on('command.run', { command: 'ship-guard' }, ($, e, next) => (ownsMain ? next(e) : openPane($))).catch(($, e, next) => next(e))
105
106 // 確認の効き目はターンで数える (メインのターンだけ)
107 on('turn.start', async ($, e, next) => {
108 if (e.agentId === undefined) await update($, TURN, n => n + 1)
109 return next(e)
110 }).catch(($, e, next) => next(e))
111
112 // AskUserQuestion で公開してよいと答えたら、このターンと次のターンは聞き直さない。
113 // 数えるのは Claude が聞いた物だけ (ほかの mod の $.ui.ask の「はい」は公開の許可ではない。自分の「実行する」は guard で許す)
114 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
115 const r = await next(e)
116 if (next.origin.plugin === 'engine' && r.deny === undefined && r.isError !== true && typeof r.text === 'string') {
117 const about = approvedIn(r.text)
118 if (about !== undefined) await grant($, about)
119 }
120 return r
121 }).catch(($, e, next) => next(e))
122
123 on('tool.call', { tool: 'Bash' }, ($, e, next) => guard($, e.command, 'bash', () => next(e)))
124 on('tool.call', { tool: 'PowerShell' }, ($, e, next) => guard($, e.command, 'powershell', () => next(e)))
125
126 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
127 const t = $.ui.resolve(e)
128 const { Box, Text, Button } = t
129 // terminal の表にも Svg の名前があるので、画面の種類でも分ける
130 const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : undefined
131 const input = { isOff: await read($, OFF), log: await read($, LOG), permit: await read($, PERMIT), turn: await read($, TURN) }
132 const lines = paneLines(input)
133 return (
134 <Box flexDirection="column" gap={1}>
135 {Svg ? (
136 <Svg source={paneSvg(input)} alt={lines.map(l => l.text).join('\n')} />
137 ) : (
138 <Box key="lines" flexDirection="column">
139 {lines.map(l => (
140 <Text color={l.color} dimColor={l.isDim} bold={l.isBold}>
141 {l.text}
142 </Text>
143 ))}
144 </Box>
145 )}
146 {input.isOff ? (
147 <Button key="on" label="見張りを戻す" variant="primary" onPress={() => void update($, OFF, () => false)} />
148 ) : (
149 <Button key="off" dimColor label="このセッションは見張らない" onPress={() => void update($, OFF, () => true)} />
150 )}
151 <Text dimColor>見るのは git push・gh release (create/upload/edit/delete)・gh repo create・npm/pnpm/yarn publish です。</Text>
152 {cfg.isChecking ? <Text dimColor>git push の前は、送るコミットの作者のメールと、足した行の個人のメール・ユーザー名入りのパスも確かめます。</Text> : null}
153 </Box>
154 )
155 })
156}
157hooks/guard.ts 430 lines1// 外に出す操作の見分けと、確認の読み取り、ペインの SVG と文。どれも純関数
2import type { GuardEntry, GuardOutcome, GuardPermit } from '../types'
3import { C, HEADER_H, clean, esc, frame, header, panel, short } from './style'
4import { type Shell, type Word, baseName, parse } from './words'
5
6export const LOG_MAX = 20
7
8// ── 外に出す操作の見分け。語に分けて読む (引用符・コメント・ヒアドキュメントの中の言葉を拾わない)
9
10// 頭に付いても中身は後ろのコマンド (env X=1 git push、then git push など)。値は値を 1 語取るオプション
11const PREFIX = new Map<string, readonly string[]>([
12 ['env', ['-u', '-C', '--unset', '--chdir']],
13 ['sudo', ['-u', '-g', '-h', '-p', '-C', '-U', '-r', '-t', '-D']],
14 ['xargs', ['-n', '-I', '-L', '-P', '-d', '-s', '-E', '-a']],
15 ['npx', ['-p', '--package']],
16 ['bunx', ['-p', '--package']],
17 ['time', ['-f', '-o']],
18 ['nice', ['-n']],
19 ['timeout', ['-s', '-k', '--signal', '--kill-after']],
20 ['exec', ['-a']],
21 ...['command', 'builtin', 'nohup', 'pnpx', 'call', '.', '!', 'if', 'elif', 'then', 'else', 'do', 'while', 'until'].map(n => [n, []] as [string, readonly string[]]),
22])
23
24/** 頭の VAR=値・前置き (env・sudo・then・! など) を飛ばした、コマンドの名前の位置 */
25function headAt(words: Word[]) {
26 let k = 0
27 while (k < words.length) {
28 if (/^[A-Za-z_]\w*=/.test(words[k]?.bare ?? '')) {
29 k++
30 continue
31 }
32 const name = baseName(words[k]?.text ?? '')
33 const takes = PREFIX.get(name)
34 if (takes === undefined) break
35 k++
36 while ((words[k]?.text ?? '').startsWith('-')) k += takes.includes(words[k]?.text ?? '') ? 2 : 1
37 // timeout 10 git push の 10
38 if (name === 'timeout') k++
39 }
40 return k
41}
42
43/** 入れ子のシェル (bash -c "..."・eval・powershell -Command "..."・iex・cmd /c) の中身と、そのシェル */
44function innerOf(name: string, args: Word[]): { command: string; shell: Shell } | undefined {
45 const after = (re: RegExp) => {
46 const k = args.findIndex(w => re.test(w.text))
47 return k < 0 ? undefined : args.slice(k + 1).map(w => w.text).join(' ')
48 }
49 const as = (command: string | undefined, shell: Shell) => (command === undefined ? undefined : { command, shell })
50 if (name === 'powershell' || name === 'pwsh') return as(after(/^-c(?:o(?:m(?:m(?:a(?:n(?:d)?)?)?)?)?)?:?$/i), 'powershell')
51 if (name === 'cmd') return as(after(/^\/[ck]$/i), 'cmd')
52 if (name === 'bash' || name === 'sh' || name === 'zsh' || name === 'dash') return as(after(/^-\w*c$/), 'bash')
53 if (name === 'eval') return { command: args.map(w => w.text).join(' '), shell: 'bash' }
54 if (name === 'iex' || name === 'invoke-expression') return { command: args.filter(w => !/^-c(?:ommand)?:?$/i.test(w.text)).map(w => w.text).join(' '), shell: 'powershell' }
55 return undefined
56}
57
58/** 区切りの並び。入れ子のシェルの中身は、その場所に広げる */
59function flat(command: string, shell: Shell, depth = 0): Word[][] {
60 return parse(command, shell).flatMap(ws => {
61 const at = headAt(ws)
62 const inner = depth < 3 ? innerOf(baseName(ws[at]?.text ?? ''), ws.slice(at + 1)) : undefined
63 return inner ? flat(inner.command, inner.shell, depth + 1) : [ws]
64 })
65}
66
67/** オプション (と、その値) を飛ばした残りの語 */
68const positional = (args: Word[], takes: readonly string[]) => {
69 const out: string[] = []
70 for (let k = 0; k < args.length; k++) {
71 const t = args[k]?.text ?? ''
72 if (!t.startsWith('-')) out.push(t)
73 else if (takes.includes(t)) k++
74 }
75 return out
76}
77
78// git のサブコマンドの前で値を 1 語取るオプション
79const GIT_ARGS = new Set(['-c', '-C', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env', '--attr-source'])
80// Start-Process で値を 1 語取るオプション (-FilePath と -ArgumentList の値は読む側)
81const START_ARGS = ['-windowstyle', '-workingdirectory', '-redirectstandardoutput', '-redirectstandarderror', '-redirectstandardinput', '-credential', '-verb']
82const PKG_ARGS: Record<string, readonly string[]> = { npm: ['--prefix', '-C', '-w', '--workspace'], pnpm: ['-C', '--dir', '--filter', '-F'], yarn: ['--cwd'] }
83
84/** git push の中身: push の後ろの語と、-C の場所 */
85type Push = { args: Word[]; dirs: string[] }
86type Seen = { label: string; push?: Push }
87
88function gitSeen(args: Word[], depth: number): Seen | undefined {
89 const dirs: string[] = []
90 const alias = new Map<string, string>()
91 let k = 0
92 for (; k < args.length; k++) {
93 const t = args[k]?.text ?? ''
94 if (!t.startsWith('-')) break
95 if (!GIT_ARGS.has(t)) continue
96 const v = args[++k]?.text ?? ''
97 if (t === '-C') dirs.push(v)
98 const m = t === '-c' ? /^alias\.([^=]+)=([\s\S]*)$/i.exec(v) : null
99 if (m) alias.set((m[1] ?? '').toLowerCase(), m[2] ?? '')
100 }
101 let sub = args[k]?.text ?? ''
102 let rest = args.slice(k + 1)
103 const a = alias.get(sub.toLowerCase())
104 if (a?.startsWith('!')) {
105 // シェルの別名 (alias.x='!git push') は中身をコマンドとして読む
106 const cmd = [a.slice(1), ...rest.map(w => w.text)].join(' ')
107 return depth < 3 ? flat(cmd, 'bash', depth + 1).map(ws => seenIn(ws, depth + 1)).find(s => s !== undefined) : undefined
108 }
109 if (a !== undefined) {
110 const words = parse(a, 'bash')[0] ?? []
111 sub = words[0]?.text ?? ''
112 rest = [...words.slice(1), ...rest]
113 }
114 return sub === 'push' ? { label: 'git push', push: { args: rest, dirs } } : undefined
115}
116
117/** 1 つの区切りが外に出す操作か */
118function seenIn(words: Word[], depth: number): Seen | undefined {
119 const at = headAt(words)
120 const name = baseName(words[at]?.text ?? '')
121 const args = words.slice(at + 1)
122 if (name === 'git') return gitSeen(args, depth)
123 if ((name === 'start-process' || name === 'saps') && depth < 3) {
124 // Start-Process git -ArgumentList push は、-FilePath と -ArgumentList の中身を 1 つのコマンドとして読む
125 const isValue = (prev: string) => prev.length > 3 && START_ARGS.some(o => o.startsWith(prev.toLowerCase()))
126 return seenIn(args.filter((w, k) => !w.text.startsWith('-') && w.text !== '@' && !isValue(args[k - 1]?.text ?? '')), depth + 1)
127 }
128 if (name === 'gh') {
129 const [a, b] = positional(args, ['-R', '--repo'])
130 if (a === 'release' && /^(?:create|upload|edit|delete)$/.test(b ?? '')) return { label: 'gh release' }
131 if (a === 'repo' && b === 'create') return { label: 'gh repo create' }
132 }
133 if (Object.hasOwn(PKG_ARGS, name)) {
134 const [a, b] = positional(args, PKG_ARGS[name] ?? [])
135 if (a === 'publish' || (name === 'yarn' && a === 'npm' && b === 'publish')) return { label: 'npm publish' }
136 }
137 return undefined
138}
139
140/** 外に出す操作を前から順に。index は区切りの並び (segs) の何番目か */
141function seenAll(command: string, shell: Shell) {
142 const segs = flat(command, shell)
143 return { segs, seen: segs.flatMap((ws, index) => {
144 const s = seenIn(ws, 0)
145 return s ? [{ ...s, index }] : []
146 }) }
147}
148
149/** 外に出す操作なら、その名前 (git push など)。違えば無し。shell は Bash のツールなら bash、PowerShell なら powershell */
150export function outwardOf(command: string, shell: Shell = 'bash'): string | undefined {
151 return seenAll(command, shell).seen[0]?.label
152}
153
154/** 最初の git push (push の後ろの語・-C の場所) と、それより前の区切り */
155function pushAt(command: string, shell: Shell) {
156 const { segs, seen } = seenAll(command, shell)
157 const p = seen.find(s => s.label === 'git push')
158 return p ? { before: segs.slice(0, p.index), push: p.push } : undefined
159}
160
161/** コマンドに git push があるか (公開前のチェックをするか) */
162export const hasPush = (command: string, shell: Shell = 'bash') => pushAt(command, shell) !== undefined
163
164// ── 公開前のチェック (git push の時だけ)
165
166const CD = new Set(['cd', 'chdir', 'pushd', 'set-location', 'sl', 'push-location'])
167const isAbsolute = (dir: string) => /^[A-Za-z]:[\\/]/.test(dir) || dir.startsWith('/') || dir.startsWith('\\\\')
168const joinDir = (base: string | undefined, dir: string) => (base === undefined || isAbsolute(dir) ? dir : `${base.replace(/[\\/]+$/, '')}/${dir}`)
169
170/**
171 * push を読むリポジトリの場所。push より前の cd / Set-Location / pushd を順にたどり、push の git -C <dir> を足した物。
172 * 書かれていなければ無し (セッションの場所で読む)
173 */
174export function repoDirOf(command: string, shell: Shell = 'bash'): string | undefined {
175 const p = pushAt(command, shell)
176 if (!p) return undefined
177 let dir: string | undefined
178 for (const ws of p.before) {
179 const at = headAt(ws)
180 if (!CD.has(baseName(ws[at]?.text ?? ''))) continue
181 const to = ws.slice(at + 1).find(w => !/^(?:\/d|-(?:literal)?path)$/i.test(w.text))?.text
182 if (to !== undefined) dir = joinDir(dir, to)
183 }
184 for (const c of p.push?.dirs ?? []) dir = joinDir(dir, c)
185 return dir
186}
187
188/** 場所をセッションの場所から決める。/d/x の形は Windows では D:/x にする */
189export function resolveDir(dir: string | undefined, cwd: string) {
190 if (dir === undefined || dir === '' || dir === '.') return cwd
191 const isWindows = /^[A-Za-z]:/.test(cwd)
192 const msys = /^\/([A-Za-z])(\/|$)/.exec(dir)
193 if (isWindows && msys) return `${msys[1]?.toUpperCase()}:/${dir.slice(3)}`
194 if (isAbsolute(dir)) return dir
195 return `${cwd.replace(/[\\/]+$/, '')}/${dir}`
196}
197
198// git push で値を 1 語取るオプション
199const PUSH_ARGS = ['-o', '--push-option', '--repo', '--receive-pack', '--exec']
200
201/**
202 * 送るコミットの範囲。push の引数からリモートと refspec を読み、送る ref から、そのリモートに既にある物を除く
203 * (git push origin main なら main --not --remotes=origin)。--tags / --all ならブランチとタグ全部。
204 * 読めなければ HEAD からどのリモートにも無い物
205 */
206export function rangeOf(command: string, shell: Shell = 'bash'): string[] {
207 const args = pushAt(command, shell)?.push?.args ?? []
208 const [remote, ...refs] = positional(args, PUSH_ARGS)
209 // URL やパスのリモートは remotes の名前に無いので、どのリモートにも無い物にする
210 const isName = remote !== undefined && /^[\w.-]+$/.test(remote) && !remote.startsWith('-') && !args.some(w => w.text.startsWith('--repo'))
211 const not = ['--not', isName ? `--remotes=${remote}` : '--remotes']
212 if (args.some(w => /^--(?:tags|all|mirror|branches)$/.test(w.text))) return ['--branches', '--tags', ...not]
213 // git push origin tag v1 の tag は語の飾り
214 const srcs = refs
215 .filter((r, k) => !(r === 'tag' && k < refs.length - 1))
216 .map(r => r.replace(/^\+/, '').split(':')[0] ?? '')
217 .filter(s => s !== '')
218 // - で始まる物は git log のオプションになるので使わない
219 return srcs.length > 0 && srcs.every(s => !s.startsWith('-')) ? [...srcs, ...not] : ['HEAD', ...not]
220}
221
222/** git log に渡す書式。コミットの頭に \x01、作者名・メール・コミッター名・メールを \x00 で区切る */
223export const LOG_FORMAT = '--format=%x01%H%x00%an%x00%ae%x00%cn%x00%ce'
224
225export type CheckConfig = { allowed: string[]; words: string[] }
226
227/** 設定の「a, b、c」を分ける */
228export const listOf = (raw: unknown) =>
229 String(raw ?? '')
230 .split(/[,、\n]/)
231 .map(s => s.trim())
232 .filter(s => s !== '')
233
234const isAllowedEmail = (email: string, allowed: string[]) => {
235 const e = email.toLowerCase()
236 if (/noreply|no-reply/.test(e)) return true
237 if (/@(?:[\w-]+\.)*(?:example\.(?:com|org|net)|localhost|test|invalid|example)$/.test(e)) return true
238 return allowed.some(a => {
239 const x = a.toLowerCase()
240 return x.startsWith('@') ? e.endsWith(x) : e === x
241 })
242}
243
244// 見本でよく使う名前 (<ドライブ>:\Users\<名前> の <名前> がこれなら気にしない)
245const PLACEHOLDER = /^(?:public|default|default user|all users|user|users|username|yourname|your-name|your_name|name|me|you|someone|runner|shared|admin|administrator|example|foo|bar|xxx+|\.\.\.|…)$/i
246const HOME = /(?:\b[A-Za-z]:[\\/]+|(?<![\w.])\/[A-Za-z]\/|(?<![\w.])\/)(?:Users|home)[\\/]+([^\\/\s"'`<>:*?|,;)\]}]+)/g
247const EMAIL = /[\w.+-]+@[\w-]+(?:\.[\w-]+)*\.[A-Za-z]{2,}\b/g
248
249/** 人に見せる時は頭 2 字だけ残す */
250export const mask = (s: string) => (s.length <= 2 ? `${s}***` : `${s.slice(0, 2)}***`)
251const maskEmail = (e: string) => {
252 const [local = '', domain = ''] = e.split('@')
253 return `${mask(local)}@${domain}`
254}
255
256type Hit = { what: string; files: Set<string>; commits: Set<string> }
257
258/**
259 * git log -p の出力から、公開前に気になる所を拾う。作者・コミッターのメールが noreply でない、
260 * 足した行に個人のメール・ユーザー名入りのパス・指定した言葉がある。1 行ずつの文で返す (最大 8)
261 */
262export function scanPush(out: string, cfg: CheckConfig): string[] {
263 const hits = new Map<string, Hit>()
264 const hit = (key: string, what: string, commit: string, file?: string) => {
265 const h = hits.get(key) ?? { what, files: new Set<string>(), commits: new Set<string>() }
266 h.commits.add(commit)
267 if (file !== undefined) h.files.add(file)
268 hits.set(key, h)
269 }
270 const words = cfg.words.map(w => w.toLowerCase())
271 for (const chunk of out.split('\x01').slice(1)) {
272 const [head = '', ...lines] = chunk.split('\n')
273 const [hash = '', an = '', ae = '', cn = '', ce = ''] = head.split('\x00')
274 for (const email of new Set([ae, ce])) if (email !== '' && !isAllowedEmail(email, cfg.allowed)) hit(`author:${email}`, `作者のメールが noreply ではない: ${maskEmail(email)}`, hash)
275 for (const w of words) if (`${an}\n${cn}`.toLowerCase().includes(w)) hit(`author-word:${w}`, `作者名に「${w}」`, hash)
276 let file = ''
277 for (const line of lines) {
278 if (line.startsWith('+++ ')) {
279 file = line.slice(4).replace(/^b\//, '')
280 continue
281 }
282 if (!line.startsWith('+')) continue
283 const added = line.slice(1)
284 for (const m of added.matchAll(EMAIL)) if (!isAllowedEmail(m[0], cfg.allowed)) hit(`email:${m[0].toLowerCase()}`, `個人のメールらしい文字列: ${maskEmail(m[0])}`, hash, file)
285 for (const m of added.matchAll(HOME)) {
286 const name = m[1] ?? ''
287 // 1 字の名前 (/home/a) はテストの見本とみなす
288 if (name.length < 2 || PLACEHOLDER.test(name) || /^[<$%{[(*]/.test(name)) continue
289 hit(`home:${name.toLowerCase()}`, `ユーザー名入りのパス: ${m[0].slice(0, m[0].length - name.length)}${mask(name)}`, hash, file)
290 }
291 const low = added.toLowerCase()
292 for (const w of words) if (low.includes(w)) hit(`word:${w}`, `気にする言葉「${w}」`, hash, file)
293 }
294 }
295 return [...hits.values()].slice(0, 8).map(h => {
296 const files = [...h.files]
297 const where = files.length === 0 ? `${h.commits.size} コミット` : files.length === 1 ? files[0] : `${files[0]} ほか ${files.length - 1} 件`
298 // 足した行やファイル名から来た文字が混じるので、制御文字を落とす
299 return clean(`${h.what} (${short(where ?? '', 60)})`)
300 })
301}
302
303const PUB = String.raw`(?:push|プッシュ|公開|リリース|release|publish|アップロード|upload|デプロイ|deploy)`
304/**
305 * 公開する事を言っているか (公開しますか・push して・公開+リリース・リリース v1・publish to npm)。
306 * 公開の言葉の後ろが「する」の形の時だけ。公開前に・push は後で・リリースノート・push しない は違う
307 */
308export const PUBLISH_DO = new RegExp(
309 String.raw`${PUB}(?:\s*(?:を|も)?\s*(?:し(?!ない|ません|なく)|する|させ|やる|やって|実行|行|ok\b|[++・&]|v\d|[??!!。]|$)|\s+(?:to|it|now|this|the|them)\b)`,
310 'i',
311)
312/** 断る返事か */
313export const NEGATIVE =
314 /やめ|止め|しない|しません|しなく|せず|してない|していない|中止|キャンセル|見送|待って|後で|あとで|まだ|いいえ|ダメ|だめ|駄目|不要|いらない|要らない|やらない|無理|却下|拒否|\bno\b|\bnope\b|\bnah\b|cancel|don't|do not|not now|skip|abort/i
315/** 頭が肯定の返事か (はい・実行する・OK・yes など) */
316export const AFFIRM =
317 /^\s*(?:はい|ええ|うん|お願い|実行|進め|進んで|どうぞ|いいよ|いいです|よいです|大丈夫|問題な|許可|承認|了解|オッケー|オーケー|する|します|して|やる|やって|(?:ok|okay|yes|y|yep|yeah|sure|go|please)\b)/i
318
319/** AskUserQuestion の答えの文から「問い」=「答え」の組を拾う */
320export function answersOf(text: string) {
321 return [...text.matchAll(/"((?:[^"\\]|\\.)*)"="((?:[^"\\]|\\.)*)"/g)].map(m => ({ question: m[1] ?? '', answer: m[2] ?? '' }))
322}
323
324/**
325 * 公開してよいと答えたか。答えが断りでなく、答えそのものが公開すると言っている (公開+リリース・push する) か、
326 * 問いが公開するかを聞いていて答えが肯定 (はい・実行・OK) の組が 1 つあれば許す。
327 * 公開と関係ない問い (公開前に README を直しますか) の「はい」や、読めない答えでは許さない (見張りがその場で聞き直す)
328 */
329export function approvedIn(text: string) {
330 const pair = answersOf(text).find(({ question, answer }) => !NEGATIVE.test(answer) && (PUBLISH_DO.test(answer) || (PUBLISH_DO.test(question) && AFFIRM.test(answer))))
331 return pair ? short(pair.question, 60) : undefined
332}
333
334/** 確認が今のターンで効くか (確認したターンと、その次のターンまで) */
335export const isValid = (permit: GuardPermit | null, turn: number) => permit !== null && turn <= permit.turn + 1
336
337export const YES = '実行する'
338export const NO = 'やめる'
339
340const bullets = (findings: readonly string[]) => findings.map(f => `・${f}`).join('\n')
341
342/** その場で聞く文。公開前のチェックで引っかかった所があれば並べる */
343export function askText(label: string, command: string, findings: readonly string[] = []) {
344 const found = findings.length === 0 ? '' : `公開前のチェックで気になる所がありました。\n${bullets(findings)}\n\n`
345 return `外に出す操作 (${label}) を実行しようとしています。${found}実行しますか?\n\n${short(command.trim(), 300)}`
346}
347
348/** 止めた時に Claude へ返す文 */
349export function denyText(label: string, outcome: GuardOutcome, answer = '', findings: readonly string[] = []) {
350 const found = findings.length === 0 ? '' : `\n公開前のチェックで気になった所:\n${bullets(findings)}`
351 if (outcome === 'asked-no') {
352 const said = answer !== '' && answer !== NO ? `(ユーザーの返事:「${short(answer, 200)}」)` : ''
353 return `ship-guard: ユーザーが実行しないと答えたので ${label} を止めました${said}。返事に従ってください。${found}`
354 }
355 return `ship-guard: ${label} は外に出す操作です。ユーザーの確認が取れなかったので止めました。AskUserQuestion で実行してよいか確かめてから、もう一度実行してください。${found}`
356}
357
358const OUTCOME: Record<GuardOutcome, { word: string; color: () => string; term?: string }> = {
359 confirmed: { word: '確認済みで通した', color: () => C.green, term: 'success' },
360 'asked-yes': { word: 'その場で聞いて通した', color: () => C.blue },
361 'asked-no': { word: '断られたので止めた', color: () => C.yellow, term: 'warning' },
362 'no-ask': { word: '聞けないので止めた', color: () => C.red, term: 'error' },
363}
364
365/** 時刻は JST に固定する */
366const clock = (ms: number) => {
367 const d = new Date(ms + 9 * 3_600_000)
368 return [d.getUTCHours(), d.getUTCMinutes()].map(n => String(n).padStart(2, '0')).join(':')
369}
370
371export type PaneInput = { isOff: boolean; log: GuardEntry[]; permit: GuardPermit | null; turn: number }
372
373const permitWord = (i: PaneInput) => (isValid(i.permit, i.turn) ? `確認済み: ${i.permit?.about ?? ''}` : '確認はまだありません')
374
375/** ペインの SVG 1 枚。新しい順に 8 件 */
376export function paneSvg(i: PaneInput) {
377 const rows = i.log.slice(-8).reverse()
378 const accent = i.isOff ? C.dim : C.green
379 let s = panel(8, 8, 364, 72)
380 s += `<text x="20" y="34" font-size="15" font-weight="bold" fill="${accent}">${i.isOff ? 'このセッションは見張っていません' : '見張り中'}</text>`
381 s += `<text x="20" y="58" font-size="12" fill="${C.dim}">${esc(short(permitWord(i), 30))}</text>`
382 const h = Math.max(1, rows.length) * 40 + 40
383 s += panel(8, 90, 364, h)
384 s += `<text x="20" y="114" font-size="13" font-weight="bold" fill="${C.text}">最近の記録</text>`
385 if (rows.length === 0) s += `<text x="20" y="144" font-size="12" fill="${C.dim}">git push・gh release・npm publish などはまだありません</text>`
386 rows.forEach((r, k) => {
387 const y = 130 + k * 40
388 const o = OUTCOME[r.outcome]
389 s += `<rect x="20" y="${y}" width="4" height="30" rx="2" fill="${o.color()}"/>`
390 s += `<text x="32" y="${y + 13}" font-size="13" font-weight="bold" fill="${C.text}">${esc(r.label)}</text>`
391 s += `<text x="360" y="${y + 13}" font-size="12" text-anchor="end" fill="${C.dim}">${clock(r.at)}</text>`
392 s += `<text x="32" y="${y + 28}" font-size="12" fill="${o.color()}">${o.word}${findingsWord(r)}</text>`
393 })
394 // 最後に引っかかった公開前チェックの中身
395 const found = lastFindings(i.log)
396 let fh = 0
397 if (found.length > 0) {
398 fh = found.length * 20 + 40
399 const y0 = 98 + h
400 s += panel(8, y0, 364, fh)
401 s += `<text x="20" y="${y0 + 24}" font-size="13" font-weight="bold" fill="${C.yellow}">最後の公開前チェックで気になった所</text>`
402 found.forEach((f, k) => (s += `<text x="20" y="${y0 + 46 + k * 20}" font-size="12" fill="${C.text}">・${esc(short(f, 30))}</text>`))
403 fh += 8
404 }
405 return frame(HEADER_H + 100 + h + fh, header('外に出す操作の見張り', `${i.log.length} 件`, accent) + `<g transform="translate(0 ${HEADER_H})">${s}</g>`)
406}
407
408const findingsWord = (r: GuardEntry) => (r.findings && r.findings.length > 0 ? `・気になる所 ${r.findings.length} 件` : '')
409
410/** 新しい順に見て、最初に公開前チェックで引っかかった記録の中身 (最大 5) */
411const lastFindings = (log: readonly GuardEntry[]) => [...log].reverse().find(r => (r.findings?.length ?? 0) > 0)?.findings?.slice(0, 5) ?? []
412
413export type Line = { text: string; color?: string; isDim?: boolean; isBold?: boolean }
414
415/** Svg の alt と terminal で使う文の行 */
416export function paneLines(i: PaneInput): Line[] {
417 const lines: Line[] = [
418 { text: i.isOff ? 'このセッションは見張っていません' : '見張り中', isBold: true, color: i.isOff ? undefined : 'success' },
419 { text: permitWord(i), isDim: true },
420 ]
421 if (i.log.length === 0) lines.push({ text: 'git push・gh release・npm publish などはまだありません', isDim: true })
422 for (const r of i.log.slice(-8).reverse()) lines.push({ text: `${clock(r.at)} ${r.label} ─ ${OUTCOME[r.outcome].word}${findingsWord(r)}`, color: OUTCOME[r.outcome].term })
423 const found = lastFindings(i.log)
424 if (found.length > 0) {
425 lines.push({ text: '最後の公開前チェックで気になった所', isBold: true, color: 'warning' })
426 for (const f of found) lines.push({ text: `・${f}` })
427 }
428 return lines
429}
430hooks/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}
315hooks/words.ts 181 lines1// コマンドを語に分ける。run-guard の hooks/guard.ts の parse と同じ物 (mod ごとに単体で配るので写しを持つ。直す時は両方)
2
3export type Shell = 'bash' | 'powershell' | 'cmd'
4
5/** 1 語。text は引用符を外した中身、bare は引用符の中を Q にした物 (文字列の中の言葉を拾わない) */
6export type Word = { text: string; bare: string }
7
8/**
9 * コマンドを区切り (; | & { } 改行) ごとの語の並びにする。引用符の中・ヒアドキュメント・here-string は 1 語の中身になり、
10 * コメント (語の頭の #、PowerShell の <# #>) は落とす。( ) は語の切れ目だけにする (-ArgumentList @('a') -WindowStyle Hidden を 1 つに保つ)
11 */
12export function parse(command: string, shell: Shell): Word[][] {
13 const segs: Word[][] = []
14 let seg: Word[] = []
15 let text = ''
16 let bare = ''
17 let inWord = false
18 const heredocs: { tag: string; into: Word[] }[] = []
19 const endWord = () => {
20 if (inWord) seg.push({ text, bare })
21 text = ''
22 bare = ''
23 inWord = false
24 }
25 const endSeg = () => {
26 endWord()
27 if (seg.length > 0) segs.push(seg)
28 seg = []
29 }
30 const add = (t: string, b = t) => {
31 text += t
32 bare += b
33 inWord = true
34 }
35 const s = command
36 let i = 0
37 while (i < s.length) {
38 const c = s[i] ?? ''
39 const next = s[i + 1] ?? ''
40 // PowerShell の here-string (@' から行頭の '@ まで)
41 if (shell === 'powershell' && c === '@' && (next === "'" || next === '"') && !inWord && /^[ \t]*\r?\n/.test(s.slice(i + 2))) {
42 const end = s.indexOf(`\n${next}@`, i + 2)
43 const stop = end < 0 ? s.length : end + 3
44 add(s.slice(s.indexOf('\n', i) + 1, end < 0 ? s.length : end), 'Q')
45 i = stop
46 continue
47 }
48 if (c === "'" && shell !== 'cmd') {
49 let j = i + 1
50 let body = ''
51 for (; j < s.length; j++) {
52 if (s[j] === "'") {
53 if (shell === 'powershell' && s[j + 1] === "'") {
54 body += "'"
55 j++
56 continue
57 }
58 break
59 }
60 body += s[j]
61 }
62 add(body, 'Q')
63 i = j + 1
64 continue
65 }
66 if (c === '"') {
67 const esc = shell === 'bash' ? '\\' : shell === 'powershell' ? '`' : ''
68 let j = i + 1
69 let body = ''
70 for (; j < s.length && s[j] !== '"'; j++) {
71 if (esc !== '' && s[j] === esc && j + 1 < s.length) {
72 const e2 = s[j + 1] ?? ''
73 body += shell === 'bash' && !'"\\$`\n'.includes(e2) ? `\\${e2}` : e2
74 j++
75 continue
76 }
77 body += s[j]
78 }
79 add(body, 'Q')
80 i = j + 1
81 continue
82 }
83 // ${VAR} は 1 語の中
84 if (c === '$' && next === '{') {
85 const end = s.indexOf('}', i)
86 add(s.slice(i, end < 0 ? s.length : end + 1))
87 i = end < 0 ? s.length : end + 1
88 continue
89 }
90 if (c === '#' && !inWord && shell !== 'cmd') {
91 const nl = s.indexOf('\n', i)
92 i = nl < 0 ? s.length : nl
93 continue
94 }
95 if (shell === 'powershell' && c === '<' && next === '#') {
96 const end = s.indexOf('#>', i + 2)
97 i = end < 0 ? s.length : end + 2
98 continue
99 }
100 // bash のヒアドキュメント (<<EOF・<<-'EOF')。本文は次の改行の後で読む
101 if (shell === 'bash' && c === '<' && next === '<' && s[i + 2] !== '<') {
102 const m = /^<<-?\s*(['"]?)([\w.-]+)\1/.exec(s.slice(i))
103 if (m) {
104 endWord()
105 heredocs.push({ tag: m[2] ?? '', into: seg })
106 i += m[0].length
107 continue
108 }
109 }
110 if (c === '\n') {
111 endSeg()
112 i++
113 for (const h of heredocs.splice(0)) {
114 let body = ''
115 while (i < s.length) {
116 const nl = s.indexOf('\n', i)
117 const line = s.slice(i, nl < 0 ? s.length : nl)
118 i = nl < 0 ? s.length : nl + 1
119 if (line.trim() === h.tag) break
120 body += `${line}\n`
121 }
122 h.into.push({ text: body, bare: 'Q' })
123 }
124 continue
125 }
126 if (c === '`') {
127 if (shell === 'powershell') {
128 if (next === '\n' || next === '\r') endWord()
129 else add(next)
130 i += 2
131 continue
132 }
133 if (shell === 'bash') {
134 endSeg()
135 i++
136 continue
137 }
138 }
139 if (c === '\\' && shell === 'bash' && ' \n"\'`$\\;&|()'.includes(next) && next !== '') {
140 if (next === '\n') endWord()
141 else add(next)
142 i += 2
143 continue
144 }
145 // $( ... ) は中身を別の区切りとして読み、外の語は 1 語のまま (X=$(mktemp -d) claude -p を切らない)
146 if (c === '$' && next === '(') {
147 let depth = 1
148 let j = i + 2
149 for (; j < s.length && depth > 0; j++) {
150 if (s[j] === '(') depth++
151 else if (s[j] === ')') depth--
152 }
153 segs.push(...parse(s.slice(i + 2, depth === 0 ? j - 1 : j), shell))
154 add(s.slice(i, j), '$Q')
155 i = j
156 continue
157 }
158 if (';|&{}\n'.includes(c)) {
159 endSeg()
160 i++
161 continue
162 }
163 if (c === ' ' || c === '\t' || c === '\r' || c === '(' || c === ')' || c === ',') {
164 endWord()
165 i++
166 continue
167 }
168 add(c)
169 i++
170 }
171 endSeg()
172 return segs
173}
174
175/** 名前の頭 (パスと .exe などを落として小文字) */
176export const baseName = (s: string) =>
177 s
178 .replace(/^.*[\\/]/, '')
179 .toLowerCase()
180 .replace(/\.(?:exe|cmd|bat|com|ps1)$/, '')
181types/index.d.ts 30 lines1/** 見張りの結果。confirmed は直前の確認で通した、asked-yes / asked-no はその場で聞いた、no-ask は聞けなかった */
2export type GuardOutcome = 'confirmed' | 'asked-yes' | 'asked-no' | 'no-ask'
3
4/** 記録の 1 件。古い順、最大 20 */
5export type GuardEntry = {
6 at: number
7 /** git push など */
8 label: string
9 /** コマンドの頭 200 字 */
10 command: string
11 outcome: GuardOutcome
12 /** 公開前のチェック (git push の時) で気になった所。無ければ無し */
13 findings?: string[]
14}
15
16/** 公開してよいという確認。turn のターンと、その次のターンまで効く */
17export type GuardPermit = { turn: number; at: number; about: string }
18
19declare module 'claude-code' {
20 interface PluginState {
21 'ship-guard': {
22 isOff: boolean
23 log: GuardEntry[]
24 permit: GuardPermit | null
25 /** メインのターンの数 */
26 turn: number
27 }
28 }
29}
30