Notifies you when 1Password blocks a git commit or push, with a one-press retry

Personal dotfiles for sharing setups and configs between machines.
Feel free to steal or use as you like.. Let me know if I'm missing any good tricks!
hooks/register.tsx 130 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SignBlock } from '../types'
5
6const block = atom({ plugin: 'sign-wait', key: 'block' } as const, null)
7
8// Commit signing (op-ssh-sign) and SSH push auth both go through the 1Password agent.
9const AGENT_FAILED = /1Password:|sign_and_send_pubkey: signing failed|communication with agent failed/
10const GIT_WRITE = /\bgit\s+(commit|push|rebase|cherry-pick|merge|tag)\b/
11const RENOTIFY_MS = 10 * 60_000
12
13let lastNotifiedAt = 0
14
15// Claude often runs `cd other/repo && git commit`; follow it there.
16async function repoDir($: EngineInterface, command: string, fallback: string) {
17 const m = command.match(/(?:^|&&|;)\s*cd\s+("[^"]+"|'[^']+'|[^\s;&]+)/) ?? command.match(/\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/)
18 if (!m?.[1]) return fallback
19 let p = m[1].replace(/^['"]|['"]$/g, '')
20 if (p.startsWith('~')) p = ((await $.env.get('HOME')) ?? '') + p.slice(1)
21 return p.startsWith('/') ? p : `${fallback}/${p}`
22}
23
24function describe(b: SignBlock) {
25 return b.kind === 'unsigned'
26 ? `${b.unsigned?.length} unsigned commit${b.unsigned?.length === 1 ? '' : 's'} held back from push`
27 : `1Password blocked a git ${b.kind}`
28}
29
30// ponytail: checks every commit not on any remote, not just the pushed refspec; parse the refspec if pushing other branches matters
31async function unsignedCommits($: EngineInterface, dir: string) {
32 const log = await $.process.run(['git', 'log', '--reverse', '--format=%h %G?', 'HEAD', '--not', '--remotes'], { cwd: dir })
33 if (log.exitCode !== 0) return []
34 return log.stdout.split('\n').filter(l => l.endsWith(' N')).map(l => l.slice(0, -2))
35}
36
37async function blocked($: EngineInterface, b: SignBlock) {
38 await update($, block, () => b)
39 $.ui.toast(`${describe(b)}. Unlock 1Password, then press Retry.`, { timeoutMs: 15_000 })
40 if (b.at - lastNotifiedAt < RENOTIFY_MS) return
41 lastNotifiedAt = b.at
42 // A macOS notification reaches you away from the terminal; the toast doesn't.
43 await $.process.run([
44 'osascript', '-e',
45 `display notification "${describe(b)}. Unlock 1Password, then Retry." with title "Claude Code: signing" sound name "Glass"`,
46 ]).catch(() => undefined)
47}
48
49// An unsigned-commits block stays until a push gets through, i.e. the commits were re-signed.
50async function cleared($: EngineInterface, isPush: boolean) {
51 const b = await read($, block)
52 if (!b || (b.kind === 'unsigned' && !isPush)) return
53 await update($, block, () => null)
54 $.ui.toast('1Password signing works again')
55}
56
57async function retry($: EngineInterface, b: SignBlock) {
58 await update($, block, () => null)
59 const text =
60 b.kind === 'unsigned'
61 ? `1Password is unlocked now. In ${b.dir}, re-sign the unsigned commits (${b.unsigned?.join(', ')}) with \`git rebase --exec 'git commit --amend --no-edit -n -S' ${b.unsigned?.[0]}^\`, check \`git log --format='%h %G?' HEAD --not --remotes\` shows no N, then push.`
62 : `1Password is unlocked now. Retry the git ${b.kind} that failed in ${b.dir}, then carry on.`
63 await $.prompt.submit({ text, asUser: true })
64}
65
66function ago(ms: number) {
67 const min = Math.round(ms / 60_000)
68 return min < 1 ? 'just now' : min < 60 ? `${min} min ago` : `${Math.round(min / 60)} h ago`
69}
70
71export const register: Register = on => {
72 // Fails open: if the unsigned check itself errors, the push goes ahead.
73 on('tool.call', async ($, e, next) => {
74 if (e.tool !== 'Bash' || !GIT_WRITE.test(e.command)) return next(e)
75
76 if (/\bgit\s+push\b/.test(e.command)) {
77 const dir = await repoDir($, e.command, await $.session.cwd())
78 const unsigned = await unsignedCommits($, dir)
79 // The check runs before the command, so an unsigned commit made earlier in it would slip past.
80 if (/commit\.gpgsign=false|--no-gpg-sign/.test(e.command)) {
81 return { deny: 'sign-wait: commit unsigned and push in separate commands, so the push can be checked for unsigned commits.' }
82 }
83 if (unsigned.length > 0) {
84 await blocked($, { dir, kind: 'unsigned', at: await $.clock.now(), unsigned })
85 return {
86 deny: `sign-wait: push held back, ${unsigned.length} commit(s) are unsigned (${unsigned.join(', ')}). The user has been notified and will press Retry to re-sign and push once 1Password is unlocked. Do not push them unsigned; carry on with other work.`,
87 }
88 }
89 }
90
91 const ran = await next(e)
92
93 if (ran.isError === true && AGENT_FAILED.test(ran.text ?? '')) {
94 const dir = await repoDir($, e.command, await $.session.cwd())
95 const kind = /\bgit\s+push\b/.test(e.command) && !/\bgit\s+commit\b/.test(e.command) ? 'push' : 'commit'
96 await blocked($, { dir, kind, at: await $.clock.now() })
97 return {
98 ...ran,
99 context: [
100 ...(ran.context ?? []),
101 "sign-wait: the 1Password agent is locked or away and the user has been notified. Commit unsigned to keep going (`git -c commit.gpgsign=false commit ...`); pushes are held back until those commits are re-signed.",
102 ],
103 }
104 }
105 if (ran.isError !== true && ran.deny === undefined) await cleared($, /\bgit\s+push\b/.test(e.command))
106 return ran
107 }).catch(($, e, next) => next(e))
108
109 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
110 const b = await read($, block)
111 if (!b || e.props.hasSurvey) return next(e)
112 const { Box, Button, Text } = $.ui.resolve(e)
113 const now = await $.clock.now()
114 const below = await next(e)
115 return (
116 <Box flexDirection="column">
117 <Box>
118 <Text color="yellow">🔒 {describe(b)} </Text>
119 <Text dimColor>
120 in {b.dir.split('/').pop()} · {ago(now - b.at)}{' '}
121 </Text>
122 <Button key="sign-retry" label="Retry" variant="primary" onPress={() => retry($, b)} />
123 <Button key="sign-dismiss" label="Dismiss" role="dismiss" onPress={() => update($, block, () => null)} />
124 </Box>
125 {below}
126 </Box>
127 )
128 })
129}
130types/index.d.ts 14 lines1export type SignBlock = {
2 dir: string
3 kind: 'commit' | 'push' | 'unsigned'
4 at: number
5 /** For `unsigned`: the commits a push would have sent unsigned, oldest first. */
6 unsigned?: string[]
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'sign-wait': { block: SignBlock | null }
12 }
13}
14