SLOPSHOPPER

ci-watch

Follows the GitHub Actions runs of a commit after git push: status line while they run, toast with the failed workflows and their link when they end

newguardcommandtoaststatusprocess
v0.1.0MITupdated 2026-10-05vincentlauriat/ModsTools/mods/ci-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ci-watch
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ci-watch ⎿ ci-watch: No push watched this session. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ci-watch

Follows the GitHub Actions runs of a commit right after you (or Claude) push it, so a red build does not go unnoticed.

What it does

  • After a successful main-agent Bash git push (also behind rtk, env, VAR=x, cd x && or git -C x), it reads the branch and HEAD sha of that folder and the push remote (git push <remote>, else the branch's remote, else origin), then polls gh run list -R owner/repo --commit <sha> --json … every 30 s.
  • All of that runs deferred: the push result is never delayed.
  • Status line while the runs go: CI ⏳ 2 running, then CI ✓ 3 passed or CI ✗ 1 failed.
  • When every run is completed: a toast (CI ✓ all 3 passed on main, or CI ✗ failed on main: Lint, Test — <first failed run URL>). The final status line stays 10 minutes, then clears.
  • Runs can take a few seconds to show up: it keeps asking for up to 2 minutes. If no run appears, it gives up silently (no workflow).
  • It stops after 45 minutes at most. There is one watch per pushed sha: a push of another sha replaces it, and the same sha pushed again keeps it.
  • Dry runs (--dry-run), deletes (--delete, :branch), backgrounded or failed pushes and subagent pushes are ignored.
  • When gh is missing or not authenticated, or the remote is not on github.com, nothing is shown. /ci-watch says why.
  • Success means success, neutral or skipped. Any other conclusion counts as failed.

Commands

CommandEffect
/ci-watchShows the watched commit and its runs with their URLs, or why nothing is watched
/ci-watch stopStops the current watch and clears the status line
/ci-watch offStops following pushes (kept across sessions)
/ci-watch onFollows pushes again

Install

claude --plugin-dir /path/to/ModsTools/mods/ci-watch

Limits

  • The watched sha is the folder's HEAD after the push. A push of another ref (git push origin other:main) is followed under HEAD's sha.
  • A cd made in an earlier Bash call is not known. The folder is the session folder plus the cd/-C of the push command itself.
  • Only github.com remotes (https, ssh, scp-like). GitHub Enterprise hosts are not followed.
  • "All completed" means all runs listed at that moment. A workflow that starts later (for example via workflow_run) is not waited for.
  • A hot reload of the mod (or a new session) drops a watch in progress: the timers go with the old module.

Develop

claude plugin validate mods/ci-watch
claude plugin test mods/ci-watch   # 18 tests
Source 2 files
hooks/register.ts 214 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { RUN_FIELDS, allDone, finalToast, githubRepo, isUrl, parsePush, parseRuns, resolveDir, runLine, shortSha, statusLine } from './ci'
4import type { Push, Run } from './ci'
5
6const POLL_MS = 30_000
7const APPEAR_MS = 2 * 60_000
8const MAX_MS = 45 * 60_000
9const KEEP_MS = 10 * 60_000
10const GH_MS = 20_000
11const GIT_MS = 10_000
12
13type Timer = { cancel: () => void }
14
15type Watch = {
16  sha: string
17  branch: string
18  repo: string
19  dir: string
20  startedAt: number
21  runs: Run[]
22  phase: 'waiting' | 'running' | 'done' | 'no workflow' | 'stopped' | 'timed out'
23  timer: Timer | null
24  clear: Timer | null
25  busy: boolean
26}
27
28type Ctx = {
29  watch: Watch | null
30  // Bumped by every push seen: a start that is no longer the latest drops its work.
31  seq: number
32  // Why nothing is watched, for /ci-watch.
33  reason: string | null
34}
35
36type Ran = { exitCode: number; stdout: string } | null
37
38async function run($: EngineInterface, argv: string[], cwd: string, timeoutMs: number): Promise<Ran> {
39  try {
40    const ran = await $.process.run(argv, { cwd, timeoutMs })
41    return { exitCode: ran.exitCode, stdout: ran.stdout }
42  } catch {
43    return null
44  }
45}
46
47async function git($: EngineInterface, dir: string, args: string[]): Promise<string | null> {
48  const ran = await run($, ['git', '-C', dir, ...args], dir, GIT_MS)
49
50  return ran !== null && ran.exitCode === 0 && ran.stdout.trim() !== '' ? ran.stdout.trim() : null
51}
52
53async function enabled($: EngineInterface): Promise<boolean> {
54  return (await $.store.get('enabled')) !== false
55}
56
57const active = (watch: Watch | null): watch is Watch => watch !== null && (watch.phase === 'waiting' || watch.phase === 'running')
58
59function halt(watch: Watch, phase: Watch['phase']) {
60  watch.timer?.cancel()
61  watch.timer = null
62  watch.phase = phase
63}
64
65function drop($: EngineInterface, ctx: Ctx) {
66  const watch = ctx.watch
67  if (watch === null) return
68  watch.clear?.cancel()
69  watch.clear = null
70  if (active(watch)) halt(watch, 'stopped')
71  $.ui.status(undefined)
72}
73
74async function tick($: EngineInterface, ctx: Ctx, watch: Watch) {
75  if (watch.busy || !active(watch)) return
76  watch.busy = true
77  try {
78    const now = await $.clock.now()
79    if (now - watch.startedAt >= MAX_MS) {
80      halt(watch, 'timed out')
81      if (ctx.watch === watch) {
82        $.ui.status(undefined)
83        $.ui.toast(`CI: stopped watching ${shortSha(watch.sha)} after 45 min`)
84      }
85      return
86    }
87    const ran = await run($, ['gh', 'run', 'list', '-R', watch.repo, '--commit', watch.sha, '--json', RUN_FIELDS], watch.dir, GH_MS)
88    // A push that replaced this watch, or a stop, while gh ran: its answer is stale.
89    if (ctx.watch !== watch || !active(watch)) return
90    const runs = ran !== null && ran.exitCode === 0 ? parseRuns(ran.stdout) : null
91    if (runs === null) ctx.reason = ran === null ? 'gh run list could not run or timed out' : 'gh run list failed'
92    if (runs === null || runs.length === 0) {
93      if (watch.runs.length === 0 && now - watch.startedAt >= APPEAR_MS) {
94        halt(watch, 'no workflow')
95        ctx.reason = `no workflow run for ${shortSha(watch.sha)} after 2 min`
96        $.ui.status(undefined)
97      }
98      return
99    }
100    ctx.reason = null
101    watch.runs = runs
102    watch.phase = 'running'
103    $.ui.status(statusLine(runs))
104    if (allDone(runs)) {
105      halt(watch, 'done')
106      $.ui.toast(finalToast(runs, watch.branch), { timeoutMs: 8000 })
107      watch.clear = $.clock.after(KEEP_MS, () => {
108        if (ctx.watch === watch) $.ui.status(undefined)
109      })
110    }
111  } finally {
112    watch.busy = false
113  }
114}
115
116// Everything a push sets off, deferred: git and gh are asked here, never while the tool result waits.
117async function startWatch($: EngineInterface, ctx: Ctx, push: Push, seq: number) {
118  const latest = () => seq === ctx.seq
119  if (!(await enabled($))) return
120  const dir = resolveDir(await $.session.cwd(), push.dirs)
121  const sha = await git($, dir, ['rev-parse', 'HEAD'])
122  const branch = (await git($, dir, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? 'HEAD'
123  if (!latest()) return
124  if (sha === null) {
125    ctx.reason = 'the pushed folder is not a git repository'
126    return
127  }
128  if (active(ctx.watch) && ctx.watch.sha === sha) return
129  const remote = push.remote ?? (await git($, dir, ['config', '--get', `branch.${branch}.remote`])) ?? 'origin'
130  const url = isUrl(remote) ? remote : await git($, dir, ['remote', 'get-url', '--push', remote])
131  const repo = url === null ? null : githubRepo(url)
132  if (!latest()) return
133  if (repo === null) {
134    ctx.reason = `remote ${remote} is not a GitHub repository`
135    return
136  }
137  const auth = await run($, ['gh', 'auth', 'status', '--hostname', 'github.com'], dir, GH_MS)
138  if (!latest()) return
139  if (auth === null) {
140    ctx.reason = 'gh is not installed (or did not answer)'
141    return
142  }
143  if (auth.exitCode !== 0) {
144    ctx.reason = 'gh is not authenticated (gh auth login)'
145    return
146  }
147  drop($, ctx)
148  ctx.reason = null
149  const watch: Watch = { sha, branch, repo, dir, startedAt: await $.clock.now(), runs: [], phase: 'waiting', timer: null, clear: null, busy: false }
150  ctx.watch = watch
151  watch.timer = $.clock.every(POLL_MS, () => void tick($, ctx, watch))
152  await tick($, ctx, watch)
153}
154
155function listing(ctx: Ctx, isOn: boolean): string {
156  const lines: string[] = []
157  if (!isOn) lines.push('ci-watch is off (/ci-watch on to resume).')
158  const watch = ctx.watch
159  if (watch !== null) {
160    lines.push(`${watch.repo} · ${watch.branch} @ ${shortSha(watch.sha)} · ${watch.phase}`)
161    lines.push(...watch.runs.map(runLine))
162    if (watch.runs.length === 0 && watch.phase === 'waiting') lines.push('Waiting for the first workflow run to appear…')
163  }
164  if (ctx.reason !== null) lines.push(`Note: ${ctx.reason}.`)
165  if (lines.length === 0) lines.push('No push watched this session.')
166
167  return lines.join('\n')
168}
169
170export const register: Register = on => {
171  const ctx: Ctx = { watch: null, seq: 0, reason: null }
172
173  on('session.start', async ($, e, next) => {
174    await $.command.register({ name: 'ci-watch', description: 'Show the GitHub Actions runs of the last push (stop | off | on)' })
175
176    return next(e)
177  })
178
179  on('tool.call', async ($, e, next) => {
180    const ran = await next(e)
181    if (e.agentId !== undefined || e.tool !== 'Bash' || ran.deny !== undefined || ran.isError === true) return ran
182    const result = ran.result as { backgroundTaskId?: unknown; interrupted?: unknown } | undefined
183    if (result?.backgroundTaskId !== undefined || result?.interrupted === true) return ran
184    const push = parsePush(e.command)
185    if (push === null) return ran
186    ctx.seq += 1
187    const seq = ctx.seq
188    $.clock.after(0, () => void startWatch($, ctx, push, seq))
189
190    return ran
191  })
192
193  on('command.run', { command: 'ci-watch' }, async ($, e) => {
194    const arg = e.args.trim()
195    if (arg === 'stop') {
196      const was = active(ctx.watch)
197      drop($, ctx)
198      return { text: was ? 'Stopped watching CI.' : 'Nothing was being watched.' }
199    }
200    if (arg === 'off') {
201      await $.store.set('enabled', false)
202      ctx.seq += 1
203      drop($, ctx)
204      return { text: 'ci-watch off: pushes are no longer followed.' }
205    }
206    if (arg === 'on') {
207      await $.store.set('enabled', true)
208      return { text: 'ci-watch on: the next push is followed.' }
209    }
210
211    return { text: listing(ctx, await enabled($)) }
212  })
213}
214
hooks/ci.ts 234 lines
1// Pure logic of ci-watch: reading `git push` command lines, GitHub remotes and `gh run list --json` output.
2
3export const RUN_FIELDS = 'databaseId,name,status,conclusion,url,workflowName'
4
5export type Run = {
6  databaseId: number
7  name: string
8  workflowName: string
9  status: string
10  conclusion: string
11  url: string
12}
13
14export type Push = {
15  // `cd` targets and `git -C` folders in the order the shell applies them, relative to the session folder.
16  dirs: string[]
17  // The remote named on the command line (`git push upstream …`), if any.
18  remote: string | null
19}
20
21type Token = { text: string; expands: boolean }
22
23const PREFIXES = new Set(['rtk', 'env', 'time', 'command', 'exec', 'nohup'])
24
25// Splits a shell command into simple commands (on unquoted && || ; | & and newlines) of words, quotes removed.
26// A word that holds an unquoted or double-quoted `$` or a backquote is marked: its value is the shell's, not ours.
27export function simpleCommands(command: string): Token[][] {
28  const commands: Token[][] = [[]]
29  let word: Token | null = null
30  let quote: '"' | "'" | null = null
31  let redirect = false
32  const push = () => {
33    if (word !== null && redirect) redirect = false
34    else if (word !== null) commands[commands.length - 1]!.push(word)
35    word = null
36  }
37  const add = (ch: string, expands = false) => {
38    word ??= { text: '', expands: false }
39    word.text += ch
40    if (expands) word.expands = true
41  }
42  for (let i = 0; i < command.length; i += 1) {
43    const ch = command[i]!
44    if (quote === "'") {
45      if (ch === "'") quote = null
46      else add(ch)
47    } else if (quote === '"') {
48      if (ch === '"') quote = null
49      else if (ch === '\\' && i + 1 < command.length) add(command[++i]!)
50      else add(ch, ch === '$' || ch === '`')
51    } else if (ch === "'" || ch === '"') {
52      quote = ch
53      word ??= { text: '', expands: false }
54    } else if (ch === '\\' && i + 1 < command.length) {
55      if (command[i + 1] === '\n') i += 1
56      else add(command[++i]!)
57    } else if (/\s/.test(ch) && ch !== '\n') {
58      push()
59    } else if (ch === '>' || ch === '<') {
60      if (word !== null && /^\d+$/.test(word.text)) word = null
61      else push()
62      while (command[i + 1] === '>' || command[i + 1] === '<') i += 1
63      if (command[i + 1] === '&') {
64        i += 1
65        while (/[\d-]/.test(command[i + 1] ?? '')) i += 1
66      } else redirect = true
67    } else if (ch === '\n' || ch === ';' || ch === '|' || ch === '&') {
68      push()
69      if (commands[commands.length - 1]!.length > 0) commands.push([])
70    } else {
71      add(ch, ch === '$' || ch === '`')
72    }
73  }
74  push()
75
76  return commands.filter(words => words.length > 0)
77}
78
79const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1)
80
81// Skips `VAR=x`, `rtk`, `env`, … in front of a simple command.
82function skipPrefixes(words: Token[]): number {
83  let i = 0
84  while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!.text) || PREFIXES.has(baseName(words[i]!.text)))) i += 1
85
86  return i
87}
88
89// Push options that take a separate value.
90const PUSH_VALUED = new Set(['--repo', '--receive-pack', '--exec', '-o', '--push-option'])
91
92// Reads a command line that runs `git push` (behind rtk, env assignments, `cd x &&`, `git -C x`).
93// Null when no push runs, or when it is a dry run or a delete, or a folder comes from a shell expansion.
94export function parsePush(command: string): Push | null {
95  const dirs: string[] = []
96  for (const words of simpleCommands(command)) {
97    let i = skipPrefixes(words)
98    const head = words[i]
99    if (head === undefined) continue
100    if (head.text === 'cd' || head.text === 'pushd') {
101      const target = words[i + 1]
102      if (target === undefined || target.expands || target.text.startsWith('~') || target.text === '-') dirs.push('\0')
103      else dirs.push(target.text)
104      continue
105    }
106    if (baseName(head.text) !== 'git') continue
107    i += 1
108    const local: string[] = []
109    let unresolvable = false
110    while (i < words.length && words[i]!.text.startsWith('-')) {
111      const flag = words[i]!.text
112      if (flag === '-C') {
113        const target = words[i + 1]
114        if (target === undefined || target.expands || target.text.startsWith('~')) unresolvable = true
115        else local.push(target.text)
116        i += 2
117      } else if (flag === '-c') i += 2
118      else i += 1
119    }
120    if (words[i]?.text !== 'push') continue
121    if (unresolvable || dirs.includes('\0')) return null
122    let remote: string | null = null
123    const args = words.slice(i + 1)
124    if (args.some(arg => ['--dry-run', '-n', '--delete', '-d', '--help', '-h'].includes(arg.text) || arg.text.startsWith(':'))) return null
125    for (let j = 0; j < args.length; j += 1) {
126      const text = args[j]!.text
127      if (PUSH_VALUED.has(text)) j += 1
128      else if (!text.startsWith('-')) {
129        remote = args[j]!.expands ? null : text
130        break
131      }
132    }
133
134    return { dirs: [...dirs, ...local], remote }
135  }
136
137  return null
138}
139
140// Applies `cd` targets to the session folder, as plain path arithmetic.
141export function resolveDir(cwd: string, dirs: string[]): string {
142  let parts = cwd.split('/').filter(Boolean)
143  for (const dir of dirs) {
144    if (dir.startsWith('/')) parts = []
145    for (const part of dir.split('/')) {
146      if (part === '' || part === '.') continue
147      if (part === '..') parts.pop()
148      else parts.push(part)
149    }
150  }
151
152  return `/${parts.join('/')}`
153}
154
155// `owner/repo` of a github.com remote URL (https, ssh or scp-like), else null.
156export function githubRepo(url: string): string | null {
157  const match = /^(?:(?:https?|ssh|git):\/\/(?:[^@/]+@)?github\.com(?::\d+)?\/|[^@\s]+@github\.com:)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(url.trim())
158
159  return match === null ? null : `${match[1]}/${match[2]}`
160}
161
162// A remote given on the command line may be a URL instead of a name.
163export const isUrl = (remote: string): boolean => /^[a-z]+:\/\//.test(remote) || /^[^/\s]+@[^:\s]+:/.test(remote)
164
165// Parses `gh run list --json …` output; null when it is not an array of runs.
166export function parseRuns(stdout: string): Run[] | null {
167  let raw: unknown
168  try {
169    raw = JSON.parse(stdout)
170  } catch {
171    return null
172  }
173  if (!Array.isArray(raw)) return null
174  const str = (x: unknown) => (typeof x === 'string' ? x : '')
175
176  return raw
177    .filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null && typeof (v as Record<string, unknown>).databaseId === 'number')
178    .map(v => ({
179      databaseId: v.databaseId as number,
180      name: str(v.name),
181      workflowName: str(v.workflowName),
182      status: str(v.status),
183      conclusion: str(v.conclusion),
184      url: str(v.url),
185    }))
186}
187
188const PASSED = ['success', 'neutral', 'skipped']
189
190export type Verdict = 'running' | 'passed' | 'failed'
191
192export function verdict(run: Run): Verdict {
193  if (run.status !== 'completed') return 'running'
194
195  return PASSED.includes(run.conclusion) ? 'passed' : 'failed'
196}
197
198export const allDone = (runs: Run[]): boolean => runs.length > 0 && runs.every(run => verdict(run) !== 'running')
199
200export const label = (run: Run): string => run.workflowName || run.name || `run ${run.databaseId}`
201
202// "CI ⏳ 2 running", "CI ✓ 3 passed", "CI ✗ 1 failed"; undefined with no run.
203export function statusLine(runs: Run[]): string | undefined {
204  if (runs.length === 0) return undefined
205  const count = (v: Verdict) => runs.filter(run => verdict(run) === v).length
206  const running = count('running')
207  const failed = count('failed')
208  if (running > 0) return failed > 0 ? `CI ⏳ ${running} running · ✗ ${failed} failed` : `CI ⏳ ${running} running`
209  if (failed > 0) return `CI ✗ ${failed} failed`
210
211  return `CI ✓ ${count('passed')} passed`
212}
213
214// The toast once every run is completed.
215export function finalToast(runs: Run[], branch: string): string {
216  const failed = runs.filter(run => verdict(run) === 'failed')
217  if (failed.length === 0) return `CI ✓ all ${runs.length} passed on ${branch}`
218  const names = [...new Set(failed.map(label))].join(', ')
219  const url = failed[0]!.url
220
221  return url === '' ? `CI ✗ failed on ${branch}: ${names}` : `CI ✗ failed on ${branch}: ${names} — ${url}`
222}
223
224const MARK: Record<Verdict, string> = { running: '⏳', passed: '✓', failed: '✗' }
225
226export function runLine(run: Run): string {
227  const state = run.status === 'completed' ? run.conclusion : run.status
228  const name = run.name !== '' && run.name !== run.workflowName ? `${label(run)} · ${run.name}` : label(run)
229
230  return `${MARK[verdict(run)]} ${name} (${state})${run.url === '' ? '' : ` ${run.url}`}`
231}
232
233export const shortSha = (sha: string) => sha.slice(0, 7)
234