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…

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.
/plugin marketplace add Hula-Hoop-AI/supermods
/plugin install ci@supermods
Needs the gh CLI, signed in, and a repository on GitHub.
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.
| Option | Default | Effect |
|---|---|---|
poll_seconds | 60 | Seconds between checks of the latest run. |
workflow | empty | Only show runs of this workflow (its name or file name). Empty shows the latest run of any workflow. |
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.
gh call, so a short poll_seconds spends GitHub API quota.hooks/register.tsx 158 lines1import { 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};
158types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'ci': { seen: Record<string, string>; hint: string };
4 }
5}
6