Watches GitHub Actions after every git push; status line, toast, and a Fix it button on failure

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 113 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CiFailure } from '../types'
5
6type Run = { databaseId: number; name: string; status: string; conclusion: string }
7
8const failure = atom({ plugin: 'ci-watch', key: 'failure' } as const, null)
9const FAILED = new Set(['failure', 'timed_out', 'startup_failure'])
10
11let watch: { dir: string; sha: string; branch: string; polls: number; timer?: { cancel(): void } } | undefined
12
13// Claude often runs `cd other/repo && git push`; follow it there.
14async function repoDir($: EngineInterface, command: string, fallback: string) {
15 const m = command.match(/(?:^|&&|;)\s*cd\s+("[^"]+"|'[^']+'|[^\s;&]+)/) ?? command.match(/\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/)
16 if (!m?.[1]) return fallback
17 let p = m[1].replace(/^['"]|['"]$/g, '')
18 if (p.startsWith('~')) p = ((await $.env.get('HOME')) ?? '') + p.slice(1)
19 return p.startsWith('/') ? p : `${fallback}/${p}`
20}
21
22async function poll($: EngineInterface) {
23 const w = watch
24 if (!w) return
25 w.polls++
26 const res = await $.process.run(
27 ['gh', 'run', 'list', '--commit', w.sha, '--json', 'databaseId,name,status,conclusion', '-L', '50'],
28 { cwd: w.dir, timeoutMs: 15_000 },
29 ).catch(() => undefined)
30 if (w !== watch) return
31 const runs: Run[] = res?.exitCode === 0 ? JSON.parse(res.stdout) : []
32 const short = w.sha.slice(0, 7)
33
34 // Runs take a few seconds to appear; give up after ~2 min with none, or ~1 h overall.
35 if ((runs.length === 0 && w.polls > 6) || w.polls > 180) {
36 w.timer?.cancel()
37 watch = undefined
38 $.ui.status(runs.length === 0 ? undefined : `CI ? ${short} (stopped watching)`)
39 return
40 }
41 if (runs.length === 0) return
42
43 const done = runs.filter(r => r.status === 'completed')
44 const failed = done.filter(r => FAILED.has(r.conclusion))
45 if (done.length < runs.length) {
46 $.ui.status(`CI ⏳ ${done.length}/${runs.length}${failed.length ? ` · ${failed.length} ✗` : ''}`)
47 return
48 }
49
50 w.timer?.cancel()
51 watch = undefined
52 if (failed.length === 0) {
53 $.ui.status(`CI ✓ ${short}`)
54 $.ui.toast(`CI green on ${w.branch} (${runs.length} runs)`)
55 await update($, failure, () => null)
56 return
57 }
58 const names = failed.map(r => r.name)
59 $.ui.status(`CI ✗ ${names.join(', ')}`)
60 $.ui.toast(`CI failed on ${w.branch}: ${names.join(', ')}`, { timeoutMs: 10_000 })
61 const f: CiFailure = { branch: w.branch, sha: w.sha, names, runId: failed[0]!.databaseId }
62 await update($, failure, () => f)
63}
64
65async function start($: EngineInterface, dir: string) {
66 const sha = await $.process.run(['git', 'rev-parse', 'HEAD'], { cwd: dir })
67 const branch = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir })
68 if (sha.exitCode !== 0) return
69 watch?.timer?.cancel()
70 watch = { dir, sha: sha.stdout.trim(), branch: branch.stdout.trim(), polls: 0 }
71 watch.timer = $.clock.every(20_000, () => void poll($))
72 await update($, failure, () => null)
73 $.ui.status(`CI ⏳ ${watch.sha.slice(0, 7)}`)
74}
75
76async function fix($: EngineInterface, f: CiFailure) {
77 await update($, failure, () => null)
78 await $.prompt.submit({
79 text: `CI failed on ${f.branch} (${f.sha.slice(0, 7)}): ${f.names.join(', ')}. Read the failing logs with \`gh run view ${f.runId} --log-failed\`, fix the cause, then commit and push.`,
80 asUser: true,
81 })
82}
83
84export const register: Register = on => {
85 on('tool.call', async ($, e, next) => {
86 const ran = await next(e)
87 if (e.tool === 'Bash' && ran.isError !== true && ran.deny === undefined && /\bgit\s+push\b/.test(e.command)) {
88 await start($, await repoDir($, e.command, await $.session.cwd()))
89 }
90 return ran
91 }).catch(($, e, next) => next(e))
92
93 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
94 const f = await read($, failure)
95 if (!f || e.props.hasSurvey) return next(e)
96 const { Box, Button, Text } = $.ui.resolve(e)
97 const below = await next(e)
98 return (
99 <Box flexDirection="column">
100 <Box>
101 <Text color="red">CI ✗ </Text>
102 <Text>
103 {f.branch} {f.sha.slice(0, 7)}: {f.names.join(', ')}{' '}
104 </Text>
105 <Button key="ci-fix" label="Fix it" variant="primary" onPress={() => fix($, f)} />
106 <Button key="ci-dismiss" label="Dismiss" role="dismiss" onPress={() => update($, failure, () => null)} />
107 </Box>
108 {below}
109 </Box>
110 )
111 })
112}
113types/index.d.ts 8 lines1export type CiFailure = { branch: string; sha: string; names: string[]; runId: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'ci-watch': { failure: CiFailure | null }
6 }
7}
8