git push などを止め、人が承認ボタンを押した 1 回だけ通す

English | 日本語
A marketplace of mods for Claude Code. A mod is a plugin that adds features to Claude Code through function hooks.
| Mod | What it does |
|---|---|
| branch-watch | Stops commits on a branch another session switched to, and lists each session's worktree and branch |
| push-gate | Holds git push and similar commands until a person approves |
| auto-worktree | Creates a worktree on the first prompt and starts the work there |
| session-diff | Shows the files this session edited and their diffs |
| pr-pane | Shows open pull requests and their CI results |
| side-question | /btw asks a side question without stopping the work |
| done-chime | Plays a sound when a long turn ends and when Claude waits for permission |
| ja-check | Tells you when a reply starts in English |
| usage-meter | Shows each turn's token counts and prompt cache rate |
claude plugin marketplace add yut0takagi/mods
claude plugin install branch-watch@yut0takagi-mods # one line per mod you want
Developed with Claude Code 2.1.289. Mods are an early access feature, so a Claude Code update may break them. The VS Code and Cursor extension draws no mod UI, so some mods are used differently there.
To change a mod, read CONTRIBUTING. Changes are listed in the CHANGELOG. Report security problems as described in SECURITY.
hooks/register.tsx 124 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { APPROVAL_MS, approvedText, denyText, entriesOf, gatedBy, rejectedText } from './gate'
5
6const pending = atom({ plugin: 'push-gate', key: 'pending' } as const, null)
7const approved = atom({ plugin: 'push-gate', key: 'approved' } as const, null)
8
9/** プロンプトは、今の処理が終わってから積む。コマンドの中から積むと、そのコマンドの終わりを待つことになるため。 */
10function submitLater($: EngineInterface, text: string) {
11 $.clock.after(1, () => void $.prompt.submit({ text }))
12}
13
14/** 承認を待っているコマンドを承認し、同じコマンドの実行を頼むプロンプトを積む。 */
15async function approve($: EngineInterface): Promise<string | undefined> {
16 const waiting = await read($, pending)
17
18 if (waiting === null) {
19 return undefined
20 }
21
22 const at = await $.clock.now()
23 await update($, approved, () => ({ command: waiting.command, at }))
24 await update($, pending, () => null)
25 submitLater($, approvedText(waiting.command))
26
27 return waiting.command
28}
29
30/** 承認を待っているコマンドを却下し、実行しないよう伝える。 */
31async function reject($: EngineInterface): Promise<string | undefined> {
32 const waiting = await read($, pending)
33
34 if (waiting === null) {
35 return undefined
36 }
37
38 await update($, pending, () => null)
39 submitLater($, rejectedText(waiting.command))
40
41 return waiting.command
42}
43
44export const register: Register = (on, options) => {
45 const entries = entriesOf(typeof options.commands === 'string' ? options.commands : 'git push, gh pr merge')
46
47 on('session.start', async ($, e, next) => {
48 await $.command.register({
49 name: 'push-gate',
50 description: '承認を待っているコマンドを見る。approve で承認、reject で却下',
51 argumentHint: '[approve|reject]',
52 })
53
54 return next(e)
55 })
56
57 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
58 const command = e.command.trim()
59 const entry = gatedBy(command, entries)
60
61 if (entry === undefined) {
62 return next(e)
63 }
64
65 const grant = await read($, approved)
66
67 if (grant !== null && grant.command === command && (await $.clock.now()) - grant.at < APPROVAL_MS) {
68 await update($, approved, () => null)
69
70 return next(e)
71 }
72
73 await update($, pending, () => ({ command, entry }))
74 $.ui.toast(`push-gate: ${entry} の承認を待っています`, { timeoutMs: 10_000 })
75
76 // 画面がないとボタンもトーストも出ない。分からないときは、ボタンとコマンドの両方を案内する
77 const surfaces = await $.session.surfaces().catch(() => undefined)
78
79 return { deny: denyText(entry, surfaces === undefined || surfaces.length > 0) }
80 })
81
82 on('command.run', { command: 'push-gate' }, async ($, e) => {
83 const action = e.args.trim()
84
85 if (action === 'approve' || action === 'reject') {
86 // 承認と却下は、人が入力したときだけ受け付ける(モデルやほかのプラグインからは通さない)
87 if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') {
88 return { text: 'push-gate: 承認と却下は、人が入力したときだけ受け付けます。' }
89 }
90
91 const command = action === 'approve' ? await approve($) : await reject($)
92
93 return { text: command === undefined ? '承認を待っているコマンドはありません。' : `${action === 'approve' ? '承認' : '却下'}しました: ${command}` }
94 }
95
96 const waiting = await read($, pending)
97
98 return { text: waiting === null ? '承認を待っているコマンドはありません。' : `承認待ち: ${waiting.command}` }
99 })
100
101 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
102 const waiting = await read($, pending)
103
104 if (waiting === null || e.props.hasSurvey) {
105 return next(e)
106 }
107
108 const { Box, Button, Text } = $.ui.resolve(e)
109
110 return (
111 <Box flexDirection="column">
112 <Text bold>⏸ push-gate: 承認待ち({waiting.entry})</Text>
113 <Text dimColor wrap="truncate-end">
114 {waiting.command}
115 </Text>
116 <Box gap={1}>
117 <Button key="approve" label="承認" variant="primary" hotkey="y" onPress={() => approve($)} />
118 <Button key="reject" label="却下" hotkey="n" onPress={() => reject($)} />
119 </Box>
120 </Box>
121 )
122 })
123}
124hooks/gate.ts 45 lines1/** 承認が要るコマンドの見分け方と、文言。$ に触れない部分。 */
2
3// 語と語の間に挟まってよいオプション(`git -C dir push`、`git --no-pager push` など)
4const OPTIONS = String.raw`(?:\s+-\S+(?:\s+\S+)?)*?`
5
6const escape = (word: string) => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
7
8/** 設定の文字列を項目に分ける。 */
9export const entriesOf = (setting: string) =>
10 setting
11 .split(',')
12 .map(entry => entry.trim().split(/\s+/).join(' '))
13 .filter(entry => entry !== '')
14
15/** 項目の語が順に並ぶコマンドに当たる正規表現。最初の語のあとにはオプションが挟まってよい。 */
16export function patternOf(entry: string): RegExp {
17 const [first = '', ...rest] = entry.split(' ').map(escape)
18 const tail = rest.map(word => String.raw`\s+${word}`).join('')
19
20 return new RegExp(String.raw`(?:^|[\s;&|(])${first}${rest.length > 0 ? OPTIONS : ''}${tail}(?=$|[\s;&|)])`)
21}
22
23/** コマンドが当たった項目。当たらなければ undefined。 */
24export const gatedBy = (command: string, entries: readonly string[]) =>
25 entries.find(entry => patternOf(entry).test(command))
26
27/** 承認の有効期限。承認したまま実行されなかったコマンドを、あとで素通りさせない。 */
28export const APPROVAL_MS = 10 * 60 * 1_000
29
30/** `hasButton` が false(VS Code 拡張機能のように画面がない)なら、出ないボタンには触れずコマンドだけを案内する。 */
31export const denyText = (entry: string, hasButton: boolean) =>
32 [
33 `push-gate: \`${entry}\` には人の承認が要るため、実行していません。`,
34 hasButton
35 ? 'ユーザーに承認を頼んで、このターンを終えてください(入力欄の上の「承認」ボタン、または /push-gate approve)。'
36 : 'ユーザーに、入力欄で /push-gate approve と打って承認するよう頼んで、このターンを終えてください(取りやめるなら /push-gate reject)。この画面には承認ボタンが出ません。',
37 '承認されると、同じコマンドの実行を頼むメッセージが届きます。',
38 ].join('\n')
39
40export const approvedText = (command: string) =>
41 ['push-gate: 承認しました。次のコマンドを、書き換えずにそのまま実行してください。', '', '```sh', command, '```'].join('\n')
42
43export const rejectedText = (command: string) =>
44 ['push-gate: 却下しました。次のコマンドは実行しないでください。', '', '```sh', command, '```'].join('\n')
45types/index.d.ts 21 lines1/** 止めて、承認を待っているコマンド。 */
2export type Pending = {
3 /** 実行されかけたコマンド(前後の空白を除いたもの)。 */
4 command: string
5 /** どの設定項目に当たったか(`git push` など)。 */
6 entry: string
7}
8
9/** 人が承認したコマンド。同じコマンドが 1 回通るまで、または期限まで有効。 */
10export type Approval = {
11 command: string
12 /** 承認した時刻(エポックからのミリ秒)。 */
13 at: number
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'push-gate': { pending: Pending | null; approved: Approval | null }
19 }
20}
21