claudial 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,12 +2,13 @@
2
2
 
3
3
  # ⚽ claudial
4
4
 
5
- ### Live World Cup 2026 in your terminal — goal, red-card and VAR takeovers included
5
+ ### Live World Cup 2026 in your terminal — goal and red-card takeovers included
6
6
 
7
- One line. Zero install. Every score, while you work.
7
+ One command. Every World Cup score, in your terminal and right inside Claude
8
+ Code, while you work.
8
9
 
9
10
  ```
10
- npx claudial
11
+ npm install -g claudial && claudial setup
11
12
  ```
12
13
 
13
14
  <br/>
@@ -24,45 +25,14 @@ npx claudial
24
25
 
25
26
  ---
26
27
 
27
- Live matches with scores, minutes and scorers on top. Upcoming fixtures in
28
- your local time below. Auto-refreshing while you work.
29
-
30
- ```
31
- ▐▛███▜▌ WORLD CUP 2026 · LIVE
32
- ▝▜█████▛▘ ───────────────────────
33
-
34
- ⏺ LIVE 67'
35
- ARG 🇦🇷 2 — 1 🇲🇽 MEX
36
- ⚽ 23' Messi · 51' Álvarez | 44' Lozano
37
-
38
- ⏺ LIVE 12'
39
- FRA 🇫🇷 0 — 0 🇸🇳 SEN
40
-
41
- ○ UPCOMING
42
- 18:00 BRA 🇧🇷 — 🇭🇷 CRO
43
- 21:00 ESP 🇪🇸 — 🇲🇦 MAR
44
-
45
- r refresh · q quit
46
- ```
47
-
48
- And when a goal goes in — anywhere in the tournament — the whole screen
49
- takes over:
50
-
51
- ```
52
-
53
-
54
- █▀▀█ █▀▀█ █▀▀█ █
55
- █ ▄▄ █ █ █▀▀█ █
56
- ▀▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀
57
-
58
- LIONEL MESSI · 23'
59
-
60
- ARG 🇦🇷 2 — 1 🇲🇽 MEX
28
+ Live World Cup scores right inside Claude Code's status bar — under the input
29
+ box, always visible while you work, lighting up the moment anyone scores:
61
30
 
31
+ <div align="center">
62
32
 
63
- ```
33
+ ![claudial — live World Cup 2026 scores in Claude Code's status bar](demo.gif)
64
34
 
65
- Four seconds of glory, then back to the board.
35
+ </div>
66
36
 
67
37
  ## Claude Code, World Cup edition
68
38
 
@@ -81,17 +51,40 @@ The wizard drops a live-score statusline into your Claude Code settings:
81
51
  ⚽ QAT 🇶🇦 0—1 🇨🇭 SUI 67' · main ← during a match
82
52
  ○ QAT 🇶🇦 — 🇨🇭 SUI 10:00 PM · main ← between matches (next kickoff, or last result)
83
53
  ⚽ G O O O L · ARG 🇦🇷 1—0 🇲🇽 MEX ← for 15s whenever anyone scores
54
+ 🟥 R E D · OTAMENDI · ARG 🇦🇷 — 🇲🇽 MEX ← for 15s on a red card
84
55
  ```
85
56
 
86
57
  It refreshes every few seconds and is pure Node, so it runs on macOS and
87
58
  Windows too, in any shell. Restart Claude Code after setup to see it.
88
59
  Scriptable, no prompts: `claudial setup --statusline --global --yes`.
89
60
 
90
- ## Why a TUI
61
+ **Already run a custom status line?** `claudial setup` keeps it — it wraps your
62
+ existing one and appends the score, so nothing you had is lost.
63
+
64
+ ## The full dashboard
65
+
66
+ Prefer the full board beside Claude? `claudial` opens a live dashboard — every
67
+ match with scores, minutes and scorers, upcoming fixtures in your local time,
68
+ all auto-refreshing while you work. And when a goal goes in — anywhere in the
69
+ tournament — the whole screen takes over:
70
+
71
+ ```
72
+
73
+
74
+ █▀▀█ █▀▀█ █▀▀█ █
75
+ █ ▄▄ █ █ █▀▀█ █
76
+ ▀▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀
77
+
78
+ LIONEL MESSI · 23'
79
+
80
+ ARG 🇦🇷 2 — 1 🇲🇽 MEX
81
+
82
+
83
+ ```
91
84
 
92
- The World Cup happens during work hours somewhere. This sits in a terminal
93
- split, costs nothing to glance at, and celebrates louder than a push
94
- notification — without you ever opening a browser tab.
85
+ Four seconds of glory, then back to the board. It costs nothing to glance at,
86
+ and celebrates louder than a push notification — without you ever opening a
87
+ browser tab.
95
88
 
96
89
  ## Usage
97
90
 
@@ -115,9 +108,9 @@ Requires Node ≥ 18. No account, no API key, no config.
115
108
  | `r` | refresh now |
116
109
  | `q` | quit |
117
110
 
118
- Beyond goals, every live match carries its incident feed: yellow cards,
119
- substitutions, injury time — and a `⚖ VAR` badge the moment a review starts.
120
- Red cards and VAR decisions get the full-screen treatment, same as goals.
111
+ Beyond goals, every live match carries its incident feed — yellow cards,
112
+ substitutions, injury time — and red cards get the full-screen treatment, same
113
+ as goals.
121
114
 
122
115
  ## Status
123
116
 
@@ -125,6 +118,10 @@ v1 is being built live during the group stage. Follow the commits.
125
118
 
126
119
  ## Notes
127
120
 
121
+ - Seeing letter codes instead of country flags (`🇦🇷` showing as `AR`)? Your
122
+ terminal isn't rendering flag emoji. Install [Konsole](https://konsole.kde.org/)
123
+ — it shows them out of the box. (macOS terminals already do; Windows
124
+ Terminal does not.)
128
125
  - Match data comes from [ESPN's public soccer scoreboard](https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard)
129
126
  — facts only (scores, scorers, cards), no logos or branding. See
130
127
  [DATA.md](DATA.md) for why this source. Not affiliated with or endorsed by
package/dist/api/espn.js CHANGED
@@ -46,15 +46,47 @@ export function pastRange(daysBack, now = Date.now()) {
46
46
  const start = new Date(now - daysBack * 86_400_000);
47
47
  return `${yyyymmdd(start)}-${yyyymmdd(end)}`;
48
48
  }
49
+ // ── Mock mode for demos (CLAUDIAL_MOCK) ──────────────────────────────────────
50
+ // A fake live Argentina–England match. The first live fetch is 1–1; from the
51
+ // second fetch on, Harry Kane has scored to make it 1–2 — so the next poll (or
52
+ // pressing `r`) fires the goal takeover with his name. Inert unless the env var
53
+ // is set. Run: `CLAUDIAL_MOCK=1 claudial`, then press `r`.
54
+ const MOCK = !!process.env.CLAUDIAL_MOCK;
55
+ let mockLiveCalls = 0;
56
+ function mockEvent() {
57
+ const scored = mockLiveCalls >= 2; // Kane's goal lands on the 2nd live fetch
58
+ return {
59
+ id: '9001',
60
+ date: new Date(Date.now() - 78 * 60_000).toISOString(),
61
+ status: { type: { state: 'in', name: 'STATUS_SECOND_HALF', shortDetail: scored ? "78'" : "76'" }, displayClock: scored ? "78'" : "76'" },
62
+ competitions: [{
63
+ competitors: [
64
+ { homeAway: 'home', team: { id: '202', abbreviation: 'ARG', displayName: 'Argentina' }, score: '1' },
65
+ { homeAway: 'away', team: { id: '448', abbreviation: 'ENG', displayName: 'England' }, score: scored ? '2' : '1' },
66
+ ],
67
+ details: [
68
+ { type: { id: '137' }, clock: { value: 600, displayValue: "10'" }, scoringPlay: true, team: { id: '202' }, athletesInvolved: [{ id: '1', displayName: 'Lionel Messi', shortName: 'L. Messi' }] },
69
+ { type: { id: '137' }, clock: { value: 2400, displayValue: "40'" }, scoringPlay: true, team: { id: '448' }, athletesInvolved: [{ id: '2', displayName: 'Jude Bellingham', shortName: 'J. Bellingham' }] },
70
+ ...(scored ? [{ type: { id: '137' }, clock: { value: 4680, displayValue: "78'" }, scoringPlay: true, team: { id: '448' }, athletesInvolved: [{ id: '3', displayName: 'Harry Kane', shortName: 'H. Kane' }] }] : []),
71
+ ],
72
+ }],
73
+ };
74
+ }
49
75
  // Kept for call-site compatibility; ESPN needs no season id to query.
50
76
  export async function resolveSeasonId() {
51
77
  return 2026;
52
78
  }
53
79
  export async function fetchLive() {
80
+ if (MOCK) {
81
+ mockLiveCalls++;
82
+ return [parseEvent(mockEvent())];
83
+ }
54
84
  const events = await scoreboard(todayRange(0));
55
85
  return events.map(parseEvent).filter((m) => m.status === 'live' || m.status === 'halftime');
56
86
  }
57
87
  export async function fetchUpcoming(_seasonId) {
88
+ if (MOCK)
89
+ return [];
58
90
  const events = await scoreboard(todayRange(10));
59
91
  return events
60
92
  .map(parseEvent)
@@ -62,6 +94,8 @@ export async function fetchUpcoming(_seasonId) {
62
94
  .sort((a, b) => a.startTimestamp - b.startTimestamp);
63
95
  }
64
96
  export async function fetchRecent(_seasonId) {
97
+ if (MOCK)
98
+ return [];
65
99
  const events = await scoreboard(pastRange(1));
66
100
  return events
67
101
  .map(parseEvent)
@@ -69,7 +103,28 @@ export async function fetchRecent(_seasonId) {
69
103
  .sort((a, b) => a.startTimestamp - b.startTimestamp);
70
104
  }
71
105
  export async function fetchIncidents(eventId) {
106
+ if (MOCK)
107
+ return parseIncidents(mockEvent());
72
108
  const events = await scoreboard(todayRange(0));
73
109
  const event = events.find((e) => Number(e.id) === eventId);
74
110
  return event ? parseIncidents(event) : [];
75
111
  }
112
+ // Red cards in currently-live matches, drawn from the same scoreboard response
113
+ // fetchLive uses (its details carry cards inline) — so no extra network call.
114
+ export async function fetchLiveRedCards() {
115
+ if (MOCK)
116
+ return [];
117
+ const events = await scoreboard(todayRange(0));
118
+ const out = [];
119
+ for (const e of events) {
120
+ const m = parseEvent(e);
121
+ if (m.status !== 'live' && m.status !== 'halftime')
122
+ continue;
123
+ for (const inc of parseIncidents(e)) {
124
+ if (inc.kind === 'redCard') {
125
+ out.push({ id: inc.id, homeCode: m.home.code, awayCode: m.away.code, player: inc.playerShort ?? inc.player ?? '?', minute: inc.minute });
126
+ }
127
+ }
128
+ }
129
+ return out;
130
+ }
package/dist/index.js CHANGED
@@ -29,11 +29,52 @@ async function main() {
29
29
  await runSetup(args.slice(1));
30
30
  process.exit(0);
31
31
  }
32
+ if (args.includes('--mock-goal')) {
33
+ // Demo trigger for recording: show ARG 1—1 ENG live in the status bar for
34
+ // ~2s, then Harry Kane scores → 1—2 and a GOOOL celebration for 15s. Writes
35
+ // to the shared statusline cache, so the bar (which Claude Code repaints on
36
+ // its timer) plays the sequence. Run e.g. `!claudial --mock-goal` while
37
+ // recording.
38
+ const { makeCache } = await import('./statusline/cache.js');
39
+ const { liveLine } = await import('./statusline/line.js');
40
+ const cache = makeCache();
41
+ const base = {
42
+ id: 9001, group: null,
43
+ home: { name: 'Argentina', code: 'ARG' }, away: { name: 'England', code: 'ENG' },
44
+ status: 'live', statusText: '', minute: 78, startTimestamp: 0, varInProgress: false,
45
+ };
46
+ cache.write(liveLine({ ...base, homeScore: 1, awayScore: 1 }));
47
+ process.stdout.write('⚽ mock: ARG 1—1 ENG live… (Kane scores in 2s)\n');
48
+ await new Promise((r) => setTimeout(r, 2000));
49
+ const scored = { ...base, homeScore: 1, awayScore: 2 };
50
+ cache.write(liveLine(scored));
51
+ cache.armGoal(scored);
52
+ process.stdout.write('⚽ GOOOL! ARG 1—2 ENG — the status bar celebrates for 15s.\n');
53
+ process.exit(0);
54
+ }
55
+ if (args.includes('--mock-redcard')) {
56
+ const { makeCache } = await import('./statusline/cache.js');
57
+ makeCache().armRedCard({ player: 'Otamendi', homeCode: 'ARG', awayCode: 'ENG' });
58
+ process.stdout.write('🟥 RED armed — the Claude Code status bar flashes a red card (ARG—ENG) for 15s.\n');
59
+ process.exit(0);
60
+ }
32
61
  if (args.includes('--statusline')) {
33
62
  const input = await readStdin();
34
63
  const { runStatusline, defaultDeps } = await import('./statusline/run.js');
35
- const line = await runStatusline(input, defaultDeps());
36
- process.stdout.write(line);
64
+ let out = await runStatusline(input, defaultDeps());
65
+ // --wrap '<cmd>': run the user's existing statusline with the same stdin and
66
+ // prepend its output, so claudial co-exists with their bar instead of replacing it.
67
+ const w = args.indexOf('--wrap');
68
+ if (w >= 0 && args[w + 1]) {
69
+ try {
70
+ const { execSync } = await import('node:child_process');
71
+ const theirs = execSync(args[w + 1], { input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], timeout: 2000 }).replace(/\n+$/, '');
72
+ if (theirs)
73
+ out = `${theirs} ${out}`;
74
+ }
75
+ catch { /* their command failed — just show ours */ }
76
+ }
77
+ process.stdout.write(out);
37
78
  process.exit(0);
38
79
  }
39
80
  let seasonId;
@@ -1,71 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from 'react';
3
2
  import { Box, Text, useInput, useApp } from 'ink';
4
3
  import { ACCENT } from '../ui/Header.js';
5
- export function Wizard({ defaultShell, allowTmux, onDone }) {
4
+ /**
5
+ * A single confirmation: install the live-score statusline globally into Claude
6
+ * Code. No scope or tmux prompts — global is the one supported path.
7
+ */
8
+ export function Wizard({ defaultShell, onDone }) {
6
9
  const { exit } = useApp();
7
- const [step, setStep] = useState('statusline');
8
- const [statusline, setStatusline] = useState(true);
9
- const [scope, setScope] = useState('global');
10
- const [tmux, setTmux] = useState(false);
11
- const [shell, setShell] = useState(defaultShell);
12
- const finish = () => {
13
- onDone({ statusline, scope, tmux, shell, yes: true, interactive: false });
14
- exit();
15
- };
16
10
  useInput((input, key) => {
17
- const yes = input.toLowerCase() === 'y';
18
- const no = input.toLowerCase() === 'n';
19
- if (input === 'q') {
11
+ if (input === 'q' || input.toLowerCase() === 'n') {
20
12
  exit();
21
13
  return;
22
14
  }
23
- if (step === 'statusline') {
24
- if (yes || key.return) {
25
- setStatusline(true);
26
- setStep('scope');
27
- }
28
- else if (no) {
29
- setStatusline(false);
30
- setStep(allowTmux ? 'tmux' : 'confirm');
31
- }
32
- }
33
- else if (step === 'scope') {
34
- if (input === 'g' || key.return) {
35
- setScope('global');
36
- setStep(allowTmux ? 'tmux' : 'confirm');
37
- }
38
- else if (input === 'p') {
39
- setScope('project');
40
- setStep(allowTmux ? 'tmux' : 'confirm');
41
- }
42
- }
43
- else if (step === 'tmux') {
44
- if (yes) {
45
- setTmux(true);
46
- setStep('shell');
47
- }
48
- else if (no || key.return) {
49
- setTmux(false);
50
- setStep('confirm');
51
- }
52
- }
53
- else if (step === 'shell') {
54
- if (input === 'z')
55
- setShell('zsh');
56
- else if (input === 'b')
57
- setShell('bash');
58
- else if (input === 'f')
59
- setShell('fish');
60
- if (key.return || 'zbf'.includes(input))
61
- setStep('confirm');
62
- }
63
- else if (step === 'confirm') {
64
- if (yes || key.return)
65
- finish();
66
- else if (no)
67
- exit();
15
+ if (key.return || input.toLowerCase() === 'y') {
16
+ onDone({ statusline: true, scope: 'global', tmux: false, shell: defaultShell, yes: true, interactive: false });
17
+ exit();
68
18
  }
69
19
  });
70
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: _jsx(Text, { bold: true, color: ACCENT, children: "claudial setup" }) }), step === 'statusline' && _jsx(Text, { children: "Add the live-score statusline inside Claude Code? (Y/n)" }), step === 'scope' && _jsx(Text, { children: "Scope \u2014 [g]lobal (~/.claude) or [p]roject (.claude)? (G/p)" }), step === 'tmux' && _jsx(Text, { children: "Also add the tmux ticker alias (claude-mundial)? (y/N)" }), step === 'shell' && _jsxs(Text, { children: ["Shell for the alias \u2014 [z]sh [b]ash [f]ish? (default ", shell, ")"] }), step === 'confirm' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "About to apply:" }), statusline && _jsxs(Text, { dimColor: true, children: [" \u2022 statusline \u2192 ", scope] }), tmux && _jsxs(Text, { dimColor: true, children: [" \u2022 tmux alias \u2192 ", shell] }), !statusline && !tmux && _jsx(Text, { dimColor: true, children: " \u2022 nothing selected" }), _jsx(Text, { children: "Proceed? (Y/n)" })] })), _jsx(Text, { dimColor: true, children: "q to quit" })] }));
20
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: _jsx(Text, { bold: true, color: ACCENT, children: "claudial setup" }) }), _jsx(Text, { children: "Install the live-score statusline into Claude Code? (Y/n)" }), _jsx(Text, { dimColor: true, children: " \u2022 statusline \u2192 global (~/.claude/settings.json)" }), _jsx(Text, { dimColor: true, children: "q to quit" })] }));
71
21
  }
@@ -1,11 +1,12 @@
1
1
  import { installStatusline } from './settings.js';
2
2
  import { appendAlias } from './shellrc.js';
3
3
  export function applySetup(opts, ctx) {
4
- const report = { statuslineWritten: false, statuslineBackedUp: false, aliasAppended: false };
4
+ const report = { statuslineWritten: false, statuslineBackedUp: false, statuslineWrapped: false, aliasAppended: false };
5
5
  if (opts.statusline) {
6
6
  const r = installStatusline(ctx.settingsPath, ctx.command, ctx.conflict);
7
7
  report.statuslineWritten = r.written;
8
8
  report.statuslineBackedUp = r.backedUp;
9
+ report.statuslineWrapped = r.wrapped;
9
10
  }
10
11
  if (opts.tmux) {
11
12
  report.aliasAppended = appendAlias(ctx.rcPath, ctx.shell).appended;
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import React from 'react';
4
4
  import { render } from 'ink';
5
5
  import { parseFlags } from './flags.js';
6
- import { detectShell, rcPathFor, isOnPath, hasTmux, isWindows } from './detect.js';
6
+ import { detectShell, rcPathFor, isOnPath } from './detect.js';
7
7
  import { applySetup } from './apply.js';
8
8
  import { Wizard } from './Wizard.js';
9
9
  function commandFor() {
@@ -11,9 +11,8 @@ function commandFor() {
11
11
  }
12
12
  function contextFor(opts) {
13
13
  const shell = opts.shell ?? detectShell();
14
- const settingsPath = opts.scope === 'project'
15
- ? join(process.cwd(), '.claude', 'settings.json')
16
- : join(homedir(), '.claude', 'settings.json');
14
+ // Always global — setup installs the statusline into ~/.claude/settings.json.
15
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
17
16
  return {
18
17
  settingsPath,
19
18
  rcPath: rcPathFor(shell, homedir()),
@@ -25,6 +24,8 @@ function contextFor(opts) {
25
24
  function printReport(opts, ctx, report) {
26
25
  if (report.statuslineWritten) {
27
26
  console.log(`✓ statusline installed → ${ctx.settingsPath}`);
27
+ if (report.statuslineWrapped)
28
+ console.log(' (kept your existing status line and appended the score)');
28
29
  if (report.statuslineBackedUp)
29
30
  console.log(` (previous settings backed up to ${ctx.settingsPath}.bak)`);
30
31
  console.log(' Restart Claude Code to see the score bar.');
@@ -55,7 +56,6 @@ export async function runSetup(args) {
55
56
  await new Promise((resolve) => {
56
57
  const { waitUntilExit } = render(React.createElement(Wizard, {
57
58
  defaultShell: detectShell(),
58
- allowTmux: !isWindows() && hasTmux(),
59
59
  onDone: (final) => run(final),
60
60
  }));
61
61
  waitUntilExit().then(() => resolve());
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync, copyFileSync, existsSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  export function statusLineBlock(command) {
4
- return { type: 'command', command, refreshInterval: 3 };
4
+ return { type: 'command', command, refreshInterval: 1 };
5
5
  }
6
6
  /** Pure merge. Returns the new settings object and whether it changed. */
7
7
  export function mergeSettings(existing, block, conflict) {
@@ -11,7 +11,7 @@ export function mergeSettings(existing, block, conflict) {
11
11
  }
12
12
  return { settings: { ...existing, statusLine: block }, changed: true };
13
13
  }
14
- /** Read → merge → (backup) → write. Creates parent dirs and a .bak. */
14
+ /** Read → (wrap existing) → merge → (backup) → write. Creates parent dirs and a .bak. */
15
15
  export function installStatusline(settingsPath, command, conflict) {
16
16
  let existing = {};
17
17
  const fileExists = existsSync(settingsPath);
@@ -23,9 +23,30 @@ export function installStatusline(settingsPath, command, conflict) {
23
23
  existing = {};
24
24
  }
25
25
  }
26
- const { settings, changed } = mergeSettings(existing, statusLineBlock(command), conflict);
26
+ // Co-exist with a user's existing statusLine: wrap a foreign command so both
27
+ // show (claudial runs theirs via --wrap and appends the score). If ours is
28
+ // already there, preserve any --wrap suffix and just refresh the rest.
29
+ const existingCmd = typeof existing?.statusLine?.command === 'string' ? existing.statusLine.command : '';
30
+ let finalCommand = command;
31
+ let wrapped = false;
32
+ if (existingCmd.includes('claudial --statusline')) {
33
+ const w = existingCmd.indexOf(' --wrap ');
34
+ if (w >= 0) {
35
+ finalCommand = command + existingCmd.slice(w);
36
+ wrapped = true;
37
+ }
38
+ }
39
+ else if (existingCmd) {
40
+ finalCommand = `${command} --wrap '${existingCmd.replace(/'/g, `'\\''`)}'`;
41
+ wrapped = true;
42
+ }
43
+ const block = statusLineBlock(finalCommand);
44
+ if (existing.statusLine && JSON.stringify(existing.statusLine) === JSON.stringify(block)) {
45
+ return { written: false, backedUp: false, wrapped };
46
+ }
47
+ const { settings, changed } = mergeSettings(existing, block, conflict);
27
48
  if (!changed)
28
- return { written: false, backedUp: false };
49
+ return { written: false, backedUp: false, wrapped };
29
50
  let backedUp = false;
30
51
  if (fileExists) {
31
52
  copyFileSync(settingsPath, `${settingsPath}.bak`);
@@ -33,5 +54,5 @@ export function installStatusline(settingsPath, command, conflict) {
33
54
  }
34
55
  mkdirSync(dirname(settingsPath), { recursive: true });
35
56
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
36
- return { written: true, backedUp };
57
+ return { written: true, backedUp, wrapped };
37
58
  }
@@ -0,0 +1,18 @@
1
+ // A gentle "goal kick → net" animation for the idle (between-matches) status
2
+ // line. Claude Code repaints the status line on a timer, so one frame shows per
3
+ // repaint — the frame is chosen from wall-clock time so it advances at a steady
4
+ // rate regardless of how often the command happens to be invoked.
5
+ export const FRAME_MS = 1000;
6
+ const FRAMES = [
7
+ '⚽·····🥅',
8
+ '·⚽····🥅',
9
+ '··⚽···🥅',
10
+ '···⚽··🥅',
11
+ '····⚽·🥅',
12
+ '·····⚽🥅',
13
+ '· G O A L! ·',
14
+ ];
15
+ /** The goal-kick animation frame for time `now` (ms); loops continuously. */
16
+ export function goalKickFrame(now = Date.now()) {
17
+ return FRAMES[Math.floor(now / FRAME_MS) % FRAMES.length];
18
+ }
@@ -8,9 +8,14 @@ const LOCK_STALE_MS = 30_000;
8
8
  // ACCENT #D97757 → truecolor; bold. Matches the old bash celebration styling.
9
9
  const GOAL_OPEN = '\x1b[1;38;2;217;119;87m';
10
10
  const GOAL_CLOSE = '\x1b[0m';
11
+ // RED #E5484D — the red-card flash.
12
+ const RED_OPEN = '\x1b[1;38;2;229;72;77m';
11
13
  function goalText(m) {
12
14
  return `${GOAL_OPEN}⚽ G O O O L · ${homeTag(m.home.code)} ${m.homeScore ?? 0}—${m.awayScore ?? 0} ${awayTag(m.away.code)}${GOAL_CLOSE}`;
13
15
  }
16
+ function redCardText(rc) {
17
+ return `${RED_OPEN}🟥 R E D · ${rc.player.toUpperCase()} · ${homeTag(rc.homeCode)} — ${awayTag(rc.awayCode)}${GOAL_CLOSE}`;
18
+ }
14
19
  export function makeCache(dir = join(tmpdir(), 'claudial-statusline')) {
15
20
  mkdirSync(dir, { recursive: true });
16
21
  const cacheFile = join(dir, 'line');
@@ -18,6 +23,9 @@ export function makeCache(dir = join(tmpdir(), 'claudial-statusline')) {
18
23
  const stateFile = join(dir, 'goals.json');
19
24
  const goalFile = join(dir, 'goal');
20
25
  const goalTsFile = join(dir, 'goalts');
26
+ const redSeenFile = join(dir, 'redseen.json');
27
+ const redFile = join(dir, 'red');
28
+ const redTsFile = join(dir, 'redts');
21
29
  const atomicWrite = (path, data) => {
22
30
  const tmp = `${path}.tmp`;
23
31
  writeFileSync(tmp, data);
@@ -95,6 +103,11 @@ export function makeCache(dir = join(tmpdir(), 'claudial-statusline')) {
95
103
  }
96
104
  atomicWrite(stateFile, JSON.stringify(cur));
97
105
  },
106
+ armGoal(match, now = Date.now()) {
107
+ // Directly fire a celebration (used by `claudial --mock-goal` for demos).
108
+ atomicWrite(goalFile, goalText(match));
109
+ atomicWrite(goalTsFile, String(now));
110
+ },
98
111
  activeGoalLine(now = Date.now()) {
99
112
  if (!existsSync(goalTsFile) || !existsSync(goalFile))
100
113
  return null;
@@ -103,5 +116,41 @@ export function makeCache(dir = join(tmpdir(), 'claudial-statusline')) {
103
116
  return null;
104
117
  return readFileSync(goalFile, 'utf8');
105
118
  },
119
+ updateRedCards(reds, now = Date.now()) {
120
+ const firstRun = !existsSync(redSeenFile);
121
+ const seen = new Set(firstRun ? [] : (() => {
122
+ try {
123
+ return JSON.parse(readFileSync(redSeenFile, 'utf8'));
124
+ }
125
+ catch {
126
+ return [];
127
+ }
128
+ })());
129
+ let armed = false;
130
+ for (const rc of reds) {
131
+ if (seen.has(rc.id))
132
+ continue;
133
+ // First run never fires (don't replay pre-existing reds when first seen mid-match).
134
+ if (!firstRun && !armed) {
135
+ atomicWrite(redFile, redCardText(rc));
136
+ atomicWrite(redTsFile, String(now));
137
+ armed = true;
138
+ }
139
+ seen.add(rc.id);
140
+ }
141
+ atomicWrite(redSeenFile, JSON.stringify([...seen]));
142
+ },
143
+ armRedCard(rc, now = Date.now()) {
144
+ atomicWrite(redFile, redCardText(rc));
145
+ atomicWrite(redTsFile, String(now));
146
+ },
147
+ activeRedCardLine(now = Date.now()) {
148
+ if (!existsSync(redTsFile) || !existsSync(redFile))
149
+ return null;
150
+ const ts = Number(readFileSync(redTsFile, 'utf8'));
151
+ if (!Number.isFinite(ts) || now - ts > GOAL_WINDOW_MS)
152
+ return null;
153
+ return readFileSync(redFile, 'utf8');
154
+ },
106
155
  };
107
156
  }
@@ -1,7 +1,8 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { fetchLive as realFetchLive, fetchRecent as realFetchRecent, fetchUpcoming as realFetchUpcoming } from '../api/espn.js';
2
+ import { fetchLive as realFetchLive, fetchRecent as realFetchRecent, fetchUpcoming as realFetchUpcoming, fetchLiveRedCards as realFetchLiveRedCards } from '../api/espn.js';
3
3
  import { scoreLine } from './line.js';
4
4
  import { makeCache, TTL_MS } from './cache.js';
5
+ import { goalKickFrame } from './anim.js';
5
6
  const PLACEHOLDER = '⚽ claudial · warming up';
6
7
  function withTimeout(p, ms) {
7
8
  let id;
@@ -17,9 +18,10 @@ export async function runStatusline(input, deps, now = Date.now()) {
17
18
  const fresh = cached != null && cached.ageMs <= TTL_MS;
18
19
  if (!fresh && cache.tryLock(now)) {
19
20
  try {
20
- const [liveM, recentM, upcomingM] = await withTimeout(Promise.all([deps.fetchLive(), deps.fetchRecent(), deps.fetchUpcoming(2026)]), deps.timeoutMs);
21
+ const [liveM, recentM, upcomingM, redsM] = await withTimeout(Promise.all([deps.fetchLive(), deps.fetchRecent(), deps.fetchUpcoming(2026), deps.fetchRedCards()]), deps.timeoutMs);
21
22
  const next = scoreLine(liveM, recentM, upcomingM, now);
22
23
  cache.updateGoalState(liveM, now);
24
+ cache.updateRedCards(redsM, now);
23
25
  if (next) {
24
26
  cache.write(next, now);
25
27
  line = next;
@@ -32,7 +34,10 @@ export async function runStatusline(input, deps, now = Date.now()) {
32
34
  cache.unlock();
33
35
  }
34
36
  }
35
- const score = cache.activeGoalLine(now) ?? line ?? PLACEHOLDER;
37
+ const goal = cache.activeGoalLine(now);
38
+ const red = cache.activeRedCardLine(now);
39
+ // Goal → animated kick; red card → static flash; otherwise the idle/live line.
40
+ const score = goal ? `${goalKickFrame(now)} ${goal}` : (red ?? line ?? PLACEHOLDER);
36
41
  const branch = deps.branchOf(input);
37
42
  return branch ? `${score} · ${branch}` : score;
38
43
  }
@@ -61,6 +66,7 @@ export function defaultDeps() {
61
66
  fetchLive: realFetchLive,
62
67
  fetchRecent: () => realFetchRecent(2026),
63
68
  fetchUpcoming: realFetchUpcoming,
69
+ fetchRedCards: realFetchLiveRedCards,
64
70
  cache: makeCache(),
65
71
  branchOf: defaultBranchOf,
66
72
  timeoutMs: 3000,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudial",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Live World Cup 2026 fixtures in your terminal — goal, red-card and VAR takeovers included",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "node": ">=18.18"
14
14
  },
15
15
  "scripts": {
16
- "build": "rm -rf dist && tsc",
16
+ "build": "rm -rf dist && tsc && chmod +x dist/index.js",
17
17
  "dev": "tsx src/index.tsx",
18
18
  "test": "vitest run",
19
19
  "prepublishOnly": "npm run build"