SLOPSHOPPER

ci

Shows the latest GitHub Actions run for the current branch in the hint line under the prompt, refreshed on a timer and after each turn, with a toast when a run…

newspinnercommandtoastprocesstimer
v0.1.1MITupdated 2026-10-05Hula-Hoop-AI/supermods/plugins/ci
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ci
› 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 ⎿ ci: No GitHub Actions runs to show: not on a branch, or `gh` is missing or signed out. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ci

Keeps the latest GitHub Actions run for the branch you are on in the dim hint line under the prompt, so you see CI go green or red without leaving Claude Code.

? for shortcuts · CI ● passing · build
? for shortcuts · CI ◌ running · test
? for shortcuts · CI ✗ failing · deploy

It refreshes on a timer and after each turn, and shows one toast with the run's link when a run that was running finishes.

Install

/plugin marketplace add Hula-Hoop-AI/supermods
/plugin install ci@supermods

Needs the gh CLI, signed in, and a repository on GitHub.

Use

Nothing to do: the segment appears after Claude Code's own hint once the session starts in a git branch with runs. /ci refreshes now and prints the run's state with its link. Off a branch, or without gh, the hint line is left as Claude Code draws it.

Configuration

OptionDefaultEffect
poll_seconds60Seconds between checks of the latest run.
workflowemptyOnly show runs of this workflow (its name or file name). Empty shows the latest run of any workflow.

What it touches

Events: session.start (registers /ci, starts the timer), turn.complete (main conversation turns), command.run (/ci), ui.render (PromptHint: adds its segment and keeps Claude Code's hint and other mods' segments).

Capabilities: $.process.run for git branch --show-current and gh run list (read-only; gh makes its own network requests), $.clock.every, $.ui.toast, $.ui.resolve, $.state. No file or model access and no network access of its own.

Limitations

  • One run: the latest on the branch (or of the chosen workflow). Several workflows on one push show whichever ran last.
  • Each refresh is a gh call, so a short poll_seconds spends GitHub API quota.
  • The prompt hint is drawn on the terminal and the desktop app only. On a narrow line the segment is cut (or left out), never Claude Code's own hint.
Source 2 files
hooks/register.tsx 158 lines
1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, Register } from 'claude-code';
3
4const COMMAND = 'ci';
5const RUN_FIELDS = 'status,conclusion,workflowName,url';
6const GH_TIMEOUT_MS = 15000;
7const SEPARATOR = ' · ';
8// Under this many columns a cut segment says nothing, so it is left out (the terminal's rule for `tail`).
9const MIN_SEGMENT_COLUMNS = 4;
10// How each run state is drawn: its glyph and its word; another state draws ○ and its own name.
11const STATES: Record<string, [string, string]> = {
12  running: ['◌', 'running'],
13  success: ['●', 'passing'],
14  failure: ['✗', 'failing'],
15  cancelled: ['⊘', 'cancelled'],
16  timed_out: ['✗', 'timed out'],
17  startup_failure: ['✗', 'startup failure'],
18  action_required: ['!', 'action required'],
19};
20
21export type Run = { status: string; conclusion: string; workflowName: string; url: string };
22export type Beacon = { branch: string; run: Run | null };
23
24type Settings = { pollMs: number; workflow: string };
25
26async function output($: EngineInterface, argv: string[]): Promise<string> {
27  try {
28    const ran = await $.process.run(argv, { timeoutMs: GH_TIMEOUT_MS });
29    return ran.exitCode === 0 ? ran.stdout.trim() : '';
30  } catch {
31    return '';
32  }
33}
34
35export function parseRuns(json: string): Run | null {
36  try {
37    const runs: unknown = JSON.parse(json);
38    const first = Array.isArray(runs) ? runs[0] : undefined;
39    if (typeof first !== 'object' || first === null) return null;
40    const run = first as Partial<Run>;
41    return {
42      status: run.status ?? '',
43      conclusion: run.conclusion ?? '',
44      workflowName: run.workflowName ?? '',
45      url: run.url ?? '',
46    };
47  } catch {
48    return null;
49  }
50}
51
52/** The run's one-word state: `running` until it completes, then its conclusion. */
53export function stateOf(run: Run): string {
54  return run.status === 'completed' ? run.conclusion || 'completed' : 'running';
55}
56
57function drawn(state: string): [string, string] {
58  return STATES[state] ?? ['○', state.replace('_', ' ')];
59}
60
61export function statusLine({ branch, run }: Beacon): string {
62  if (run === null) return `CI ○ no runs on ${branch}`;
63  const [glyph, word] = drawn(stateOf(run));
64  return `CI ${glyph} ${word} · ${run.workflowName}`;
65}
66
67/** The segment cut to `room` columns, or empty when too little of it would show. */
68export function fit(segment: string, room: number): string {
69  if (room < MIN_SEGMENT_COLUMNS) return '';
70  return segment.length <= room ? segment : `${segment.slice(0, room - 1)}…`;
71}
72
73async function fetchBeacon($: EngineInterface, { workflow }: Settings): Promise<Beacon | null> {
74  const branch = await output($, ['git', 'branch', '--show-current']);
75  if (branch === '') return null;
76  const filter = workflow === '' ? [] : ['--workflow', workflow];
77  const listing = await output($, ['gh', 'run', 'list', '--branch', branch, '--limit', '1', '--json', RUN_FIELDS, ...filter]);
78  return listing === '' ? null : { branch, run: parseRuns(listing) };
79}
80
81// The last state seen per branch, so a finished run toasts once.
82const seen = atom({ plugin: 'ci', key: 'seen' } as const, {});
83// What the prompt hint shows; empty off a branch or without gh.
84const hint = atom({ plugin: 'ci', key: 'hint' } as const, '');
85// A refresh already running answers every caller that arrives meanwhile.
86let inFlight: Promise<Beacon | null> | null = null;
87
88function refresh($: EngineInterface, settings: Settings): Promise<Beacon | null> {
89  inFlight ??= refreshNow($, settings).finally(() => (inFlight = null));
90  return inFlight;
91}
92
93async function refreshNow($: EngineInterface, settings: Settings): Promise<Beacon | null> {
94  const beacon = await fetchBeacon($, settings);
95  const text = beacon === null ? '' : statusLine(beacon);
96  await update($, hint, () => text);
97  if (beacon?.run) {
98    const state = stateOf(beacon.run);
99    let before: string | undefined;
100    await update($, seen, states => {
101      before = states[beacon.branch];
102      return { ...states, [beacon.branch]: state };
103    });
104    if (before !== undefined && before !== state && state !== 'running') {
105      $.ui.toast(`CI: ${beacon.run.workflowName} is ${drawn(state)[1]} on ${beacon.branch}. ${beacon.run.url}`);
106    }
107  }
108  return beacon;
109}
110
111export const register: Register = (on, options) => {
112  const settings: Settings = { pollMs: Number(options['poll_seconds']) * 1000, workflow: String(options['workflow'] ?? '').trim() };
113
114  on('session.start', async ($, e, next) => {
115    await $.command.register({ name: COMMAND, description: 'Check the latest GitHub Actions run for this branch now' });
116    $.clock.every(settings.pollMs, () => void refresh($, settings));
117    void refresh($, settings);
118
119    return next(e);
120  });
121
122  on('turn.complete', ($, e, next) => {
123    if (e.agentId === undefined) void refresh($, settings);
124
125    return next(e);
126  });
127
128  // The terminal adds `tail` to its own live line and cuts it at the row's end; the desktop
129  // does not draw `tail` yet, so there the engine's line is wrapped with the segment beside it.
130  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
131    const segment = await read($, hint);
132    if (segment === '') return next(e);
133    if (e.surface === 'terminal') {
134      const tail = e.props.tail ? `${e.props.tail}${SEPARATOR}${segment}` : segment;
135      return next({ ...e, props: { ...e.props, tail } });
136    }
137    const drawn = await next(e);
138    const room = e.viewport === undefined ? segment.length : e.viewport.columns - e.props.hint.length - SEPARATOR.length;
139    const shown = fit(segment, room);
140    if (shown === '') return drawn;
141    const { Box, Text } = $.ui.resolve(e);
142
143    return (
144      <Box flexDirection="row">
145        {drawn}
146        <Text dimColor wrap="truncate-end">{`${SEPARATOR}${shown}`}</Text>
147      </Box>
148    );
149  });
150
151  on('command.run', { command: COMMAND }, async $ => {
152    const beacon = await refresh($, settings);
153    if (beacon === null) return { text: 'No GitHub Actions runs to show: not on a branch, or `gh` is missing or signed out.' };
154
155    return { text: beacon.run === null ? statusLine(beacon) : `${statusLine(beacon)} ${beacon.run.url}` };
156  });
157};
158
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'ci': { seen: Record<string, string>; hint: string };
4  }
5}
6