Live football in Claude Code for the competition you follow: the score or the countdown above the prompt, a toast when a score changes, and the live score…

Live football in Claude Code for the competition you follow: the score or the countdown above the prompt, a toast when a score changes, and the live score beside your prompt for the model. The plugin reads everything from the installed claudinho CLI (claudinho ambient --json, the CLI's local cache) and never fetches anything itself.
The plugin runs the claudinho command, so install the CLI and choose a competition first. It needs @claudinho/cli 0.11.2 or later, the first whose claudinho ambient --json says what its line is (idle, empty), carries the hook's own line on each live record, and marks its fallback object empty; with an older CLI the band shows nothing and nothing toasts.
npm i -g @claudinho/cli # 0.11.2 or later
claudinho follow premier-league # or any alias from `claudinho follow --list`
claudinho init plugin # removes the statusline and hook `init claude` wrote, if you ran it
claudinho init plugin removes exactly the claudinho prompt statusline and the claudinho hook prompt hook that claudinho init claude wrote to ~/.claude/settings.json (an edited command or a wrapper stays, and it names it), so the plugin's band and context do not show twice. Then, at the prompt of a Claude Code session in a terminal:
/plugin install claudinho --marketplace arturogarrido/claudinho
Answer y to add the marketplace, then choose a scope (the user scope first, with Enter), and set the toasts option if you want another value than the default. The plugin is active in that session from then on.
claudinho prompt prints: the live score, or the countdown to the next fixture. It shows nothing when nothing is chosen or nothing is known. At the end of an edition: the World Cup's sign-off line (⚽ World Cup 2026 is complete · claudinho follow --list) is the one end-of-edition line the statusline prints, and the band shows it; after another competition's season the statusline says nothing is known, and the band shows nothing. It runs the CLI every 15 seconds (every five minutes on the sign-off line and while nothing is chosen), which is also what keeps the CLI's cache fresh. The CLI fits the line to the band's width and keeps its +N count of other matches; the engine cuts anything wider. What the band shows and how often it reads come from what the CLI says the line is, never from its text. A headless session (claude -p, the SDK) runs nothing.⚽ and the hook's own line for the match, the one claudinho hook prints (⚽ Arsenal 2–1 Chelsea (67'); on a nations competition with its flags and the roster's names, ⚽ 🇲🇽 Mexico 1–0 South Africa 🇿🇦 (67')): one toast per match per change. Best effort, never every goal. A change is never said as it happens from a view the CLI could not vouch for (stale, degraded, or a read that was not whole), and a goal across such a gap, or across a failed run, is said at the first current view after it: late, never lost while the match stays in the list (a match gone from the next current view and back is observed afresh). The first view of a match says nothing, and neither does a change of competition.toasts optionpinned (the default) toasts your pinned team's match (claudinho follow <alias> --team <name>); with no pin saved it toasts nothing. all toasts every live match of the competition you follow; off none. Change it in /config.
Live data: ESPN, read by the CLI, which keeps it in a local cache; the plugin reads the CLI's answer and nothing else.
Independent fan project. Not affiliated with FIFA, any confederation, league or club, or Anthropic.
hooks/register.tsx 255 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ClaudinhoBand, ClaudinhoBaseline, ClaudinhoContext, ClaudinhoPace, ClaudinhoTally } from '../types'
5
6/**
7 * The claudinho plugin: the live score or the countdown above the prompt (the band), a toast when a score changes,
8 * and the live score beside the prompt for the model.
9 *
10 * The CLI owns the data (its cache, its refresher, the network, the backoff) and the words: this module runs the one
11 * binary, `claudinho ambient --json --columns <the band's columns>`, with an argument array, bounded, and reads the
12 * fields of the one JSON object it prints, never its text: what the line IS (`idle`, `empty`, the first run's
13 * `noCompetition`), whether its list is `current`, and the hook's own line on each record, said verbatim. No binary,
14 * a timeout, a cut or an answer that is not the object: silence, never a blocked turn. Every run is also what
15 * triggers the CLI's refresher, so the band runs at the live pace on every line but the idle ones. Best effort
16 * throughout: a toast says a score change this module observed between two current views, never every goal.
17 */
18
19const band = atom({ plugin: 'claudinho', key: 'band' } as const, null as ClaudinhoBand)
20const pace = atom({ plugin: 'claudinho', key: 'pace' } as const, { ranAt: 0, idle: false } as ClaudinhoPace)
21const baseline = atom({ plugin: 'claudinho', key: 'baseline' } as const, null as ClaudinhoBaseline)
22const lastContext = atom({ plugin: 'claudinho', key: 'context' } as const, null as ClaudinhoContext)
23
24const LIVE_MS = 15_000
25const IDLE_MS = 300_000
26const RUN_TIMEOUT_MS = 2_000
27/** The prompt context is attached only from a run younger than two periods. */
28const CONTEXT_MAX_AGE_MS = 2 * LIVE_MS
29/** The band's width before any draw. */
30const DEFAULT_COLUMNS = 80
31
32type ToastsOption = 'pinned' | 'all' | 'off'
33const TOASTS_OPTIONS: readonly ToastsOption[] = ['pinned', 'all', 'off']
34
35/**
36 * The band's width as last drawn (`bodyColumns`), which the next run asks the CLI to fit the line to. Kept in the
37 * module, not `$.state`: the draw that learns it may not write state. A reload starts again from the default.
38 */
39let columns = DEFAULT_COLUMNS
40/** At most one run in flight: the guard is taken before the first await. */
41let busy = false
42
43/**
44 * What `claudinho ambient --json` prints, as far as this module reads it: a selected competition's VIEW (an object
45 * with a `live` object holding an `items` array that says what its line is, `idle` and `empty` as two booleans), or
46 * a shape that is no view: the first-run object (no competition chosen: `noCompetition`, the one line), the fallback
47 * object (a refused value or a failure: the line and `empty`; an older CLI's `{ line }` has the same shape), and a
48 * view from a CLI older than `idle` and `empty` (a live list and neither).
49 */
50type View = {
51 line: string
52 noCompetition?: unknown
53 idle?: unknown
54 empty?: unknown
55 context?: unknown
56 live?: { items?: unknown }
57 current?: unknown
58 competition?: { slug?: unknown } | null
59 staleAfter?: unknown
60}
61
62type Item = {
63 id: string
64 line?: unknown
65 score?: unknown
66 shootout?: unknown
67 pinned?: unknown
68}
69
70const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
71
72/** The first line of the CLI's stdout as the view (or the first-run object), or null for anything else (a run that failed). */
73function parseView(stdout: string): View | null {
74 const first = (stdout.split('\n')[0] ?? '').trim()
75 if (!first) return null
76 let parsed: unknown
77 try {
78 parsed = JSON.parse(first)
79 } catch {
80 return null
81 }
82 if (!isObject(parsed) || typeof parsed.line !== 'string') return null
83 return parsed as View
84}
85
86/** A tally `{ home, away }` of two numbers, or undefined. */
87function tally(v: unknown): { home: number; away: number } | undefined {
88 if (!isObject(v) || typeof v.home !== 'number' || typeof v.away !== 'number') return undefined
89 return { home: v.home, away: v.away }
90}
91
92/** The view's live matches that carry an id. */
93function itemsOf(view: View): Item[] {
94 const items = isObject(view.live) && Array.isArray(view.live.items) ? view.live.items : []
95 return items.filter((m): m is Item => isObject(m) && typeof m.id === 'string')
96}
97
98/** The view's competition, by its slug ('' when it states none). */
99const slugOf = (view: View): string =>
100 isObject(view.competition) && typeof view.competition.slug === 'string' ? view.competition.slug : ''
101
102/** Two tallies differ. */
103const changed = (a: { home: number; away: number }, b: { home: number; away: number }) => a.home !== b.home || a.away !== b.away
104
105/**
106 * The toasts of a CURRENT view against the last current view's baseline, under the option: ONE toast per match whose
107 * id was there and whose regulation score or shootout tally changed (a tally compared only when both carry one), its
108 * text `⚽` and the record's own line, verbatim (a record with no line says nothing). A new id is the first
109 * observation (silent); a competition change rebases with nothing to compare.
110 */
111function toastsFor(view: View, prev: ClaudinhoBaseline, option: ToastsOption): string[] {
112 if (option === 'off' || !prev || prev.slug !== slugOf(view)) return []
113 const before = new Map(prev.items.map((t) => [t.id, t]))
114 const texts: string[] = []
115 for (const m of itemsOf(view)) {
116 if (option === 'pinned' && m.pinned !== true) continue
117 const was = before.get(m.id)
118 if (!was || typeof m.line !== 'string' || m.line === '') continue
119 const score = tally(m.score)
120 const shootout = tally(m.shootout)
121 const regulation = !!(score && was.score && changed(score, was.score))
122 const penalties = !!(shootout && was.shootout && changed(shootout, was.shootout))
123 if (regulation || penalties) texts.push(`⚽ ${m.line}`)
124 }
125 return texts
126}
127
128/** The baseline a CURRENT view leaves for the next one: its competition and each match's tallies. */
129function baselineOf(view: View): ClaudinhoBaseline {
130 const items: ClaudinhoTally[] = itemsOf(view).map((m) => {
131 const score = tally(m.score)
132 const shootout = tally(m.shootout)
133 return { id: m.id, ...(score ? { score } : {}), ...(shootout ? { shootout } : {}) }
134 })
135 return { slug: slugOf(view), items }
136}
137
138/** One run of the CLI, bounded; at most one in flight; paced by the last line unless `force`. */
139function tick($: EngineInterface, option: ToastsOption, force = false): Promise<void> {
140 if (busy) return Promise.resolve()
141 busy = true
142 return run($, option, force).finally(() => {
143 busy = false
144 })
145}
146
147async function run($: EngineInterface, option: ToastsOption, force: boolean): Promise<void> {
148 const now = await $.clock.now()
149 // On a non-idle line the timer is the only pace; the age check is the idle gap's alone.
150 const last = await read($, pace)
151 if (!force && last.idle && now - last.ranAt < IDLE_MS) return
152 let answer: View | null = null
153 try {
154 const r = await $.process.run(['claudinho', 'ambient', '--json', '--columns', String(columns)], {
155 timeoutMs: RUN_TIMEOUT_MS,
156 stdin: '',
157 })
158 // A cut stdout is no answer: its first line may be part of an object.
159 if (r.exitCode === 0 && r.isStdoutTruncated !== true) answer = parseView(r.stdout)
160 } catch {
161 // No binary, a timeout, a refused spawn: a failed run.
162 }
163 if (!answer) {
164 // A failed run: nothing shown, the baseline and the context left, the next run at the next fire.
165 await update($, pace, () => ({ ranAt: now, idle: false }))
166 await update($, band, () => null)
167 return
168 }
169 const view: View = answer
170 // What the line IS comes from the fields, never the text (which `--columns` may have cut): the first-run object
171 // (nothing chosen) is hidden and idle; an object that is no view the plugin reads is hidden and not idle, never
172 // current (the context cleared, the baseline kept, nothing toasted); a view that says `empty` is hidden; one that
173 // says `idle` is read slowly. A view the plugin reads has its `live` list AND says what its line is, `idle` and
174 // `empty` as two booleans: the fallback object, an older CLI's `{ line }`, and a view from a CLI older than those
175 // two fields (a live list and neither) are no view.
176 const firstRun = view.noCompetition === true
177 const isView =
178 !firstRun &&
179 isObject(view.live) &&
180 Array.isArray(view.live.items) &&
181 typeof view.idle === 'boolean' &&
182 typeof view.empty === 'boolean'
183 const idle = firstRun || (isView && view.idle === true)
184 const hidden = !isView || view.empty === true || view.line.trim() === ''
185 await update($, pace, () => ({ ranAt: now, idle }))
186 await update($, band, () => (hidden ? null : { text: view.line }))
187
188 // The two shapes that are no view are no current view either: each clears the context and keeps the baseline.
189 if (isView && view.current === true) {
190 const prev = await read($, baseline)
191 for (const text of toastsFor(view, prev, option)) $.ui.toast(text)
192 await update($, baseline, () => baselineOf(view))
193 await update($, lastContext, () => ({
194 context: typeof view.context === 'string' && view.context !== '' ? view.context : null,
195 staleAfter: typeof view.staleAfter === 'string' ? view.staleAfter : null,
196 ranAt: now,
197 }))
198 } else {
199 // A view that is not current, of any competition, says nothing about play: the context clears and the baseline
200 // stays. Only a current view replaces it (a current view of another competition compares nothing and replaces
201 // it; a disappearance rebases), so a change across the gap is said at the next current view, late, never lost
202 // while the match stays in the list.
203 await update($, lastContext, () => null)
204 }
205}
206
207export const register: Register = (on, options) => {
208 const option: ToastsOption = TOASTS_OPTIONS.includes(options.toasts as ToastsOption)
209 ? (options.toasts as ToastsOption)
210 : 'pinned'
211
212 on('session.start', ($, e, next) => {
213 // A headless session (`-p`, the SDK) draws no band: no timer, no process.
214 if (!e.isInteractive) return next(e)
215 columns = DEFAULT_COLUMNS
216 busy = false
217 // The first run right after the start resolves (never inside it), then the pace.
218 $.clock.after(0, () => {
219 void tick($, option, true)
220 })
221 $.clock.every(LIVE_MS, () => {
222 void tick($, option)
223 })
224 return next(e)
225 })
226
227 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
228 const width = e.props.bodyColumns
229 if (typeof width === 'number' && Number.isInteger(width) && width > 0) columns = width
230 const shown = await read($, band)
231 if (e.props.hasSurvey || e.props.maxRows < 1 || shown === null) return next(e)
232 const { Box, Text } = $.ui.resolve(e)
233 // The CLI fitted the line to the width asked, its `+N` suffix reserved; the engine's cut is the backstop.
234 return (
235 <Box>
236 <Text wrap="truncate-end">{shown.text}</Text>
237 </Box>
238 )
239 })
240
241 on('prompt.submit', async ($, e, next) => {
242 // The last current view's context, while that view is inside its own deadline and its run is recent. Never a
243 // subprocess here: the timer's last run is the only source.
244 const record = await read($, lastContext)
245 if (record?.context) {
246 const now = await $.clock.now()
247 const deadline = record.staleAfter === null ? Number.NaN : Date.parse(record.staleAfter)
248 if (Number.isFinite(deadline) && now < deadline && now - record.ranAt < CONTEXT_MAX_AGE_MS) {
249 return next({ ...e, context: [...(e.context ?? []), record.context] })
250 }
251 }
252 return next(e)
253 })
254}
255types/index.d.ts 50 lines1/**
2 * The contract of the claudinho plugin: what it keeps in the session (`$.state`), each value under
3 * `PluginState.claudinho`. Everything is read from `claudinho ambient --json`, the installed CLI's view of its
4 * local cache; the plugin never fetches. A view is readable when it carries its `live` list and says what its line
5 * is, `idle` and `empty` as two booleans (`@claudinho/cli` 0.11.2 or later); anything else (the first-run object, the
6 * fallback object, a view from an older CLI) is no view: hidden, never current, the baseline kept.
7 */
8
9/** The band's line as the CLI fitted it, or null when there is nothing to show (or the last run failed). */
10export type ClaudinhoBand = { text: string } | null
11
12/**
13 * The band's pace: when the last run started (the clock's milliseconds) and whether the CLI said its answer was idle
14 * (the first-run object, nothing chosen; or a view whose `idle` is true, the edition over), which is read once per
15 * five minutes instead of every fire.
16 */
17export type ClaudinhoPace = { ranAt: number; idle: boolean }
18
19/** One live match as the toasts remember it: its id and its two tallies (the words are the CLI's record's own `line`). */
20export type ClaudinhoTally = {
21 id: string
22 score?: { home: number; away: number }
23 shootout?: { home: number; away: number }
24}
25
26/**
27 * The toasts' baseline: the last CURRENT view's live matches, under its competition. Replaced by every current view,
28 * kept through a view that is not current and through a failed run (a change across the gap is said at the next
29 * current view); null before the first current view.
30 */
31export type ClaudinhoBaseline = { slug: string; items: ClaudinhoTally[] } | null
32
33/**
34 * The prompt context's record: the last CURRENT view's context block (null when it had none), its own display
35 * deadline (`staleAfter`, ISO 8601, or null) and when its run started. Cleared by a view that is not current, kept
36 * through a failed run.
37 */
38export type ClaudinhoContext = { context: string | null; staleAfter: string | null; ranAt: number } | null
39
40declare module 'claude-code' {
41 interface PluginState {
42 claudinho: {
43 band: ClaudinhoBand
44 pace: ClaudinhoPace
45 baseline: ClaudinhoBaseline
46 context: ClaudinhoContext
47 }
48 }
49}
50