SLOPSHOPPER

run-guard

Claude が確かめのために窓のあるアプリを起動する・音を鳴らす・claude -p を隔離なしで走らせるのを、実行の前に止める (設定で聞く・伝えるだけにもできる)。/runguard で数と記録と、組ごとのオン/オフ

newpaneguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · run-guard
│ ┃ 窓と音の見張り ✕ › fix the failing auth test and add an audit log call │ ┃ 見張り中 │ ┃ 見つけたら実行の前に止めます ⏺ Read(src/auth.ts) │ ┃ 窓: 見張り中 ─ 0 回 ⎿ Read 6 lines │ ┃ 音: 見張り中 ─ 0 回 ⏺ Update(src/auth.ts) │ ┃ claude の headless 実行: 見張り中 ─ 0 回 ⎿ Added 2 lines, removed 1 line │ ┃ 最近の記録 ⏺ Bash(bun test) │ ┃ まだありません ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ 窓を見張らない ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ 音を見張らない ] │ ┃ [ claude の headless 実行を見張らない ] ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ 見るのは Bash と PowerShell › /runguard │ ┃ のコマンドです。窓は Start-Process │ ┃ (-WindowStyle Hidden │ ┃ なし)・start・ii・explorer・code・godot や │ ┃ chrome (--headless なし)、音は │ ┃ Beep・SoundPlayer・読み上げ・ffplay │ ┃ など、headless は一時の │ ┃ CLAUDE_CONFIG_DIR・127.0.0.1 の │ ┃ ANTHROPIC_BASE_URL・CLAUDE_CODE_PLUGIN_DIRS │ ┃ を消す、の 3 つをそろえずに走らせる claude │ ┃ -p です。 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 窓と音の見張り
見張り中 見つけたら実行の前に止めます 窓: 見張り中 ─ 0 回 音: 見張り中 ─ 0 回 claude の headless 実行: 見張り中 ─ 0 回 最近の記録 まだありません [ 窓を見張らない ] [ 音を見張らない ] [ claude の headless 実行を見張らない ] 見るのは Bash と PowerShell のコマンドです。窓は Start-Process (-WindowStyle Hidden なし)・start・ii・explorer・code・godot や chrome (--headless なし)、音は Beep・SoundPlayer・読み上げ・ffplay など、headless は一時の CLAUDE_CONFIG_DIR・127.0.0.1 の ANTHROPIC_BASE_URL・CLAUDE_CODE_PLUGIN_DIRS を消す、の 3 つをそろえずに走らせる claude -p です。
README

claude-code-mods

English

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

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

入っている mod

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

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

画面

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

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

動く絵

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

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

明るいテーマ

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

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

動作環境

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

入れ方

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

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

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

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

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

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

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

まとめて入れる

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

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

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

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

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

PowerShell:

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

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

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

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

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

気を付けること

  • rule-switch の約束ごとは、サブエージェントのシステムプロンプトにも入ります。また、組織が Claude Code を管理している環境では、利用者が入れたプラグインのシステムプロンプトのフックは動きません
  • rule-switch を切り替えると、プロンプトのキャッシュが作り直されます
  • canvas-pane のツールに渡された生の SVG は、スクリプト・イベント属性・外部の参照を含む物を断ります(ペインでは画像として描かれ、スクリプトは動きません)
  • turn-haiku は、答えて終わったターンごと(ツールを使ったか 20 秒以上かかった時)に Haiku のモデルを 1 回呼び、トークンを使います。/haiku の「自動で詠む」で止められます
  • port-watch の「止める」は子プロセスごと止めます(Windows は taskkill /T /F)。node で動く MCP サーバーも開発用に見えるので、止める前に名前とポートを確かめてください。macOS / Linux(lsof)の実機では未確認です
  • pre-check の秘密情報の検出は手がかり程度です。見つからなくても安全とは限りません
  • done-chime は Windows では PowerShell の SoundPlayer で鳴らします。それ以外は Claude Code の音の再生を使い、Linux のターミナルでは鳴らないことがあります
  • spec-board の一覧は SpecStatus が GitHub の API を使うため、回数の上限に当たることがあります。読み直しは 5 分ごとです
  • ci-pane と git を読む mod は、所有者の違うフォルダ(別ドライブなど)では git が止まります。git config --global --add safe.directory <フォルダ> を足してください
  • aqua-pet の背景は 15 分ごとに描き直します。描き直すと泳ぎや泡のアニメが頭から始まります
  • aqua-pet 0.2 の記録は、開いた時に今の匹数の魚を作って引き継ぎます。0.2 と 0.3 のセッションを同時に開いていると、魚の来た日が作り直されることがあります
  • dev-rpg の装備は turn-gacha 0.2.0 以降と一緒に入れた時だけ使えます。ガチャの図鑑から消えたカードは外れた扱いになりますが、記録は残ります
  • toast-inbox が拾うのは、ほかの mod が $.ui.toast / $.ui.status を呼んだ時だけです。「空にする」はほかのセッションの記録も消します
  • usage-history は入れた後のターンから数えます。日付・曜日・時刻は日本時間です
  • file-map は失敗した呼び出しを数えません。Grep と Glob は path を渡した時だけ数えます
  • 明るいテーマでも、水槽・俳句の短冊・おみくじの紙・実況のスタジアム・RPG の窓は絵の色のままです
  • mod の版を上げたら、開いているほかのセッションも開き直してください。0.7.0 から記録は知らない欄を残して書くので、新しい版の記録は消えなくなりましたが、0.6 以前の mod を読んだまま動いているセッションは、新しい版で増えた欄(魚の種類・装備・SP の割り振りなど)を消して書き戻します
  • fail-log の記録は、同時に開いた全セッションで共有します。「今日」は日本時間の日付で、残している 200 件から数えます
  • todo-board は git の中では git grep で読みます(.gitignore の物は読みません)。git の外では作業フォルダを歩いて読み(2000 ファイル・5000 件・8 秒で止め、1 MiB を超えるファイルと . で始まるフォルダ・node_modules・dist などは読みません)、この時はファイルを変えても自動では読み直さないので「再読込」を押してください
  • daily-quest のお題は日本時間の日付だけで決まります。数えるのはメインのターンだけで、「テスト」はコマンドの名前の形(npm test・pytest など)で見ます。ごほうびは dev-rpg と turn-gacha がターンの終わりに 1 回だけ受け取ります
  • trophy-shelf はほかの mod の記録を読むだけです。入っていない mod の棚は「入っていません」と出ます。一度取ったトロフィーは、その mod を外しても残ります
  • cc-dash の「自分の並び」は記録に残るので、全セッションで共有されます
  • mod-check が版と「読み込みを拒まれた」を出せるのは、mod-check より後に読み込まれた mod だけです。「state まだ」は壊れているという意味ではありません(開
Source 4 files
hooks/register.tsx 184 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RunAction, RunEntry, RunGroup, RunSaved } from '../types'
5import { EMPTY, GROUPS, GROUP_NAME, LOG_MAX, NO, YES, addEntry, askText, classify, denyText, fromRaw, headOf, isWritable, listOf, modeOf, noteText, paneLines, paneSvg, withUnknown } from './guard'
6import type { Found, Mode } from './guard'
7import { followTheme, mayWrite, readTheme, rememberVersion } from './style'
8
9const PANE = 'run-guard'
10const TITLE = '窓と音の見張り'
11const DESCRIPTION = '窓・音・隔離なしの claude -p の見張りの数と記録と、組ごとのオン/オフのペインを開く'
12const KEY = 'save'
13
14const SAVED = atom({ plugin: 'run-guard', key: 'saved' } as const, EMPTY)
15
16type $ = EngineInterface
17type ToolResult = { deny?: string; isError?: boolean; context?: readonly string[] }
18
19let ownsMain = false
20// 設定は register で userConfig から入れる
21const cfg = { mode: '止める' as Mode, windowApps: [] as string[] }
22
23/** store を読み直して state に置く */
24async function load($: $) {
25  const s = fromRaw(await $.store.get(KEY).catch(() => undefined))
26  await update($, SAVED, () => s)
27  return s
28}
29
30// ponytail: 書き込みはこのセッションの中で 1 本ずつ (読み直し → 書く の間に別の書き込みを挟まない)。別のセッションとは読み直しで合わせる
31let chain: Promise<unknown> = Promise.resolve()
32
33let toldBroken = false
34
35/** 書く直前に読み直し、知らない欄を残して書く。古い版のまま・読めない (reject・形が違う) なら書かない */
36function save($: $, change: (s: RunSaved) => RunSaved): Promise<boolean> {
37  const run = async () => {
38    if (!(await mayWrite($, await $.clock.now()))) return false
39    const raw = await $.store.get(KEY)
40    if (!isWritable(raw)) {
41      if (!toldBroken) {
42        toldBroken = true
43        $.ui.toast('run-guard: 記録を読めなかったので、記録は書いていません (見張りは続けます)', { timeoutMs: 8000 })
44      }
45      return false
46    }
47    const mine = change(fromRaw(raw))
48    await $.store.set(KEY, withUnknown(raw, mine))
49    await update($, SAVED, () => mine)
50    return true
51  }
52  const p = chain.then(run, run)
53  chain = p.catch(() => undefined)
54  return p
55}
56
57// 書けなかった記録 (読めない・古い版・store が拒んだ)。次に書ける時に一緒に足す
58let pending: RunEntry[] = []
59
60async function record($: $, found: Found[], command: string, action: RunAction) {
61  const groups = [...new Set(found.map(f => f.group))]
62  try {
63    const at = await $.clock.now()
64    pending = [...pending, { at, groups, command: headOf(command), action }].slice(-LOG_MAX)
65    let took: RunEntry[] = []
66    const ok = await save($, s => {
67      took = pending
68      return took.reduce(addEntry, s)
69    })
70    if (ok) pending = pending.filter(e => !took.includes(e))
71  } catch {
72    // 記録が書けなくても見張りは続ける
73  }
74  if (action === 'blocked' || action === 'told') {
75    const names = groups.map(g => GROUP_NAME[g]).join('・')
76    $.ui.toast(`run-guard: ${names}${action === 'blocked' ? 'を止めました' : 'について Claude に伝えました'}`, { timeoutMs: 6000 })
77  }
78}
79
80/**
81 * シェルのツールの見張り。見つけたら、設定に合わせて止める・聞く・実行の後で伝える。
82 * 見張りなので .catch は付けない (見分けで失敗した時だけ通す)
83 */
84async function guard<R extends ToolResult>($: $, command: string, shell: 'bash' | 'powershell', next: () => Promise<R>): Promise<R | { deny: string }> {
85  const s = await load($).catch(() => EMPTY)
86  let found: Found[] = []
87  try {
88    found = classify(command, shell, cfg.windowApps).filter(f => !s.off[f.group])
89  } catch {
90    found = []
91  }
92  if (found.length === 0) return next()
93
94  if (cfg.mode === '伝えるだけ') {
95    const r = await next()
96    if (r.deny !== undefined) return r
97    await record($, found, command, 'told')
98    return { ...r, context: [...(r.context ?? []), noteText(found)] }
99  }
100  if (cfg.mode === '聞く') {
101    let answer: string | undefined
102    try {
103      answer = await $.ui.ask(askText(found, command), { options: [YES, NO], header: '窓と音の確認' })
104    } catch {
105      answer = undefined
106    }
107    if (answer === YES) {
108      await record($, found, command, 'asked-yes')
109      return next()
110    }
111    await record($, found, command, answer === undefined ? 'blocked' : 'asked-no')
112    return { deny: denyText(found, answer === undefined ? 'no-ask' : 'asked-no', answer) }
113  }
114  await record($, found, command, 'blocked')
115  return { deny: denyText(found, 'blocked') }
116}
117
118const openPane = async ($: $) => {
119  await load($).catch(() => undefined)
120  await $.ui.open({ id: PANE, title: TITLE })
121  return { text: '窓と音の見張りのペインを開きました。' }
122}
123
124const toggle = ($: $, g: RunGroup) => save($, s => ({ ...s, off: { ...s.off, [g]: !s.off[g] } })).catch(() => false)
125
126export const register: Register = (on, options) => {
127  cfg.mode = modeOf(options.mode)
128  cfg.windowApps = listOf(options.windowApps)
129
130  followTheme(on)
131  on('session.start', async ($, e, next) => {
132    const started = await next(e)
133    await readTheme($)
134    await rememberVersion($)
135    await load($).catch(() => undefined)
136    // /runguard が拒まれたら /run-guard にする
137    const got = await $.command.register({ name: 'runguard', description: DESCRIPTION }).catch(() => undefined)
138    ownsMain = got?.command === 'runguard'
139    if (!ownsMain) await $.command.register({ name: 'run-guard', description: DESCRIPTION }).catch(() => {})
140    return started
141  })
142
143  on('command.run', { command: 'runguard' }, ($, e, next) => (ownsMain ? openPane($) : next(e))).catch(($, e, next) => next(e))
144  // /runguard を持てた時は予備名を開かない (同じ名前の別のコマンドを横取りしない)
145  on('command.run', { command: 'run-guard' }, ($, e, next) => (ownsMain ? next(e) : openPane($)))
146
147  on('tool.call', { tool: 'Bash' }, ($, e, next) => guard($, e.command, 'bash', () => next(e)))
148  on('tool.call', { tool: 'PowerShell' }, ($, e, next) => guard($, e.command, 'powershell', () => next(e)))
149
150  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
151    const t = $.ui.resolve(e)
152    const { Box, Text, Button } = t
153    // terminal の表にも Svg の名前があるので、画面の種類でも分ける
154    const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : undefined
155    const input = { saved: await read($, SAVED), mode: cfg.mode }
156    const lines = paneLines(input)
157    return (
158      <Box flexDirection="column" gap={1}>
159        {Svg ? (
160          <Svg source={paneSvg(input)} alt={lines.map(l => l.text).join('\n')} />
161        ) : (
162          <Box key="lines" flexDirection="column">
163            {lines.map(l => (
164              <Text color={l.color} dimColor={l.isDim} bold={l.isBold}>
165                {l.text}
166              </Text>
167            ))}
168          </Box>
169        )}
170        <Box key="toggles" flexDirection="column">
171          {GROUPS.map(g =>
172            input.saved.off[g] ? (
173              <Button key={`toggle-${g}`} label={`${GROUP_NAME[g]}の見張りを戻す`} variant="primary" onPress={() => toggle($, g)} />
174            ) : (
175              <Button key={`toggle-${g}`} dimColor label={`${GROUP_NAME[g]}を見張らない`} onPress={() => toggle($, g)} />
176            ),
177          )}
178        </Box>
179        <Text dimColor>見るのは Bash と PowerShell のコマンドです。窓は Start-Process (-WindowStyle Hidden なし)・start・ii・explorer・code・godot や chrome (--headless なし)、音は Beep・SoundPlayer・読み上げ・ffplay など、headless は一時の CLAUDE_CONFIG_DIR・127.0.0.1 の ANTHROPIC_BASE_URL・CLAUDE_CODE_PLUGIN_DIRS を消す、の 3 つをそろえずに走らせる claude -p です。</Text>
180      </Box>
181    )
182  })
183}
184
hooks/guard.ts 648 lines
1// 窓・音・隔離なしの headless claude の見分けと、記録の形、ペインの SVG と文。どれも純関数
2import type { RunAction, RunEntry, RunGroup, RunSaved } from '../types'
3import { C, HEADER_H, clean, esc, frame, header, panel, short } from './style'
4
5export const LOG_MAX = 50
6export const GROUPS: readonly RunGroup[] = ['window', 'sound', 'headless']
7export const GROUP_NAME: Record<RunGroup, string> = { window: '窓', sound: '音', headless: 'claude の headless 実行' }
8
9export type Shell = 'bash' | 'powershell' | 'cmd'
10export type Found = { group: RunGroup; what: string; fix: string }
11
12// ── コマンドを語に分ける
13
14/** 1 語。text は引用符を外した中身、bare は引用符の中を Q にした物 (文字列の中の言葉を拾わない) */
15export type Word = { text: string; bare: string }
16
17/**
18 * コマンドを区切り (; | & { } 改行) ごとの語の並びにする。引用符の中・ヒアドキュメント・here-string は 1 語の中身になり、
19 * コメント (語の頭の #、PowerShell の <# #>) は落とす。( ) は語の切れ目だけにする (-ArgumentList @('a') -WindowStyle Hidden を 1 つに保つ)
20 */
21export function parse(command: string, shell: Shell): Word[][] {
22  const segs: Word[][] = []
23  let seg: Word[] = []
24  let text = ''
25  let bare = ''
26  let inWord = false
27  const heredocs: { tag: string; into: Word[] }[] = []
28  const endWord = () => {
29    if (inWord) seg.push({ text, bare })
30    text = ''
31    bare = ''
32    inWord = false
33  }
34  const endSeg = () => {
35    endWord()
36    if (seg.length > 0) segs.push(seg)
37    seg = []
38  }
39  const add = (t: string, b = t) => {
40    text += t
41    bare += b
42    inWord = true
43  }
44  const s = command
45  let i = 0
46  while (i < s.length) {
47    const c = s[i] ?? ''
48    const next = s[i + 1] ?? ''
49    // PowerShell の here-string (@' から行頭の '@ まで)
50    if (shell === 'powershell' && c === '@' && (next === "'" || next === '"') && !inWord && /^[ \t]*\r?\n/.test(s.slice(i + 2))) {
51      const end = s.indexOf(`\n${next}@`, i + 2)
52      const stop = end < 0 ? s.length : end + 3
53      add(s.slice(s.indexOf('\n', i) + 1, end < 0 ? s.length : end), 'Q')
54      i = stop
55      continue
56    }
57    if (c === "'" && shell !== 'cmd') {
58      let j = i + 1
59      let body = ''
60      for (; j < s.length; j++) {
61        if (s[j] === "'") {
62          if (shell === 'powershell' && s[j + 1] === "'") {
63            body += "'"
64            j++
65            continue
66          }
67          break
68        }
69        body += s[j]
70      }
71      add(body, 'Q')
72      i = j + 1
73      continue
74    }
75    if (c === '"') {
76      const esc = shell === 'bash' ? '\\' : shell === 'powershell' ? '`' : ''
77      let j = i + 1
78      let body = ''
79      for (; j < s.length && s[j] !== '"'; j++) {
80        if (esc !== '' && s[j] === esc && j + 1 < s.length) {
81          const e2 = s[j + 1] ?? ''
82          body += shell === 'bash' && !'"\\$`\n'.includes(e2) ? `\\${e2}` : e2
83          j++
84          continue
85        }
86        body += s[j]
87      }
88      add(body, 'Q')
89      i = j + 1
90      continue
91    }
92    // ${VAR} は 1 語の中
93    if (c === '$' && next === '{') {
94      const end = s.indexOf('}', i)
95      add(s.slice(i, end < 0 ? s.length : end + 1))
96      i = end < 0 ? s.length : end + 1
97      continue
98    }
99    if (c === '#' && !inWord && shell !== 'cmd') {
100      const nl = s.indexOf('\n', i)
101      i = nl < 0 ? s.length : nl
102      continue
103    }
104    if (shell === 'powershell' && c === '<' && next === '#') {
105      const end = s.indexOf('#>', i + 2)
106      i = end < 0 ? s.length : end + 2
107      continue
108    }
109    // bash のヒアドキュメント (<<EOF・<<-'EOF')。本文は次の改行の後で読む
110    if (shell === 'bash' && c === '<' && next === '<' && s[i + 2] !== '<') {
111      const m = /^<<-?\s*(['"]?)([\w.-]+)\1/.exec(s.slice(i))
112      if (m) {
113        endWord()
114        heredocs.push({ tag: m[2] ?? '', into: seg })
115        i += m[0].length
116        continue
117      }
118    }
119    if (c === '\n') {
120      endSeg()
121      i++
122      for (const h of heredocs.splice(0)) {
123        let body = ''
124        while (i < s.length) {
125          const nl = s.indexOf('\n', i)
126          const line = s.slice(i, nl < 0 ? s.length : nl)
127          i = nl < 0 ? s.length : nl + 1
128          if (line.trim() === h.tag) break
129          body += `${line}\n`
130        }
131        h.into.push({ text: body, bare: 'Q' })
132      }
133      continue
134    }
135    if (c === '`') {
136      if (shell === 'powershell') {
137        if (next === '\n' || next === '\r') endWord()
138        else add(next)
139        i += 2
140        continue
141      }
142      if (shell === 'bash') {
143        endSeg()
144        i++
145        continue
146      }
147    }
148    if (c === '\\' && shell === 'bash' && ' \n"\'`$\\;&|()'.includes(next) && next !== '') {
149      if (next === '\n') endWord()
150      else add(next)
151      i += 2
152      continue
153    }
154    // $( ... ) は中身を別の区切りとして読み、外の語は 1 語のまま (X=$(mktemp -d) claude -p を切らない)
155    if (c === '$' && next === '(') {
156      let depth = 1
157      let j = i + 2
158      for (; j < s.length && depth > 0; j++) {
159        if (s[j] === '(') depth++
160        else if (s[j] === ')') depth--
161      }
162      segs.push(...parse(s.slice(i + 2, depth === 0 ? j - 1 : j), shell))
163      add(s.slice(i, j), '$Q')
164      i = j
165      continue
166    }
167    if (';|&{}\n'.includes(c)) {
168      endSeg()
169      i++
170      continue
171    }
172    if (c === ' ' || c === '\t' || c === '\r' || c === '(' || c === ')' || c === ',') {
173      endWord()
174      i++
175      continue
176    }
177    add(c)
178    i++
179  }
180  endSeg()
181  return segs
182}
183
184// ── 見分け
185
186/** 名前の頭 (パスと .exe などを落として小文字) */
187export const baseName = (s: string) =>
188  s
189    .replace(/^.*[\\/]/, '')
190    .toLowerCase()
191    .replace(/\.(?:exe|cmd|bat|com|ps1)$/, '')
192
193/** 設定の「a, b、c」を名前の並びにする */
194export const listOf = (raw: unknown) =>
195  String(raw ?? '')
196    .split(/[,、\n]/)
197    .map(x => baseName(x.trim()))
198    .filter(x => x !== '')
199
200// 頭に付いても中身は後ろのコマンド (env X=1 cmd、nohup cmd、then cmd、! cmd、xargs cmd など)
201const PREFIX = new Set(['env', 'nohup', 'exec', 'time', 'command', 'sudo', 'nice', 'call', '.', 'if', 'then', 'else', 'elif', 'do', 'while', 'until', '!', 'xargs'])
202// xargs の、値を後ろの語に取るオプション
203const XARGS_VALUE = /^-[nILPdEsa]$/
204// 文字や検索を出すだけのコマンド (grep Media.SoundPlayer src などを数えない)
205const TEXTUAL = new Set(['echo', 'printf', 'write-host', 'write-output', 'write-verbose', 'grep', 'egrep', 'rg', 'select-string', 'sls', 'findstr', 'git', 'gh'])
206const INFO = /^(?:--?version|-v|--?help|-h|-\?)$/i
207const RUNNERS = new Set(['node', 'npx', 'deno', 'bun', 'bunx', 'tsx', 'python', 'python3', 'py', 'pytest', 'uv'])
208const BROWSERS = new Set(['chrome', 'msedge', 'chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable', 'firefox'])
209const PLAYERS = new Set(['ffplay', 'afplay', 'aplay', 'paplay', 'say'])
210const CODE_INFO = /^--(?:version|list-extensions|install-extension|uninstall-extension|help|status|locate-shell-integration-path)$|^-[vh]$/i
211
212// ── claude -p の隔離 (環境変数の状態)
213
214/** 決めた CLAUDE_CONFIG_DIR・ANTHROPIC_BASE_URL の値 (決めていなければ undefined) と、CLAUDE_CODE_PLUGIN_DIRS を消したか */
215export type Env = { configDir?: string; baseUrl?: string; pluginDirsGone: boolean }
216export const NO_ENV: Env = { pluginDirsGone: false }
217
218/** 変数を決める (value が undefined なら消す)。見張る 3 つのほかは何もしない */
219function setVar(env: Env, name: string, value: string | undefined): Env {
220  const v = value === '$null' ? '' : value
221  const n = name.toUpperCase()
222  if (n === 'CLAUDE_CONFIG_DIR') return { ...env, configDir: v }
223  if (n === 'ANTHROPIC_BASE_URL') return { ...env, baseUrl: v }
224  if (n === 'CLAUDE_CODE_PLUGIN_DIRS') return { ...env, pluginDirsGone: v === undefined || v === '' }
225  return env
226}
227
228/** 本物の ~/.claude を指さない、空でない CLAUDE_CONFIG_DIR か */
229const isOwnConfigDir = (v: string | undefined) => v !== undefined && v.trim() !== '' && !/(?:^|[\\/])\.claude[\\/]?$/i.test(v.trim())
230/** モデルを呼ばない (この PC の中を指す) ANTHROPIC_BASE_URL か */
231const isLocalUrl = (v: string | undefined) => v !== undefined && /^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?(?:\/|$)/i.test(v.trim())
232
233/** claude -p を隔離して走らせる環境か。一時の CLAUDE_CONFIG_DIR・この PC の中の ANTHROPIC_BASE_URL・CLAUDE_CODE_PLUGIN_DIRS を消す、の 3 つ */
234export const isIsolated = (env: Env) => isOwnConfigDir(env.configDir) && isLocalUrl(env.baseUrl) && env.pluginDirsGone
235
236/**
237 * 頭の VAR=値・前置き (env・nohup・then・xargs・timeout など)・PowerShell の $x = を飛ばした、コマンドの名前の位置。
238 * local はこの区切りだけに効く環境 (VAR=値 cmd・env -u)、persist は後ろの区切りにも残る環境 ($env:X = ...)
239 */
240function headAt(words: Word[], shell: Shell, env: Env) {
241  let local = env
242  let persist = env
243  let k = 0
244  while (k < words.length) {
245    const w = words[k]?.bare ?? ''
246    const name = baseName(w)
247    const ps = shell === 'powershell' ? /^\$(\w+(?::\w+)?)(?:=(.*))?$/s.exec(words[k]?.text ?? '') : null
248    if (ps && (ps[2] !== undefined || words[k + 1]?.bare === '=')) {
249      // $env:X = 値 は後ろにも残る。$p = New-Object ... は代入の後ろがコマンド
250      const v = ps[2] ?? words[k + 2]?.text ?? ''
251      const envName = /^env:(\w+)$/i.exec(ps[1] ?? '')?.[1]
252      if (envName !== undefined) {
253        local = setVar(local, envName, v)
254        persist = setVar(persist, envName, v)
255      }
256      k += ps[2] !== undefined ? 1 : 2
257    } else if (/^[A-Za-z_]\w*=/.test(w)) {
258      const t = words[k]?.text ?? ''
259      local = setVar(local, t.slice(0, t.indexOf('=')), t.slice(t.indexOf('=') + 1))
260      k++
261    } else if (PREFIX.has(name)) {
262      k++
263      while (/^-/.test(words[k]?.bare ?? '')) {
264        const o = words[k]?.text ?? ''
265        if (name === 'env') {
266          // env -u NAME・-uNAME・--unset=NAME・-i (全部消す)
267          const m = /^(?:-u|--unset=?)(.*)$/.exec(o)
268          if (m) {
269            const target = m[1] === '' ? words[k + 1]?.text : m[1]
270            local = setVar(local, target ?? '', undefined)
271            k += m[1] === '' ? 2 : 1
272            continue
273          }
274          if (o === '-i' || o === '-' || o === '--ignore-environment') local = { pluginDirsGone: true }
275          k++
276        } else if (name === 'xargs') k += XARGS_VALUE.test(o) ? 2 : 1
277        else k += o === '-u' ? 2 : 1
278      }
279    } else if (name === 'timeout' && words.length > k + 1) {
280      // timeout -s KILL -k 5 --preserve-status 10 cmd
281      k++
282      while (/^-/.test(words[k]?.text ?? '')) k += /^(?:-s|-k|--signal|--kill-after)$/.test(words[k]?.text ?? '') ? 2 : 1
283      if (/^\d/.test(words[k]?.text ?? '')) k++
284    } else break
285  }
286  return { at: k, local, persist }
287}
288
289/** その区切りが後ろに残す環境 (export X=…・unset X・Remove-Item Env:X) */
290function persistOf(name: string, args: Word[], env: Env): Env {
291  let out = env
292  if (name === 'export') {
293    for (const w of args) {
294      if (!/^[A-Za-z_]\w*=/.test(w.bare)) continue
295      out = setVar(out, w.text.slice(0, w.text.indexOf('=')), w.text.slice(w.text.indexOf('=') + 1))
296    }
297  } else if (name === 'unset') {
298    for (const w of args) if (!/^-/.test(w.text)) out = setVar(out, w.text, undefined)
299  } else if (['remove-item', 'ri', 'rm', 'del', 'erase'].includes(name)) {
300    for (const w of args) {
301      const m = /^env:[\\/]?(\w+)$/i.exec(w.text)
302      if (m) out = setVar(out, m[1] ?? '', undefined)
303    }
304  }
305  return out
306}
307
308const WINDOW_FIX = '開くと窓が出ます。開くのは依頼者に任せ、パスを伝えてください (中身を確かめるなら読むだけにする)'
309const SOUND_FIX = '音を出さずに確かめてください (鳴らす処理は呼ばず、その手前の値や終了コードを見る)'
310export const CLAUDE_FIX =
311  '次の 3 つを同じコマンドで決めてから走らせてください。一時の CLAUDE_CONFIG_DIR (mktemp -d などの空のフォルダ。本物の ~/.claude に書かせない)、' +
312  'CLAUDE_CODE_PLUGIN_DIRS を消す (bash は env -u CLAUDE_CODE_PLUGIN_DIRS、PowerShell は Remove-Item Env:CLAUDE_CODE_PLUGIN_DIRS)、' +
313  'ANTHROPIC_BASE_URL=http://127.0.0.1:9 (モデルを呼ばない)'
314
315const hasHidden = (args: Word[]) =>
316  args.some((w, k) => {
317    const m = /^-wi\w*(?::(.+))?$/i.exec(w.text)
318    return m !== null && (m[1] ?? args[k + 1]?.text ?? '').toLowerCase() === 'hidden'
319  })
320const hasFlag = (args: Word[], re: RegExp) => args.some(w => re.test(w.text))
321const hasDummyAudio = (args: Word[]) => args.some((w, k) => /^--audio-driver(?:=dummy)?$/i.test(w.text) && (/=dummy$/i.test(w.text) || (args[k + 1]?.text ?? '').toLowerCase() === 'dummy'))
322
323/** -EncodedCommand の中身 (base64 の UTF-16LE)。ほどけなければ空 */
324export function decodeEncoded(b64: string) {
325  try {
326    const bin = atob(b64)
327    let s = ''
328    for (let i = 0; i + 1 < bin.length; i += 2) s += String.fromCharCode(bin.charCodeAt(i) | (bin.charCodeAt(i + 1) << 8))
329    return s
330  } catch {
331    return ''
332  }
333}
334
335/** powershell の -e・-ec・-en…(-EncodedCommand の頭) か (-ex は -ExecutionPolicy なので違う) */
336const isEncodedFlag = (t: string) => {
337  const m = /^[-/](\w+)$/.exec(t)
338  const s = m?.[1]?.toLowerCase() ?? ''
339  return s === 'e' || s === 'ec' || (s.length >= 2 && 'encodedcommand'.startsWith(s))
340}
341
342/** 入れ子のシェル (powershell -Command "...", -EncodedCommand ..., iex "...", cmd /c ..., bash -c "...") の中身と、そのシェル */
343function innerOf(name: string, args: Word[]): { command: string; shell: Shell } | undefined {
344  const after = (re: RegExp) => {
345    const k = args.findIndex(w => re.test(w.text))
346    return k < 0 ? undefined : args.slice(k + 1).map(w => w.text).join(' ')
347  }
348  if (name === 'powershell' || name === 'pwsh') {
349    const enc = args.findIndex(w => isEncodedFlag(w.text))
350    if (enc >= 0) return { command: decodeEncoded(args[enc + 1]?.text ?? ''), shell: 'powershell' }
351    const command = after(/^-c(?:o(?:m(?:m(?:a(?:n(?:d)?)?)?)?)?)?:?$/i)
352    return command === undefined ? undefined : { command, shell: 'powershell' }
353  }
354  if (name === 'iex' || name === 'invoke-expression') {
355    return { command: args.filter(w => !/^-c(?:ommand)?:?$/i.test(w.text)).map(w => w.text).join(' '), shell: 'powershell' }
356  }
357  if (name === 'cmd') {
358    const command = after(/^\/[ck]$/i)
359    return command === undefined ? undefined : { command, shell: 'cmd' }
360  }
361  if (name === 'bash' || name === 'sh' || name === 'zsh') {
362    const command = after(/^-\w*c$/)
363    return command === undefined ? undefined : { command, shell: 'bash' }
364  }
365  return undefined
366}
367
368/** 1 つの区切りの中で見つけた物と、後ろの区切りに残す環境 */
369function inSegment(words: Word[], shell: Shell, windowApps: readonly string[], env: Env, depth: number): { found: Found[]; env: Env } {
370  const { at, local, persist } = headAt(words, shell, env)
371  const head = words[at]
372  if (head === undefined) return { found: [], env: persist }
373  const name = baseName(head.text)
374  const args = words.slice(at + 1)
375  const after = persistOf(name, args, persist)
376  return { found: findIn(head, name, args, shell, windowApps, local, depth), env: after }
377}
378
379const WINDOW_APPS = new Set(['notepad', 'mshta', 'xdg-open'])
380const PYTHONS = /^(?:python[\d.]*w?|py|pythonw)$/
381// playwright のうち窓を開くコマンド (codegen・open と、その別名 cr・ff・wk、記録や報告の表示)
382const PLAYWRIGHT_WINDOW = /^(?:codegen|open|cr|ff|wk|show-trace|show-report)$/i
383const CLAUDE_PKG = /^@anthropic-ai\/claude-code(?:@\S*)?$/i
384const CLAUDE_SUB = new Set(['plugin', 'mcp'])
385const PRINT = /^-[A-Za-z]*p|^--print(?:=|$)/
386
387function findIn(head: Word, name: string, args: Word[], shell: Shell, windowApps: readonly string[], env: Env, depth: number): Found[] {
388  const found: Found[] = []
389  const win = (what: string, fix: string) => found.push({ group: 'window', what, fix })
390  const snd = (what: string, fix: string) => found.push({ group: 'sound', what, fix })
391
392  const inner = innerOf(name, args)
393  if (inner && depth < 3) return classifyIn(inner.command, inner.shell, windowApps, env, depth + 1)
394
395  if (windowApps.includes(name)) win(`${name} は窓のアプリ (設定 windowApps)`, '窓のアプリは自分で起動しないでください。確かめは自動テストか窓の出ないやり方にして、実画面は依頼者に頼んでください')
396  const isInfo = hasFlag(args, INFO)
397
398  if (name === 'start-process' || name === 'saps' || (name === 'start' && shell === 'powershell')) {
399    if (!hasHidden(args)) win('Start-Process に -WindowStyle Hidden が無い', '-WindowStyle Hidden を付けてください (結果は -RedirectStandardOutput のファイルや -PassThru の終了コードで読む)')
400  } else if (name === 'start') {
401    win('start で新しい窓を開く', 'start / cmd /c start は新しい窓を開きます。start を付けずにそのまま走らせて結果を読むか、PowerShell の Start-Process -WindowStyle Hidden にしてください')
402  }
403  if (name === 'invoke-item' || (name === 'ii' && shell === 'powershell')) win('Invoke-Item でファイルを開く', WINDOW_FIX)
404  if (name === 'explorer') win('explorer で窓を開く', WINDOW_FIX)
405  if ((name === 'code' || name === 'code-insiders') && !hasFlag(args, CODE_INFO)) win('code で VS Code の窓を開く', WINDOW_FIX)
406  if (BROWSERS.has(name) && !isInfo && !hasFlag(args, /^--?headless\b/i)) win(`${name} に --headless が無い`, '--headless=new を付けてください (firefox は --headless)')
407  if (/^godot/.test(name) && !isInfo && !hasFlag(args, /^--headless$/i)) {
408    win('godot に --headless が無い', '--headless を付けてください (窓も音も出ません)')
409    if (!hasDummyAudio(args)) snd('godot に --audio-driver Dummy も --headless も無い', '--headless か --audio-driver Dummy を付けてください')
410  }
411  if (WINDOW_APPS.has(name)) win(`${name} で窓を開く`, WINDOW_FIX)
412  if (name === 'rundll32') {
413    if (hasFlag(args, /messagebeep/i)) snd('rundll32 の MessageBeep で音を鳴らす', SOUND_FIX)
414    if (hasFlag(args, /^(?:FileProtocolHandler|OpenURL|ShellExec_RunDLL)$/i)) win('rundll32 でファイルや URL を開く', WINDOW_FIX)
415  }
416  const all = [head, ...args]
417  const pw = all.findIndex(w => /(?:^|[\\/@])playwright(?:@\S*)?$/i.test(w.text))
418  if (pw >= 0 || all.some(w => /playwright/i.test(w.text))) {
419    if (hasFlag(all, /^--headed$/)) win('playwright を --headed で動かす', '--headed を外してください (headless で動かす)')
420    if (hasFlag(all, /^--ui$/)) win('playwright を --ui で動かす', '--ui を外してください (headless で動かし、結果はレポートのファイルで読む)')
421    if (pw >= 0 && PLAYWRIGHT_WINDOW.test(all[pw + 1]?.text ?? '')) win(`playwright ${all[pw + 1]?.text} で窓を開く`, WINDOW_FIX)
422  }
423  if (RUNNERS.has(name) && all.some(w => /\bheadless\s*[:=]\s*false\b/i.test(w.text))) win('ブラウザを headless: false で動かす', 'headless: true (Python は headless=True) にしてください')
424  if ((PYTHONS.test(name) || name === 'uv') && all.some(w => /\bwinsound\b/.test(w.text))) snd('python の winsound で音を鳴らす', SOUND_FIX)
425
426  if (PLAYERS.has(name) && !isInfo && !(name === 'aplay' && hasFlag(args, /^-[lL]$/))) {
427    snd(`${name} で音を鳴らす`, name === 'say' ? SOUND_FIX : `${name} で鳴らさず、ffprobe か ffmpeg -i <file> -f null - で中身を確かめてください`)
428  }
429  if ((name === 'mpv' || name === 'vlc' || name === 'cvlc') && !isInfo && !hasFlag(args, /^(?:--no-audio|--ao=null|--aout=dummy)$/i)) snd(`${name} に --no-audio が無い`, '--no-audio を付けてください')
430  if (!TEXTUAL.has(name)) {
431    // .NET の型の名前は New-Object の引数なら引用符の中も見る
432    const seen = all.map(w => (name === 'new-object' || name === 'add-type' ? w.text : w.bare)).join(' ')
433    if (/\[(?:system\.)?console\]::beep\b/i.test(seen)) snd('[console]::Beep で音を鳴らす', SOUND_FIX)
434    if (/\bmedia\.(?:soundplayer|systemsounds)\b/i.test(seen)) snd('SoundPlayer / SystemSounds で音を鳴らす', SOUND_FIX)
435    if (/speechsynthesizer|\bsapi\.spvoice\b/i.test(seen)) snd('読み上げ (SpeechSynthesizer / SAPI) で声を出す', SOUND_FIX)
436    if (/::playsound\b/i.test(seen)) snd('PlaySound で音を鳴らす', SOUND_FIX)
437    if (/\[(?:system\.)?diagnostics\.process\]::start\b/i.test(seen)) win('[Diagnostics.Process]::Start で起動する', WINDOW_FIX)
438  }
439
440  // npx @anthropic-ai/claude-code -p も claude と同じ
441  const pkg = (name === 'npx' || name === 'bunx' || name === 'pnpx') && args.find(w => !/^-/.test(w.text))
442  const claudeArgs = name === 'claude' ? args : pkg && CLAUDE_PKG.test(pkg.text) ? args.slice(args.indexOf(pkg) + 1) : undefined
443  if (claudeArgs && !hasFlag(claudeArgs, INFO) && !CLAUDE_SUB.has(claudeArgs[0]?.text ?? '') && hasFlag(claudeArgs, PRINT) && !isIsolated(env)) {
444    found.push({ group: 'headless', what: 'claude -p を隔離なしで走らせる', fix: CLAUDE_FIX })
445  }
446  return found
447}
448
449/** 区切りを頭から順に見る。export X=… や $env:X = … は後ろの区切りにも効く */
450function classifyIn(command: string, shell: Shell, windowApps: readonly string[], outer: Env, depth: number): Found[] {
451  let env = outer
452  const found: Found[] = []
453  for (const ws of parse(command, shell)) {
454    const r = inSegment(ws, shell, windowApps, env, depth)
455    env = r.env
456    found.push(...r.found)
457  }
458  return found
459}
460
461/**
462 * コマンドの中の、窓を出す・音を鳴らす・隔離なしで claude -p を走らせる所の一覧 (同じ物は 1 つ)。
463 * 文字列・コメント・ヒアドキュメントの中は数えない。windowApps は いつも窓とみなす名前
464 */
465export function classify(command: string, shell: 'bash' | 'powershell', windowApps: readonly string[] = []): Found[] {
466  const found = classifyIn(command, shell, windowApps, NO_ENV, 0)
467  return found.filter((f, k) => found.findIndex(x => x.what === f.what) === k)
468}
469
470// ── 記録の形 (store の 1 キー。知らない欄を残して書く)
471
472export const EMPTY: RunSaved = { off: { window: false, sound: false, headless: false }, counts: { window: 0, sound: 0, headless: 0 }, log: [] }
473const ACTIONS: readonly RunAction[] = ['blocked', 'asked-yes', 'asked-no', 'told']
474
475const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
476const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0)
477
478/** この版が描ける記録の 1 件か (知らない action・group の行は描かないが、store には残す) */
479export const isEntry = (v: unknown): v is RunEntry =>
480  isObj(v) &&
481  typeof v.at === 'number' &&
482  typeof v.command === 'string' &&
483  ACTIONS.includes(v.action as RunAction) &&
484  Array.isArray(v.groups) &&
485  v.groups.length > 0 &&
486  v.groups.every(g => GROUPS.includes(g as RunGroup))
487
488/** 表に出す記録。この版が描ける行だけ、新しい LOG_MAX 件 */
489export const shownLog = (s: RunSaved) => s.log.filter(isEntry).slice(-LOG_MAX)
490
491/**
492 * store の生の値を読む。読めない所は既定にする。記録の行は物ならそのまま持つ
493 * (知らない欄・知らない action の行も書き戻す。描く時に shownLog で濾す)
494 */
495export function fromRaw(raw: unknown): RunSaved {
496  const o = isObj(raw) ? raw : {}
497  const off = isObj(o.off) ? o.off : {}
498  const counts = isObj(o.counts) ? o.counts : {}
499  return {
500    off: { window: off.window === true, sound: off.sound === true, headless: off.headless === true },
501    counts: { window: num(counts.window), sound: num(counts.sound), headless: num(counts.headless) },
502    log: Array.isArray(o.log) ? (o.log.filter(isObj) as RunEntry[]) : [],
503  }
504}
505
506/** 書き戻してよい形か (無い・この形の物)。違えば書かない (書くと記録を消してしまう) */
507export const isWritable = (raw: unknown) =>
508  raw === undefined ||
509  (isObj(raw) && (raw.off === undefined || isObj(raw.off)) && (raw.counts === undefined || isObj(raw.counts)) && (raw.log === undefined || Array.isArray(raw.log)))
510
511/** raw のうち known に無い欄 (この版が知らない、新しい版が書いた物)。物でなければ空 */
512export function unknownOf(raw: unknown, known: Iterable<string>): Record<string, unknown> {
513  if (!isObj(raw)) return {}
514  const k = new Set(known)
515  return Object.fromEntries(Object.entries(raw).filter(([key]) => !k.has(key)))
516}
517
518/** store に書く形。書く直前に読んだ raw の、この版の知らない欄 (入れ子の off・counts の中も) を残して mine を重ねる */
519export function withUnknown(raw: unknown, mine: RunSaved): Record<string, unknown> {
520  const o = isObj(raw) ? raw : {}
521  return {
522    ...unknownOf(o, Object.keys(EMPTY)),
523    ...mine,
524    off: { ...unknownOf(o.off, GROUPS), ...mine.off },
525    counts: { ...unknownOf(o.counts, GROUPS), ...mine.counts },
526  }
527}
528
529/** 記録に 1 件足す (数も足す) */
530export function addEntry(s: RunSaved, entry: RunEntry): RunSaved {
531  const counts = { ...s.counts }
532  for (const g of entry.groups) counts[g] += 1
533  return { ...s, counts, log: [...s.log, entry].slice(-LOG_MAX) }
534}
535
536/** コマンドの頭 (記録とペイン用。制御文字を落とす) */
537export const headOf = (command: string) => short(clean(command.trim().split('\n')[0] ?? ''), 80)
538
539// ── Claude と依頼者への文
540
541export const YES = '実行する'
542export const NO = 'やめる'
543
544const bullets = (found: readonly Found[]) => found.map(f => `・${f.what}: ${f.fix}`).join('\n')
545const groupsWord = (found: readonly Found[]) => [...new Set(found.map(f => GROUP_NAME[f.group]))].join('・')
546
547/** その場で聞く文 */
548export const askText = (found: readonly Found[], command: string) =>
549  `確かめのための実行で、${groupsWord(found)}が出そうです。\n${found.map(f => `・${f.what}`).join('\n')}\n\n実行しますか?\n\n${short(command.trim(), 300)}`
550
551/** 止めた時に Claude へ返す文 */
552export function denyText(found: readonly Found[], outcome: 'blocked' | 'no-ask' | 'asked-no', answer = '') {
553  const why =
554    outcome === 'asked-no'
555      ? `依頼者が実行しないと答えたので止めました${answer !== '' && answer !== NO ? ` (依頼者の返事:「${short(answer, 200)}」)` : ''}`
556      : outcome === 'no-ask'
557        ? '依頼者に聞けなかったので止めました'
558        : '実行の前に止めました'
559  return (
560    `run-guard: ${why} (実行していません)。依頼者は同じ PC でゲームをしていることがあり、窓と音を出さない・claude を隔離なしで走らせない約束です。\n` +
561    `${bullets(found)}\n` +
562    '直したやり方で、もう一度実行してください。窓や音がどうしても要る時は依頼者に聞き、/runguard でそのグループを切ってもらってください。'
563  )
564}
565
566/** 伝えるだけの時に、実行の結果の後ろに足す文 */
567export const noteText = (found: readonly Found[]) =>
568  `run-guard: 今の実行で${groupsWord(found)}が出たかもしれません。依頼者は同じ PC でゲームをしていることがあります。次からは次のやり方にしてください。\n${bullets(found)}`
569
570// ── ペイン
571
572export type Mode = '止める' | '聞く' | '伝えるだけ'
573export const MODES: readonly Mode[] = ['止める', '聞く', '伝えるだけ']
574export const modeOf = (raw: unknown): Mode => (MODES.includes(raw as Mode) ? (raw as Mode) : '止める')
575
576const MODE_WORD: Record<Mode, string> = { 止める: '見つけたら実行の前に止めます', 聞く: '見つけたら実行してよいか聞きます', 伝えるだけ: '見つけたら実行の後で Claude に伝えます' }
577const GROUP_COLOR = (g: RunGroup) => ({ window: C.blue, sound: C.yellow, headless: C.purple })[g]
578const GROUP_TERM = (g: RunGroup) => ({ window: 'suggestion', sound: 'warning', headless: 'claude' })[g]
579const ACTION: Record<RunAction, { word: string; color: () => string }> = {
580  blocked: { word: '止めた', color: () => C.green },
581  'asked-yes': { word: '聞いて通した', color: () => C.blue },
582  'asked-no': { word: '聞いて止めた', color: () => C.green },
583  told: { word: '伝えた', color: () => C.yellow },
584}
585
586/** 日本時間 (JST) の時:分 */
587export const clock = (ms: number) => {
588  const d = new Date(ms + 9 * 3_600_000)
589  return [d.getUTCHours(), d.getUTCMinutes()].map(n => String(n).padStart(2, '0')).join(':')
590}
591const groupsOf = (e: RunEntry) => e.groups.map(g => GROUP_NAME[g]).join('・')
592
593export type PaneInput = { saved: RunSaved; mode: Mode }
594
595/** ペインの SVG 1 枚。記録は新しい順に全部 (最大 50) */
596export function paneSvg(i: PaneInput) {
597  const { off, counts } = i.saved
598  const log = shownLog(i.saved)
599  const total = GROUPS.reduce((a, g) => a + counts[g], 0)
600  const allOff = GROUPS.every(g => off[g])
601  const accent = allOff ? C.dim : C.green
602  let s = panel(8, 8, 364, 52)
603  s += `<text x="20" y="32" font-size="15" font-weight="bold" fill="${accent}">${allOff ? '見張っていません' : '見張り中'}</text>`
604  s += `<text x="20" y="50" font-size="12" fill="${C.dim}">${MODE_WORD[i.mode]}</text>`
605  const y0 = 68
606  s += panel(8, y0, 364, GROUPS.length * 30 + 12)
607  GROUPS.forEach((g, n) => {
608    const y = y0 + 10 + n * 30
609    s += `<rect x="20" y="${y + 2}" width="4" height="18" rx="2" fill="${off[g] ? C.line : GROUP_COLOR(g)}"/>`
610    s += `<text x="32" y="${y + 16}" font-size="13" font-weight="bold" fill="${off[g] ? C.dim : C.text}">${esc(GROUP_NAME[g])}</text>`
611    s += `<text x="312" y="${y + 16}" font-size="12" text-anchor="end" fill="${off[g] ? C.dim : C.green}">${off[g] ? '切っています' : '見張り中'}</text>`
612    s += `<text x="360" y="${y + 16}" font-size="13" font-weight="bold" text-anchor="end" fill="${C.text}">${counts[g]} 回</text>`
613  })
614  const rows = [...log].reverse()
615  const y1 = y0 + GROUPS.length * 30 + 20
616  const h = Math.max(1, rows.length) * 36 + 36
617  s += panel(8, y1, 364, h)
618  s += `<text x="20" y="${y1 + 22}" font-size="13" font-weight="bold" fill="${C.text}">最近の記録</text>`
619  if (rows.length === 0) s += `<text x="20" y="${y1 + 50}" font-size="12" fill="${C.dim}">まだありません</text>`
620  rows.forEach((e, n) => {
621    const y = y1 + 36 + n * 36
622    const a = ACTION[e.action]
623    s += `<rect x="20" y="${y}" width="4" height="28" rx="2" fill="${GROUP_COLOR(e.groups[0] ?? 'window')}"/>`
624    s += `<text x="32" y="${y + 12}" font-size="12" font-weight="bold" fill="${C.text}">${esc(short(groupsOf(e), 16))}</text>`
625    s += `<text x="360" y="${y + 12}" font-size="11" text-anchor="end" fill="${a.color()}">${clock(e.at)} ${a.word}</text>`
626    s += `<text x="32" y="${y + 26}" font-size="11" fill="${C.dim}">${esc(short(e.command, 44))}</text>`
627  })
628  return frame(HEADER_H + y1 + h + 8, header('窓と音の見張り', `${total} 回`, accent) + `<g transform="translate(0 ${HEADER_H})">${s}</g>`)
629}
630
631export type Line = { text: string; color?: string; isDim?: boolean; isBold?: boolean }
632
633/** Svg の alt と terminal で使う文の行 */
634export function paneLines(i: PaneInput): Line[] {
635  const { off, counts } = i.saved
636  const log = shownLog(i.saved)
637  const allOff = GROUPS.every(g => off[g])
638  const lines: Line[] = [
639    { text: allOff ? '見張っていません' : '見張り中', isBold: true, color: allOff ? undefined : 'success' },
640    { text: MODE_WORD[i.mode], isDim: true },
641  ]
642  for (const g of GROUPS) lines.push({ text: `${GROUP_NAME[g]}: ${off[g] ? '切っています' : '見張り中'} ─ ${counts[g]} 回`, isDim: off[g] })
643  lines.push({ text: '最近の記録', isBold: true })
644  if (log.length === 0) lines.push({ text: 'まだありません', isDim: true })
645  for (const e of [...log].reverse()) lines.push({ text: `${clock(e.at)} ${groupsOf(e)} ${ACTION[e.action].word} ─ ${clean(e.command)}`, color: GROUP_TERM(e.groups[0] ?? 'window') })
646  return lines
647}
648
hooks/style.ts 315 lines
1// 全 mod で共通の見た目(色・見出し・動き)と小物。
2// 正本は shared/style.ts。各 mod の hooks/style.ts はその写しなので、直すのは正本だけにして
3// `node tools/copy_shared.mts` で写す(`--check` で写しのずれを見つける)。
4import type { EngineInterface, On } from 'claude-code'
5
6export type Palette = {
7  bg: string
8  face: string
9  line: string
10  text: string
11  dim: string
12  weak: string
13  blue: string
14  green: string
15  yellow: string
16  red: string
17  purple: string
18  orange: string
19}
20
21/** GitHub Dark 系(docs/mod-guide.md の 7 節) */
22export const DARK: Palette = {
23  bg: '#0d1117',
24  face: '#161b22',
25  line: '#30363d',
26  text: '#c9d1d9',
27  dim: '#8b949e',
28  weak: '#8b949e',
29  blue: '#58a6ff',
30  green: '#3fb950',
31  yellow: '#d29922',
32  red: '#f85149',
33  purple: '#bc8cff',
34  orange: '#f78166',
35}
36
37/** GitHub Light 系 */
38export const LIGHT: Palette = {
39  bg: '#ffffff',
40  face: '#f6f8fa',
41  line: '#d0d7de',
42  text: '#1f2328',
43  dim: '#656d76',
44  weak: '#656d76',
45  blue: '#0969da',
46  green: '#1a7f37',
47  yellow: '#9a6700',
48  red: '#cf222e',
49  purple: '#8250df',
50  orange: '#bc4c00',
51}
52
53// ponytail: 今の色はモジュールの変数 1 個(この mod の中だけ)。SVG を作る関数は C.bg などを呼ぶたびに読むので、
54// 引数を増やさずにテーマが変わる。描く途中で切り替わることは無い(JS は 1 本で走る)
55/** 今の色。setTheme で中身が入れ替わる(同じ物を指し続けるので、import した C のままでよい) */
56export const C: Palette = { ...DARK }
57let isLightNow = false
58
59export const isLight = () => isLightNow
60
61/** /config の theme の値が明るい系か('light'・'light-daltonized'・'light-ansi' など) */
62export const isLightTheme = (value: unknown) => typeof value === 'string' && value.startsWith('light')
63
64export function setTheme(light: boolean) {
65  isLightNow = light
66  Object.assign(C, light ? LIGHT : DARK)
67}
68
69/**
70 * 今の /config の theme を読んで色を合わせる。mod 自身の session.start の中で呼ぶ
71 * (同じイベントに matcher 無しのフックを 2 つ置くと読み込みを拒まれるので、ここでは on しない)。
72 * VS Code の配色は mod から読めないので、Claude Code の theme が light 系の時だけ明るくなる
73 */
74export async function readTheme($: EngineInterface) {
75  const rows = await $.config.list().catch(() => [])
76  setTheme(isLightTheme(rows.find(r => r.key === 'theme')?.value))
77}
78
79/** /config で theme が変わったら色を替えて描き直す。register の中で 1 回呼ぶ */
80export function followTheme(on: On) {
81  on('config.set', { key: 'theme' }, async ($, e, next) => {
82    const r = await next(e)
83    if (r.deny === undefined) {
84      setTheme(isLightTheme(r.value))
85      $.ui.invalidate('ui.render')
86    }
87    return r
88  })
89}
90
91// ---- 小物 ----
92
93/** 外から来る文字列の制御文字を落とす。エンジンは制御文字を含む Text の子や alt を拒み、ペインが閉じる */
94export const clean = (s: string) => s.replace(/[\t\n\r]+/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim()
95
96/** SVG の文字に入れる前のエスケープ(制御文字も落とす) */
97export const esc = (s: string) =>
98  clean(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
99
100// ---- 時間帯と季節 (日本時間 JST = UTC+9 固定。動いている環境のタイムゾーンには頼らない) ----
101
102export type DayPart = '朝' | '昼' | '夕' | '夜'
103export type Season = '春' | '夏' | '秋' | '冬'
104
105const jstDate = (now: number) => new Date(now + 9 * 3_600_000)
106
107/** 時間帯。朝 5-10 時 / 昼 10-16 時 / 夕 16-19 時 / 夜 19-5 時 */
108export const dayPart = (now: number): DayPart => {
109  const h = jstDate(now).getUTCHours()
110  return h >= 5 && h < 10 ? '朝' : h >= 10 && h < 16 ? '昼' : h >= 16 && h < 19 ? '夕' : '夜'
111}
112
113/** 季節。春 3-5 月 / 夏 6-8 月 / 秋 9-11 月 / 冬 12-2 月 */
114export const season = (now: number): Season => {
115  const m = jstDate(now).getUTCMonth() + 1
116  return m >= 3 && m <= 5 ? '春' : m >= 6 && m <= 8 ? '夏' : m >= 9 && m <= 11 ? '秋' : '冬'
117}
118
119/** n 文字を超えたら … で切る */
120export const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(0, n - 1))}…` : s)
121
122// ---- 記録を守る(docs/mod-guide.md の 9 節) ----
123
124/** JST の YYYY-MM-DD */
125export const jstDay = (now: number) => jstDate(now).toISOString().slice(0, 10)
126
127export type Backup = { day: string; value: unknown }
128
129// ponytail: $.fs に消す手が無いので、曜日ごとの 7 つの枠を上書きして 7 日分だけ残す。もっと残すなら枠を日付にして process で消す
130const backupPath = (dir: string, plugin: string, key: string, wd: number) => `${dir.replace(/[\\/]+$/, '')}/${plugin}/${key}-${wd}.json`
131
132/**
133 * $.store の key の値を、その日はじめて呼ばれた時だけ dir に控える(曜日ごとの枠で 7 日分)。
134 * dir が空なら何もしない(公開の既定は空。入れた人が backupDir を設定する)。書いたら true
135 */
136export async function backupDaily($: EngineInterface, dir: string, key: string, value: unknown, now: number): Promise<boolean> {
137  if (!dir.trim() || value === undefined) return false
138  const day = jstDay(now)
139  const path = backupPath(dir, $.plugin.name, key, jstDate(now).getUTCDay())
140  // 壊れた枠(途中まで・null)は無いのと同じに扱って上書きする
141  const old = await $.fs.read(path).then(t => parseBackup(t)?.day ?? '', () => '')
142  if (old === day) return false
143  await $.fs.write(path, JSON.stringify({ day, value } satisfies Backup))
144  return true
145}
146
147/** 控えの 1 枠を読む。壊れていれば undefined */
148export function parseBackup(text: string): Backup | undefined {
149  try {
150    const b = JSON.parse(text) as Partial<Backup> | null
151    return b && typeof b.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(b.day) && b.value !== undefined ? (b as Backup) : undefined
152  } catch {
153    return undefined
154  }
155}
156
157/** 控えの一覧。新しい日から順。読めない枠・壊れた枠は飛ばす */
158export async function listBackups($: EngineInterface, dir: string, key: string): Promise<Backup[]> {
159  if (!dir.trim()) return []
160  const all = await Promise.all(
161    [0, 1, 2, 3, 4, 5, 6].map(wd =>
162      $.fs.read(backupPath(dir, $.plugin.name, key, wd)).then(parseBackup, () => undefined),
163    ),
164  )
165  return all.filter((b): b is Backup => b !== undefined).sort((a, b) => b.day.localeCompare(a.day))
166}
167
168let loadedPrint: string | undefined
169let staleAt = 0
170let isStaleNow = false
171let toldStale = false
172
173const normPath = (p: string) => p.replace(/[\\/]+/g, '/').replace(/\/$/, '').toLowerCase()
174
175/** FNV-1a 32bit。中身が変わったかを見るだけ */
176function fnv(s: string) {
177  let h = 0x811c9dc5
178  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
179  return (h >>> 0).toString(16)
180}
181
182/**
183 * 今ディスクにある、この mod の指紋。plugin.json と hooks の .ts / .tsx の中身から作る
184 * (版の数字を上げずに中身だけ変えた時も変わる)。plugin.json が読めなければ undefined
185 */
186async function diskPrint($: EngineInterface): Promise<string | undefined> {
187  const root = $.plugin.root
188  let manifest: string | undefined
189  for (const p of [`${root}/.claude-plugin/plugin.json`, `${root}/plugin.json`]) {
190    manifest = await $.fs.read(p).then(t => t, () => undefined)
191    if (manifest !== undefined) break
192  }
193  if (manifest === undefined) return undefined
194  const names = await $.fs.list(`${root}/hooks`).then(
195    es => es.filter(e => e.kind === 'file' && /\.tsx?$/.test(e.name)).map(e => e.name).sort(),
196    () => [] as string[],
197  )
198  const parts = await Promise.all(names.map(n => $.fs.read(`${root}/hooks/${n}`).then(t => `${n}\n${t}`, () => n)))
199  return fnv([manifest, ...parts].join('\0'))
200}
201
202/**
203 * マーケットプレイスから入れた形(<plugins>/cache/<market>/<name>/<版>)なら、installed_plugins.json に
204 * 今入っている場所が自分と違うか。違えば新しい版が入っている。分からなければ false
205 */
206async function replacedInCache($: EngineInterface): Promise<boolean> {
207  const root = normPath($.plugin.root)
208  const at = root.lastIndexOf('/plugins/cache/')
209  if (at < 0) return false
210  const raw = await $.fs.read(`${$.plugin.root.slice(0, at + '/plugins'.length)}/installed_plugins.json`).then(t => t, () => '')
211  try {
212    const plugins = (JSON.parse(raw) as { plugins?: Record<string, { installPath?: unknown }[]> }).plugins ?? {}
213    const mine = Object.entries(plugins)
214      .filter(([k]) => k.startsWith(`${$.plugin.name}@`))
215      .flatMap(([, v]) => (Array.isArray(v) ? v : []))
216      .map(e => (typeof e.installPath === 'string' ? normPath(e.installPath) : ''))
217    return mine.length > 0 && !mine.includes(root)
218  } catch {
219    return false
220  }
221}
222
223/** 読み込んだ時の指紋を覚える。mod の session.start で 1 回呼ぶ */
224export async function rememberVersion($: EngineInterface) {
225  loadedPrint ??= await diskPrint($)
226}
227
228/**
229 * 新しい版が入ったのに、このセッションはまだ古い版で動いているか。
230 * フォルダから読ませている時は中身の指紋が読み込んだ時と違えば、マーケットプレイスから入れた時は
231 * 入っている場所が自分でなくなれば true。読み直すのは 60 秒に 1 回まで。分からない時は false
232 */
233export async function isStale($: EngineInterface, now: number): Promise<boolean> {
234  if (isStaleNow || now - staleAt < 60_000) return isStaleNow
235  staleAt = now
236  if (await replacedInCache($)) return (isStaleNow = true)
237  if (loadedPrint === undefined) return false
238  const p = await diskPrint($)
239  isStaleNow = p !== undefined && p !== loadedPrint
240  return isStaleNow
241}
242
243/**
244 * $.store に書いてよいか。古い版のままなら書かず、1 回だけトーストで開き直しを頼む
245 * (古い版は新しい版の欄を知らないので、書くと消してしまう)
246 */
247export async function mayWrite($: EngineInterface, now: number): Promise<boolean> {
248  if (!(await isStale($, now))) return true
249  if (!toldStale) {
250    toldStale = true
251    $.ui.toast(`${$.plugin.name}: 新しい版が入りました。VS Code を開き直すまで記録は書きません`)
252  }
253  return false
254}
255
256// ---- SVG の部品 ----
257
258export const W = 380
259export const HEADER_H = 44
260
261/** SVG 全体の枠。地を塗って body を入れる。幅は W にそろえる */
262export const frame = (h: number, body: string, w = W) =>
263  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" font-family="sans-serif">` +
264  `<rect width="${w}" height="${h}" rx="10" fill="${C.bg}"/>${body}</svg>`
265
266/** 面(カード)。角丸 8、線 1 */
267export const panel = (x: number, y: number, w: number, h: number, fill = C.face) =>
268  `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="${fill}" stroke="${C.line}"/>`
269
270/**
271 * 共通の見出し帯(高さ HEADER_H)。左に色の縦線、左に題、右に添え書き。
272 * 下の細い線が左から伸びる
273 */
274export function header(title: string, sub = '', accent = C.blue, y = 0, w = W) {
275  const pad = 14
276  return [
277    `<rect x="0" y="${y}" width="${w}" height="${HEADER_H}" rx="10" fill="${C.face}"/>`,
278    `<rect x="0" y="${y + 8}" width="4" height="${HEADER_H - 16}" rx="2" fill="${accent}"/>`,
279    `<text x="${pad}" y="${y + 28}" font-size="16" font-weight="bold" fill="${C.text}">${esc(title)}</text>`,
280    sub === '' ? '' : `<text x="${w - pad}" y="${y + 28}" font-size="12" fill="${C.dim}" text-anchor="end">${esc(sub)}</text>`,
281    growBar(pad, y + HEADER_H - 3, w - pad * 2, 2, accent, 0, 0.35),
282  ].join('')
283}
284
285/** 横に伸びる棒。幅 0 から w まで伸びて止まる(shots.mts は終わりの値で撮る) */
286export const growBar = (x: number, y: number, w: number, h: number, fill: string, delay = 0, opacity = 1) =>
287  `<rect x="${x}" y="${y}" width="${Math.max(0, w)}" height="${h}" rx="${Math.min(3, h / 2)}" fill="${fill}"${opacity === 1 ? '' : ` opacity="${opacity}"`}>` +
288  `<animate attributeName="width" from="0" to="${Math.max(0, w)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
289
290/** 下から伸びる縦の棒(下端 bottom・高さ h) */
291export const growColumn = (x: number, bottom: number, w: number, h: number, fill: string, delay = 0) =>
292  `<rect x="${x}" y="${bottom - h}" width="${w}" height="${Math.max(0, h)}" rx="${Math.min(3, w / 2)}" fill="${fill}">` +
293  `<animate attributeName="y" from="${bottom}" to="${bottom - h}" begin="${delay}s" dur="0.6s" fill="freeze"/>` +
294  `<animate attributeName="height" from="0" to="${Math.max(0, h)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
295
296/** ふわっと出る。中身は SVG の断片 */
297export const fadeIn = (body: string, delay = 0) =>
298  `<g opacity="1"><animate attributeName="opacity" from="0" to="1" begin="${delay}s" dur="0.4s" fill="freeze"/>${body}</g>`
299
300/**
301 * 0 から value まで数字が増えて止まる。コマ送りの文字を重ね、最後のコマだけが残る。
302 * attrs は <text> に付ける属性(x・y・font-size・fill など)。format で桁区切りや単位を付ける
303 */
304export function countUp(value: number, attrs: string, format: (n: number) => string = n => String(n), delay = 0) {
305  const STEPS = 8
306  const d = 0.08
307  const last = `<text ${attrs} opacity="0">${esc(format(value))}<animate attributeName="opacity" from="0" to="1" begin="${(delay + (STEPS - 1) * d).toFixed(2)}s" dur="0.01s" fill="freeze"/></text>`
308  if (!Number.isFinite(value) || value === 0) return `<text ${attrs}>${esc(format(value))}</text>`
309  const frames = Array.from({ length: STEPS - 1 }, (_, k) => {
310    const n = Number.isInteger(value) ? Math.round((value * k) / (STEPS - 1)) : (value * k) / (STEPS - 1)
311    return `<text ${attrs} opacity="0">${esc(format(n))}<animate attributeName="opacity" values="1;1" begin="${(delay + k * d).toFixed(2)}s" dur="${d}s"/></text>`
312  })
313  return frames.join('') + last
314}
315
types/index.d.ts 34 lines
1/** 見張る物の組。window は窓のあるアプリの起動、sound は音、headless は隔離なしの claude -p */
2export type RunGroup = 'window' | 'sound' | 'headless'
3
4/** blocked は止めた (聞けなかった時も)、asked-yes / asked-no は聞いて通した / 止めた、told は実行の後で Claude に伝えた */
5export type RunAction = 'blocked' | 'asked-yes' | 'asked-no' | 'told'
6
7/** 記録の 1 件。古い順、最大 50 */
8export type RunEntry = {
9  at: number
10  /** 見つけた組 (1 つ以上) */
11  groups: RunGroup[]
12  /** コマンドの頭 80 字 */
13  command: string
14  action: RunAction
15}
16
17/** $.store の 'save' の形 (知らない欄は残して書く) */
18export type RunSaved = {
19  /** true の組は見張らない */
20  off: Record<RunGroup, boolean>
21  /** 組ごとの見つけた数 (ずっと足していく) */
22  counts: Record<RunGroup, number>
23  log: RunEntry[]
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    'run-guard': {
29      /** store の 'save' を読んだ物 */
30      saved: RunSaved
31    }
32  }
33}
34