SLOPSHOPPER

ci-pipeline

A pane that shows where the current branch is in the CI/CD pipeline, from local dev to prod smoke tests

newpanecommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ci-pipeline
│ ┃ Pipeline ✕ › fix the failing auth test and add an audit log call │ ┃ feat/auth-refresh │ ┃ PR #undefined undefined ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Code ⏺ Update(src/auth.ts) │ ┃ ✓ Local dev ⎿ Added 2 lines, removed 1 line │ ┃ ✓ PR open #undefined ⏺ Bash(bun test) │ ┃ ✓ PR checks running no checks ⎿ 3 pass, 1 fail │ ┃ ◌ PR merged │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Staging │ ┃ · Deploying to Staging ✻ Worked for 42s · done 4:20 PM │ ┃ · Staging Deployed │ ┃ · Smoke Testing Staging › /pipeline │ ┃ · Staging Smoke Tested ⎿ ci-pipeline: Pipeline pane open, following the current branch. │ ┃ │ ┃ Production │ ┃ · Deploying to Prod │ ┃ · Prod Deployed │ ┃ · Smoke Testing Prod │ ┃ · Prod Smoke Tested │ ┃ │ ┃ Updated 1:53:20 AM r: Refresh │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ ci-pipeline: CI ◌ PR merged

Draws

Pane · Pipeline
feat/auth-refresh PR #undefined undefined Code ✓ Local dev ✓ PR open #undefined ✓ PR checks running no checks ◌ PR merged Staging · Deploying to Staging · Staging Deployed · Smoke Testing Staging · Staging Smoke Tested Production · Deploying to Prod · Prod Deployed · Smoke Testing Prod · Prod Smoke Tested Updated 1:53:20 AM r: Refresh
README

claude-mods

Two mods for Claude Code. They work in the terminal and in the Code tab of the Claude desktop app.

ModWhat it shows
ci-pipelineA pane with the CI/CD stage of the branch you work on
my-actionsA box above the prompt with the actions and questions the agent left for you

Early access. Mods use the Claude Code function-hooks API. This API changes between releases. I tested these mods on Claude Code 2.1.288.

Install

In Claude Code, add this repo as a plugin marketplace:

/plugin marketplace add alamine42/claude-mods

Then install the mods you want:

/plugin install ci-pipeline@claude-mods
/plugin install my-actions@claude-mods

To try a mod without installing it, clone this repo and start Claude Code with the mod folder:

claude --plugin-dir ./plugins/my-actions

ci-pipeline

<!-- TODO: screenshot -->

A pane that shows where the current branch is in 12 stages:

CodeStagingProduction
Local devDeploying to StagingDeploying to Prod
PR openStaging DeployedProd Deployed
PR checks runningSmoke Testing StagingSmoke Testing Prod
PR mergedStaging Smoke TestedProd Smoke Tested

Each stage has a mark: ✓ done, ● running, ✗ failed, ◌ the next stage it waits for, · not yet reached. The status line shows the current stage. A toast tells you when the stage changes.

Commands

  • /pipeline opens the pane.
  • /pipeline 123 follows PR #123.
  • /pipeline branch follows the current branch again.
  • In the pane, press r to refresh.

How it finds each stage

  • Local dev, PR open, PR checks and PR merged come from git and gh pr view.
  • The staging and prod stages come from the GitHub Actions jobs that ran on the merge commit. The mod matches the name workflow / job against a regex for each stage.

Options

OptionDefault
deployStaging`deploy.*stag\stag.*deploy`
smokeStaging`smoke.*stag\stag.*smoke`
deployProd`deploy.*prod\prod.*deploy`
smokeProd`smoke.*prod\prod.*smoke`
pollSeconds20

Change them in the Claude Code config menu, or under pluginConfigs.ci-pipeline in your settings.

Needs: git and the GitHub CLI, logged in.

Limits

  • Deploys that do not run in GitHub Actions on the merge commit do not show.
  • After you switch to main, the pane follows main. Use /pipeline <number> to keep a PR in view.

my-actions

<!-- TODO: screenshot -->

When you come back to a long session, the asks for you are easy to miss in the text. This mod lists them above the prompt, in two boxes:

  • To do (▸): a task only you can do, such as a check in the app or a test by hand. It shows the exact command when there is one.
  • To do, agent can do (↻, dim): a task the agent asked you to do but could do itself. Tell the agent to do it, or do it yourself.
  • Questions (?): a question the agent waits on. A request for your go-ahead ("Shall I push?") is a question too.

Each item has a Done button. When the box has focus, keys 1–9 press it. Clear empties one box. The boxes sit side by side when the window is wide enough. Long items wrap, and a long list scrolls. A task shows in one box only. The status line shows a count, for example Your move: 2 actions, 1 question.

How it works. When a reply ends, the mod sends the reply, your last message, and the open list to a small model. The model returns the new list. It adds new asks. It removes the asks that you answered or that the agent did. When you send a message, the mod also checks it at once and clears the questions it answers, so they do not wait for the reply to end. When the mod loads or a session resumes, it reads the last few messages.

Commands

  • /my-actions builds the list again from the whole session.
  • /my-actions clear empties the list.

Options

OptionDefault
modelhaiku

Cost and privacy. The mod makes one model call per reply, on your own Claude account. Each call sends the reply text and your last message to that model.

Limits

  • Asks inside subagent output show only when the main reply repeats them.
  • The model can miss an ask or add one that is not real. Use Done or /my-actions to correct the list.

License

MIT

Source 2 files
hooks/register.tsx 301 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mark, Pipeline, Stage } from '../types'
5
6const PANE = 'ci-pipeline'
7const pipeline = atom({ plugin: 'ci-pipeline', key: 'pipeline' } as const, null)
8const pinned = atom({ plugin: 'ci-pipeline', key: 'pinned' } as const, null)
9
10const NAMES = [
11  'Local dev',
12  'PR open',
13  'PR checks running',
14  'PR merged',
15  'Deploying to Staging',
16  'Staging Deployed',
17  'Smoke Testing Staging',
18  'Staging Smoke Tested',
19  'Deploying to Prod',
20  'Prod Deployed',
21  'Smoke Testing Prod',
22  'Prod Smoke Tested',
23]
24
25const GROUPS = [
26  { title: 'Code', from: 0, to: 4 },
27  { title: 'Staging', from: 4, to: 8 },
28  { title: 'Production', from: 8, to: 12 },
29]
30
31const GLYPH: Record<Mark, string> = {
32  done: '✓',
33  running: '●',
34  failed: '✗',
35  waiting: '◌',
36  pending: '·',
37}
38
39const COLOR: Record<Mark, string | undefined> = {
40  done: 'green',
41  running: 'yellow',
42  failed: 'red',
43  waiting: 'cyan',
44  pending: undefined,
45}
46
47type Job = { label: string; status: string; conclusion: string }
48type Result = 'running' | 'failed' | 'done' | null
49
50// A GitHub job or check state, folded to one result.
51const resultOf = (status: string, conclusion: string): Result => {
52  const s = status.toLowerCase()
53  const c = conclusion.toLowerCase()
54  if (s && s !== 'completed') return 'running'
55  if (['failure', 'cancelled', 'timed_out', 'action_required', 'startup_failure'].includes(c)) return 'failed'
56  if (c === 'skipped' || c === 'neutral') return null
57  return 'done'
58}
59
60// Many jobs for one stage: any failure wins, then any running job.
61const summarise = (jobs: Job[]): Result => {
62  const results = jobs.map(j => resultOf(j.status, j.conclusion)).filter(r => r !== null)
63  if (results.length === 0) return null
64  if (results.includes('failed')) return 'failed'
65  if (results.includes('running')) return 'running'
66  return 'done'
67}
68
69const toRegex = (source: unknown, fallback: string) => {
70  try {
71    return new RegExp(typeof source === 'string' && source ? source : fallback, 'i')
72  } catch {
73    return new RegExp(fallback, 'i')
74  }
75}
76
77let match = {
78  smokeStaging: /smoke.*stag|stag.*smoke/i,
79  smokeProd: /smoke.*prod|prod.*smoke/i,
80  deployStaging: /deploy.*stag|stag.*deploy/i,
81  deployProd: /deploy.*prod|prod.*deploy/i,
82}
83
84// Jobs of completed runs do not change, so keep them for the module's life.
85const doneRuns = new Map<number, Job[]>()
86let isBusy = false
87
88async function run($: EngineInterface, argv: string[]) {
89  const out = await $.process.run(argv, { timeoutMs: 20000 })
90  return { ok: out.exitCode === 0, text: out.stdout.trim(), err: out.stderr.trim() }
91}
92
93async function jobsFor($: EngineInterface, sha: string): Promise<Job[]> {
94  const list = await run($, [
95    'gh', 'run', 'list', '--commit', sha, '--limit', '30',
96    '--json', 'databaseId,workflowName,status,conclusion',
97  ])
98  if (!list.ok) return []
99  const runs = JSON.parse(list.text || '[]') as {
100    databaseId: number; workflowName: string; status: string; conclusion: string
101  }[]
102  const all: Job[] = []
103  for (const r of runs) {
104    const kept = doneRuns.get(r.databaseId)
105    if (kept) {
106      all.push(...kept)
107      continue
108    }
109    const view = await run($, ['gh', 'run', 'view', String(r.databaseId), '--json', 'jobs'])
110    if (!view.ok) continue
111    const { jobs } = JSON.parse(view.text) as { jobs: { name: string; status: string; conclusion: string }[] }
112    const mapped = jobs.map(j => ({
113      label: `${r.workflowName} / ${j.name}`,
114      status: j.status ?? '',
115      conclusion: j.conclusion ?? '',
116    }))
117    if (r.status === 'completed') doneRuns.set(r.databaseId, mapped)
118    all.push(...mapped)
119  }
120  return all
121}
122
123async function build($: EngineInterface): Promise<Pipeline> {
124  const stages: Stage[] = NAMES.map(name => ({ name, mark: 'pending' as Mark }))
125  const updatedAt = await $.clock.now()
126  const head = await run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
127  if (!head.ok) return { branch: null, pr: null, stages, error: 'Not a git repository', updatedAt }
128  const branch = head.text
129
130  const pin = await read($, pinned)
131  const view = await run($, [
132    'gh', 'pr', 'view', ...(pin ? [String(pin)] : []),
133    '--json', 'number,title,url,state,mergedAt,mergeCommit,statusCheckRollup',
134  ])
135
136  if (!view.ok) {
137    const dirty = await run($, ['git', 'status', '--porcelain'])
138    const ahead = await run($, ['git', 'rev-list', '--count', '@{u}..HEAD'])
139    const changed = dirty.ok && dirty.text ? dirty.text.split('\n').length : 0
140    const notes = [
141      changed ? `${changed} changed file${changed === 1 ? '' : 's'}` : 'clean',
142      ahead.ok ? `${ahead.text} unpushed` : 'no upstream',
143    ]
144    stages[0] = { ...stages[0], mark: 'running', note: notes.join(', ') }
145    const isNoPr = /no pull requests? found/i.test(view.err)
146    return { branch, pr: null, stages, error: isNoPr ? undefined : view.err.split('\n')[0], updatedAt }
147  }
148
149  const pr = JSON.parse(view.text) as {
150    number: number; title: string; url: string; state: string; mergedAt: string | null
151    mergeCommit: { oid: string } | null
152    statusCheckRollup: { status?: string; conclusion?: string; state?: string }[] | null
153  }
154  stages[0] = { ...stages[0], mark: 'done' }
155  stages[1] = { ...stages[1], mark: 'done', note: `#${pr.number}` }
156
157  // CheckRun has status/conclusion; StatusContext has state only.
158  const checks = (pr.statusCheckRollup ?? []).map(c =>
159    c.state !== undefined
160      ? resultOf(c.state === 'PENDING' || c.state === 'EXPECTED' ? 'in_progress' : 'completed', c.state === 'SUCCESS' ? 'success' : 'failure')
161      : resultOf(c.status ?? '', c.conclusion ?? ''),
162  )
163  const total = checks.filter(c => c !== null).length
164  const running = checks.filter(c => c === 'running').length
165  const failed = checks.filter(c => c === 'failed').length
166  if (running) stages[2] = { ...stages[2], mark: 'running', note: `${total - running}/${total} done` }
167  else if (failed) stages[2] = { ...stages[2], mark: 'failed', note: `${failed} of ${total} failed` }
168  else stages[2] = { ...stages[2], mark: 'done', note: total ? `${total} passed` : 'no checks' }
169
170  if (pr.state === 'CLOSED') stages[3] = { ...stages[3], mark: 'failed', note: 'closed without merge' }
171  if (pr.state === 'MERGED') {
172    stages[3] = { ...stages[3], mark: 'done' }
173    const jobs = pr.mergeCommit ? await jobsFor($, pr.mergeCommit.oid) : []
174    const pick = (re: RegExp, not?: RegExp) => jobs.filter(j => re.test(j.label) && !(not && not.test(j.label)))
175    const envs = [
176      { at: 4, deploy: pick(match.deployStaging, match.smokeStaging), smoke: pick(match.smokeStaging) },
177      { at: 8, deploy: pick(match.deployProd, match.smokeProd), smoke: pick(match.smokeProd) },
178    ]
179    for (const env of envs) {
180      for (const [offset, group] of [[0, env.deploy], [2, env.smoke]] as const) {
181        const result = summarise(group)
182        const i = env.at + offset
183        if (result === 'running') stages[i] = { ...stages[i], mark: 'running', note: group[0]?.label }
184        if (result === 'failed') stages[i] = { ...stages[i], mark: 'failed', note: group.find(j => resultOf(j.status, j.conclusion) === 'failed')?.label }
185        if (result === 'done') {
186          stages[i] = { ...stages[i], mark: 'done' }
187          stages[i + 1] = { ...stages[i + 1], mark: 'done' }
188        }
189      }
190    }
191  }
192
193  // The first open stage after an unbroken run of done stages is the one we wait on.
194  const next = stages.findIndex(s => s.mark !== 'done')
195  if (next >= 0 && stages[next].mark === 'pending') stages[next] = { ...stages[next], mark: 'waiting' }
196
197  return { branch, pr: { number: pr.number, title: pr.title, url: pr.url }, stages, updatedAt }
198}
199
200const current = (p: Pipeline) =>
201  p.stages.find(s => s.mark === 'failed' || s.mark === 'running' || s.mark === 'waiting') ??
202  [...p.stages].reverse().find(s => s.mark === 'done')
203
204async function refresh($: EngineInterface) {
205  if (isBusy) return
206  isBusy = true
207  try {
208    const before = await read($, pipeline)
209    const after = await build($)
210    await update($, pipeline, () => after)
211    const now = current(after)
212    if (now && after.branch) $.ui.status(`CI ${GLYPH[now.mark]} ${now.name}`)
213    const was = before && current(before)
214    const isSameTarget = before?.branch === after.branch && before?.pr?.number === after.pr?.number
215    if (now && was && isSameTarget && (was.name !== now.name || was.mark !== now.mark)) {
216      $.ui.toast(`CI: ${now.name}${now.mark === 'failed' ? ' failed' : ''}`)
217    }
218  } catch (err) {
219    $.ui.status(`CI ? ${String(err).slice(0, 60)}`)
220  } finally {
221    isBusy = false
222  }
223}
224
225export const register: Register = (on, options) => {
226  match = {
227    smokeStaging: toRegex(options.smokeStaging, 'smoke.*stag|stag.*smoke'),
228    smokeProd: toRegex(options.smokeProd, 'smoke.*prod|prod.*smoke'),
229    deployStaging: toRegex(options.deployStaging, 'deploy.*stag|stag.*deploy'),
230    deployProd: toRegex(options.deployProd, 'deploy.*prod|prod.*deploy'),
231  }
232  const pollMs = Math.max(5, Number(options.pollSeconds) || 20) * 1000
233
234  on('session.start', async ($, e, next) => {
235    await $.command.register({
236      name: 'pipeline',
237      description: 'Show the CI/CD pipeline pane. Args: a PR number to pin, "branch" to follow the current branch',
238    })
239    void $.ui.open({ id: PANE, title: 'Pipeline' })
240    void refresh($)
241    $.clock.every(pollMs, () => void refresh($))
242
243    return next(e)
244  })
245
246  on('command.run', { command: 'pipeline' }, async ($, e) => {
247    const arg = e.args.trim()
248    if (/^\d+$/.test(arg)) await update($, pinned, () => Number(arg))
249    if (arg === 'branch') await update($, pinned, () => null)
250    await $.ui.open({ id: PANE, title: 'Pipeline' })
251    void refresh($)
252    const pin = await read($, pinned)
253
254    return { text: pin ? `Pipeline pane open, following PR #${pin}.` : 'Pipeline pane open, following the current branch.' }
255  })
256
257  // Pushes and PRs often happen inside a turn, so look again when it ends.
258  on('turn.complete', async ($, e, next) => {
259    void refresh($)
260
261    return next(e)
262  })
263
264  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
265    const { Box, Text, Button } = $.ui.resolve(e)
266    const p = await read($, pipeline)
267    const pin = await read($, pinned)
268
269    if (!p) return <Text dimColor>Reading pipeline status…</Text>
270
271    const time = new Date(p.updatedAt).toLocaleTimeString()
272    const target = p.pr ? `PR #${p.pr.number} ${p.pr.title}` : 'no PR yet'
273
274    return (
275      <Box flexDirection="column">
276        <Text bold wrap="truncate-end">{p.branch ?? '—'}{pin ? ' (pinned)' : ''}</Text>
277        <Text dimColor wrap="truncate-end">{target}</Text>
278        {p.error && <Text color="red" wrap="truncate-end">{p.error}</Text>}
279        {GROUPS.map(g => (
280          <Box flexDirection="column" marginTop={1}>
281            <Text dimColor>{g.title}</Text>
282            {p.stages.slice(g.from, g.to).map(s => (
283              <Box>
284                <Text color={COLOR[s.mark]} dimColor={s.mark === 'pending'}>{GLYPH[s.mark]} </Text>
285                <Text bold={s.mark === 'running' || s.mark === 'waiting' || s.mark === 'failed'} dimColor={s.mark === 'pending'} wrap="truncate-end">
286                  {s.name}
287                  {s.note ? <Text dimColor> {s.note}</Text> : null}
288                </Text>
289              </Box>
290            ))}
291          </Box>
292        ))}
293        <Box marginTop={1}>
294          <Text dimColor>Updated {time} </Text>
295          <Button key="refresh" label="Refresh" hotkey="r" plain onPress={() => void refresh($)} />
296        </Box>
297      </Box>
298    )
299  })
300}
301
types/index.d.ts 18 lines
1export type Mark = 'done' | 'running' | 'failed' | 'waiting' | 'pending'
2
3export type Stage = { name: string; mark: Mark; note?: string }
4
5export type Pipeline = {
6  branch: string | null
7  pr: { number: number; title: string; url: string } | null
8  stages: Stage[]
9  error?: string
10  updatedAt: number
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'ci-pipeline': { pipeline: Pipeline | null; pinned: number | null }
16  }
17}
18