SLOPSHOPPER

claudinho

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…

newbandtoastpromptprocesstimer
★ 30v0.1.0MITupdated 2026-10-10arturogarrido/claudinho/packages/plugin
A shopper browsing a rack in a slop shop
README

Claudinho for Claude Code ⚽

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.

Install

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.

What it does, and what it does not promise

  • The band above the prompt shows the line 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.
  • The toasts say a score change the plugin observed between two current views, as ⚽ 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.
  • The context: while a match is live, each prompt you submit carries the hook's live-score block beside it, for the model, from the plugin's last run (never a run on submit), and only while that run is recent and its scores are inside their display window.

The toasts option

pinned (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.

Data and disclaimer

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.

Source 2 files
hooks/register.tsx 255 lines
1import { 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}
255
types/index.d.ts 50 lines
1/**
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