claudial 0.3.0 → 0.5.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,12 @@
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
7
  One line. Zero install. Every score, while you work.
8
8
 
9
9
  ```
10
- npx claudial
10
+ npx claudial && claudial setup
11
11
  ```
12
12
 
13
13
  <br/>
@@ -24,103 +24,66 @@ npx claudial
24
24
 
25
25
  ---
26
26
 
27
- Live matches with scores, minutes and scorers on top. Upcoming fixtures in
28
- your local time below. Auto-refreshing while you work.
27
+ Live World Cup scores right inside Claude Code's status bar — under the input
28
+ box, always visible while you work, lighting up the moment anyone scores:
29
29
 
30
- ```
31
- ▐▛███▜▌ WORLD CUP 2026 · LIVE
32
- ▝▜█████▛▘ ───────────────────────
33
-
34
- ⏺ LIVE 67'
35
- Argentina 2 — 1 Mexico
36
- ⚽ 23' Messi · 51' Álvarez | 44' Lozano
30
+ <div align="center">
37
31
 
38
- ⏺ LIVE 12'
39
- France 0 — 0 Senegal
32
+ ![claudial — live World Cup 2026 scores in Claude Code's status bar](demo.gif)
40
33
 
41
- ○ UPCOMING
42
- 18:00 Brazil — Croatia
43
- 21:00 Spain — Morocco
34
+ </div>
44
35
 
45
- r refresh · q quit
46
- ```
36
+ ## Claude Code, World Cup edition
47
37
 
48
- And when a goal goes in — anywhere in the tournament — the whole screen
49
- takes over:
38
+ The whole point: your Claude Code, score-aware. One command and the live score
39
+ sits right in Claude Code's status bar — under the input box, always visible
40
+ while Claude works, with nothing to wire up by hand.
50
41
 
42
+ ```bash
43
+ npm install -g claudial
44
+ claudial setup
51
45
  ```
52
46
 
47
+ The wizard drops a live-score statusline into your Claude Code settings:
53
48
 
54
- █▀▀█ █▀▀█ █▀▀█ █
55
- █ ▄▄ █ █ █▀▀█ █
56
- ▀▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀
57
-
58
- LIONEL MESSI · 23'
59
-
60
- ARGENTINA 2 — 1 MEXICO
61
-
62
-
49
+ ```
50
+ ⚽ QAT 🇶🇦 0—1 🇨🇭 SUI 67' · main ← during a match
51
+ ○ QAT 🇶🇦 — 🇨🇭 SUI 10:00 PM · main ← between matches (next kickoff, or last result)
52
+ ⚽ G O O O L · ARG 🇦🇷 1—0 🇲🇽 MEX ← for 15s whenever anyone scores
53
+ 🟥 R E D · OTAMENDI · ARG 🇦🇷 — 🇲🇽 MEX ← for 15s on a red card
63
54
  ```
64
55
 
65
- Four seconds of glory, then back to the board.
56
+ It refreshes every few seconds and is pure Node, so it runs on macOS and
57
+ Windows too, in any shell. Restart Claude Code after setup to see it.
58
+ Scriptable, no prompts: `claudial setup --statusline --global --yes`.
66
59
 
67
- ## Claude Code, World Cup edition
60
+ **Already run a custom status line?** `claudial setup` keeps it — it wraps your
61
+ existing one and appends the score, so nothing you had is lost.
68
62
 
69
- The whole point. Two commands and your Claude Code becomes a World Cup
70
- workstation — Claude working on top, every live score ticking in a strip
71
- below, forever.
63
+ ## The full dashboard
72
64
 
73
- Needs [tmux](https://github.com/tmux/tmux) (`sudo pacman -S tmux` /
74
- `sudo apt install tmux` / `brew install tmux`), then:
65
+ Prefer the full board beside Claude? `claudial` opens a live dashboard — every
66
+ match with scores, minutes and scorers, upcoming fixtures in your local time,
67
+ all auto-refreshing while you work. And when a goal goes in — anywhere in the
68
+ tournament — the whole screen takes over:
75
69
 
76
- ```bash
77
- npm install -g claudial
78
- cat >> ~/.zshrc <<'EOF'
79
- alias claude-mundial="tmux new-session 'claude' \; split-window -v -l 5 'claudial --ticker' \; select-pane -U"
80
- EOF
81
70
  ```
82
71
 
83
- Open a new shell, type `claude-mundial`, and:
84
72
 
85
- ```
86
- ┌──────────────────────────────────────┐
87
- │ > implement the parser │
88
- │ │
89
- │ ⏺ Working… │
90
- │ │
91
- ├──────────────────────────────────────┤
92
- │ ⏺ 67' QAT 0—1 SUI │
93
- │ ○ BRA—MOR Sun 01:00 │
94
- └──────────────────────────────────────┘
95
- ```
73
+ █▀▀█ █▀▀█ █▀▀█ █
74
+ █ ▄▄ █ █ █▀▀█ █
75
+ ▀▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀
96
76
 
97
- When a goal goes in, the strip lights up while Claude keeps working.
98
- (`bash` users: append to `~/.bashrc` instead. Prefer the full board beside
99
- Claude rather than a strip below? Use
100
- `split-window -h -l 44 'claudial'` in the alias.)
77
+ LIONEL MESSI · 23'
101
78
 
102
- Don't want to hand-edit your shell config? `claudial setup --tmux` appends the
103
- right alias for you — zsh, bash or fish — idempotently.
79
+ ARG 🇦🇷 2 — 1 🇲🇽 MEX
104
80
 
105
- Want the score *inside* Claude Code itself — in its status bar, no tmux at
106
- all? Install everything in one step:
107
81
 
108
- ```bash
109
- npm install -g claudial
110
- claudial setup
111
82
  ```
112
83
 
113
- The wizard adds a live-score statusline (`⚽ QAT 0—1 SUI 67' · main`, refreshed
114
- every few seconds) to your Claude Code settings, and can also wire up the tmux
115
- ticker alias below. It works on zsh, bash and fish, and the statusline itself
116
- is pure Node — no `jq`/`flock`/`curl`, so it runs on macOS and Windows too.
117
- Non-interactive: `claudial setup --statusline --global --tmux --shell zsh --yes`.
118
-
119
- ## Why a TUI
120
-
121
- The World Cup happens during work hours somewhere. This sits in a terminal
122
- split, costs nothing to glance at, and celebrates louder than a push
123
- notification — without you ever opening a browser tab.
84
+ Four seconds of glory, then back to the board. It costs nothing to glance at,
85
+ and celebrates louder than a push notification — without you ever opening a
86
+ browser tab.
124
87
 
125
88
  ## Usage
126
89
 
@@ -144,9 +107,9 @@ Requires Node ≥ 18. No account, no API key, no config.
144
107
  | `r` | refresh now |
145
108
  | `q` | quit |
146
109
 
147
- Beyond goals, every live match carries its incident feed: yellow cards,
148
- substitutions, injury time — and a `⚖ VAR` badge the moment a review starts.
149
- Red cards and VAR decisions get the full-screen treatment, same as goals.
110
+ Beyond goals, every live match carries its incident feed — yellow cards,
111
+ substitutions, injury time — and red cards get the full-screen treatment, same
112
+ as goals.
150
113
 
151
114
  ## Status
152
115
 
@@ -154,6 +117,10 @@ v1 is being built live during the group stage. Follow the commits.
154
117
 
155
118
  ## Notes
156
119
 
120
+ - Seeing letter codes instead of country flags (`🇦🇷` showing as `AR`)? Your
121
+ terminal isn't rendering flag emoji. Install [Konsole](https://konsole.kde.org/)
122
+ — it shows them out of the box. (macOS terminals already do; Windows
123
+ Terminal does not.)
157
124
  - Match data comes from [ESPN's public soccer scoreboard](https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard)
158
125
  — facts only (scores, scorers, cards), no logos or branding. See
159
126
  [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
  }
package/dist/snapshot.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { fetchLive, fetchRecent, fetchUpcoming } from './api/espn.js';
2
2
  import { formatKickoff } from './ui/UpcomingSection.js';
3
+ import { homeTag, awayTag } from './ui/flags.js';
3
4
  function line(m) {
4
5
  if (m.status === 'upcoming')
5
- return `o ${formatKickoff(m.startTimestamp).padEnd(13)} ${m.home.name} - ${m.away.name}`;
6
+ return `o ${formatKickoff(m.startTimestamp).padEnd(13)} ${homeTag(m.home.code)} - ${awayTag(m.away.code)}`;
6
7
  const label = m.status === 'finished' ? 'FT' : m.status === 'halftime' ? 'HT' : `${m.minute ?? '?'}'`;
7
- return `* ${label.padEnd(4)} ${m.home.name} ${m.homeScore ?? '-'} - ${m.awayScore ?? '-'} ${m.away.name}`;
8
+ return `* ${label.padEnd(4)} ${homeTag(m.home.code)} ${m.homeScore ?? '-'} - ${m.awayScore ?? '-'} ${awayTag(m.away.code)}`;
8
9
  }
9
10
  export async function printSnapshot(seasonId) {
10
11
  const [live, upcoming, recent] = await Promise.all([
@@ -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
+ }
@@ -1,14 +1,20 @@
1
1
  import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, renameSync, statSync, existsSync, unlinkSync, utimesSync, futimesSync, } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { homeTag, awayTag } from '../ui/flags.js';
4
5
  export const TTL_MS = 10_000;
5
6
  export const GOAL_WINDOW_MS = 15_000;
6
7
  const LOCK_STALE_MS = 30_000;
7
8
  // ACCENT #D97757 → truecolor; bold. Matches the old bash celebration styling.
8
9
  const GOAL_OPEN = '\x1b[1;38;2;217;119;87m';
9
10
  const GOAL_CLOSE = '\x1b[0m';
11
+ // RED #E5484D — the red-card flash.
12
+ const RED_OPEN = '\x1b[1;38;2;229;72;77m';
10
13
  function goalText(m) {
11
- return `${GOAL_OPEN}⚽ G O O O L · ${m.home.code} ${m.homeScore ?? 0}—${m.awayScore ?? 0} ${m.away.code}${GOAL_CLOSE}`;
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}`;
15
+ }
16
+ function redCardText(rc) {
17
+ return `${RED_OPEN}🟥 R E D · ${rc.player.toUpperCase()} · ${homeTag(rc.homeCode)} — ${awayTag(rc.awayCode)}${GOAL_CLOSE}`;
12
18
  }
13
19
  export function makeCache(dir = join(tmpdir(), 'claudial-statusline')) {
14
20
  mkdirSync(dir, { recursive: true });
@@ -17,6 +23,9 @@ export function makeCache(dir = join(tmpdir(), 'claudial-statusline')) {
17
23
  const stateFile = join(dir, 'goals.json');
18
24
  const goalFile = join(dir, 'goal');
19
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');
20
29
  const atomicWrite = (path, data) => {
21
30
  const tmp = `${path}.tmp`;
22
31
  writeFileSync(tmp, data);
@@ -94,6 +103,11 @@ export function makeCache(dir = join(tmpdir(), 'claudial-statusline')) {
94
103
  }
95
104
  atomicWrite(stateFile, JSON.stringify(cur));
96
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
+ },
97
111
  activeGoalLine(now = Date.now()) {
98
112
  if (!existsSync(goalTsFile) || !existsSync(goalFile))
99
113
  return null;
@@ -102,5 +116,41 @@ export function makeCache(dir = join(tmpdir(), 'claudial-statusline')) {
102
116
  return null;
103
117
  return readFileSync(goalFile, 'utf8');
104
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
+ },
105
155
  };
106
156
  }
@@ -1,21 +1,22 @@
1
1
  import { formatKickoff } from '../ui/UpcomingSection.js';
2
+ import { homeTag, awayTag } from '../ui/flags.js';
2
3
  // Assumed wall-clock length of a match (kickoff → ~full time). ESPN exposes no
3
4
  // end-time, so a finished match is anchored at kickoff + this for proximity math.
4
5
  const MATCH_MS = 2 * 60 * 60 * 1000;
5
- /** A live/halftime match: "⚽ QAT 0—1 SUI 67'" (em-dash U+2014 between scores). */
6
+ /** A live/halftime match: "⚽ QAT 🇶🇦 0—1 🇨🇭 SUI 67'" (mirrored code+flag). */
6
7
  export function liveLine(match) {
7
8
  const clock = match.status === 'halftime' ? ' HT'
8
9
  : match.minute != null ? ` ${match.minute}'`
9
10
  : '';
10
- return `⚽ ${match.home.code} ${match.homeScore ?? 0}—${match.awayScore ?? 0} ${match.away.code}${clock}`;
11
+ return `⚽ ${homeTag(match.home.code)} ${match.homeScore ?? 0}—${match.awayScore ?? 0} ${awayTag(match.away.code)}${clock}`;
11
12
  }
12
- /** A finished match with its result: "⚽ QAT 2—1 SUI FT". */
13
+ /** A finished match with its result: "⚽ QAT 🇶🇦 2—1 🇨🇭 SUI FT". */
13
14
  export function finishedLine(match) {
14
- return `⚽ ${match.home.code} ${match.homeScore ?? 0}—${match.awayScore ?? 0} ${match.away.code} FT`;
15
+ return `⚽ ${homeTag(match.home.code)} ${match.homeScore ?? 0}—${match.awayScore ?? 0} ${awayTag(match.away.code)} FT`;
15
16
  }
16
- /** The next kickoff: "○ BRA—MOR Sun 01:00". */
17
+ /** The next kickoff: "○ BRA 🇧🇷 — 🇲🇦 MAR Sun 01:00". */
17
18
  function upcomingLine(match, now) {
18
- return `○ ${match.home.code}—${match.away.code} ${formatKickoff(match.startTimestamp, now)}`;
19
+ return `○ ${homeTag(match.home.code)} — ${awayTag(match.away.code)} ${formatKickoff(match.startTimestamp, now)}`;
19
20
  }
20
21
  /**
21
22
  * Live matches joined by two spaces. Otherwise show whichever moment is closer
@@ -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,
@@ -2,6 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { ACCENT } from './Header.js';
4
4
  import { formatKickoff } from './UpcomingSection.js';
5
+ import { homeTag, awayTag } from './flags.js';
5
6
  export function ScorerLine({ incidents }) {
6
7
  const goals = incidents.filter((i) => i.kind === 'goal');
7
8
  if (goals.length === 0)
@@ -32,12 +33,12 @@ function statusLabel(m) {
32
33
  export function MatchRow({ m, incidents, compact }) {
33
34
  if (m.status === 'upcoming') {
34
35
  if (compact) {
35
- return _jsxs(Text, { dimColor: true, children: ["\u25CB ", m.home.code, "\u2014", m.away.code, " ", formatKickoff(m.startTimestamp)] });
36
+ return _jsxs(Text, { dimColor: true, children: ["\u25CB ", homeTag(m.home.code), " \u2014 ", awayTag(m.away.code), " ", formatKickoff(m.startTimestamp)] });
36
37
  }
37
- return (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["\u25CB ", formatKickoff(m.startTimestamp).padEnd(13)] }), m.home.name, " \u2014 ", m.away.name, m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null] }));
38
+ return (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["\u25CB ", formatKickoff(m.startTimestamp).padEnd(13)] }), homeTag(m.home.code), " \u2014 ", awayTag(m.away.code), m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null] }));
38
39
  }
39
40
  if (compact) {
40
- return (_jsxs(Text, { children: [_jsx(Text, { color: m.status === 'finished' ? undefined : ACCENT, children: "\u23FA" }), _jsxs(Text, { dimColor: true, children: [" ", statusLabel(m), " "] }), m.home.code, " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', "\u2014", m.awayScore ?? '–'] }), " ", m.away.code, m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u2696" }) : null] }));
41
+ return (_jsxs(Text, { children: [_jsx(Text, { color: m.status === 'finished' ? undefined : ACCENT, children: "\u23FA" }), _jsxs(Text, { dimColor: true, children: [" ", statusLabel(m), " "] }), homeTag(m.home.code), " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', "\u2014", m.awayScore ?? '–'] }), " ", awayTag(m.away.code), m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u2696" }) : null] }));
41
42
  }
42
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: m.status === 'finished' ? undefined : ACCENT, children: "\u23FA" }), _jsxs(Text, { bold: m.status !== 'finished', dimColor: m.status === 'finished', children: [" ", statusLabel(m)] }), m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null, m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u00B7 \u2696 VAR" }) : null] }), _jsxs(Text, { children: [' ', m.home.name, " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', " \u2014 ", m.awayScore ?? '–'] }), " ", m.away.name] }), _jsx(ScorerLine, { incidents: incidents }), _jsx(FeedLine, { incidents: incidents })] }));
43
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: m.status === 'finished' ? undefined : ACCENT, children: "\u23FA" }), _jsxs(Text, { bold: m.status !== 'finished', dimColor: m.status === 'finished', children: [" ", statusLabel(m)] }), m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null, m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u00B7 \u2696 VAR" }) : null] }), _jsxs(Text, { children: [' ', homeTag(m.home.code), " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', " \u2014 ", m.awayScore ?? '–'] }), " ", awayTag(m.away.code)] }), _jsx(ScorerLine, { incidents: incidents }), _jsx(FeedLine, { incidents: incidents })] }));
43
44
  }
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { GOAL_ART, VAR_ART, RED_CARD_ART, spacedCaps } from '../banner.js';
4
4
  import { ACCENT, RED } from './Header.js';
5
+ import { homeTag, awayTag } from './flags.js';
5
6
  const ART = {
6
7
  goal: { art: GOAL_ART, color: ACCENT, caption: null },
7
8
  redcard: { art: RED_CARD_ART, color: RED, caption: 'R E D C A R D' },
@@ -16,7 +17,7 @@ export function TakeoverView({ takeover }) {
16
17
  const rows = process.stdout.rows ?? 24;
17
18
  if (rows < 14) {
18
19
  const label = kind === 'goal' ? 'G O A L' : kind === 'redcard' ? 'R E D C A R D' : 'V A R';
19
- return (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: rows - 1, children: [_jsx(Text, { bold: true, color: color, wrap: "truncate", children: label }), _jsxs(Text, { bold: true, wrap: "truncate", children: [spacedCaps(fallback), takeover.minute != null ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", takeover.minute, "'"] }) : null] }), takeover.detail ? _jsx(Text, { dimColor: true, wrap: "truncate", children: takeover.detail }) : null, _jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { dimColor: true, children: [match.home.name.toUpperCase(), " "] }), _jsxs(Text, { bold: true, color: ACCENT, children: [takeover.homeScore, " \u2014 ", takeover.awayScore] }), _jsxs(Text, { dimColor: true, children: [" ", match.away.name.toUpperCase()] })] })] }));
20
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: rows - 1, children: [_jsx(Text, { bold: true, color: color, wrap: "truncate", children: label }), _jsxs(Text, { bold: true, wrap: "truncate", children: [spacedCaps(fallback), takeover.minute != null ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", takeover.minute, "'"] }) : null] }), takeover.detail ? _jsx(Text, { dimColor: true, wrap: "truncate", children: takeover.detail }) : null, _jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { dimColor: true, children: [homeTag(match.home.code), " "] }), _jsxs(Text, { bold: true, color: ACCENT, children: [takeover.homeScore, " \u2014 ", takeover.awayScore] }), _jsxs(Text, { dimColor: true, children: [" ", awayTag(match.away.code)] })] })] }));
20
21
  }
21
- return (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: rows - 1, children: [art.map((line, i) => (_jsx(Text, { bold: true, color: color, wrap: "truncate", children: line }, i))), caption ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, color: color, wrap: "truncate", children: caption }) })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { bold: true, children: [spacedCaps(fallback), takeover.minute != null ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", takeover.minute, "'"] }) : null] }) }), takeover.detail ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: takeover.detail }) })) : null, _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: [match.home.name.toUpperCase(), " "] }), _jsxs(Text, { bold: true, color: ACCENT, children: [takeover.homeScore, " \u2014 ", takeover.awayScore] }), _jsxs(Text, { dimColor: true, children: [" ", match.away.name.toUpperCase()] })] })] }));
22
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: rows - 1, children: [art.map((line, i) => (_jsx(Text, { bold: true, color: color, wrap: "truncate", children: line }, i))), caption ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, color: color, wrap: "truncate", children: caption }) })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { bold: true, children: [spacedCaps(fallback), takeover.minute != null ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", takeover.minute, "'"] }) : null] }) }), takeover.detail ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: takeover.detail }) })) : null, _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: [homeTag(match.home.code), " "] }), _jsxs(Text, { bold: true, color: ACCENT, children: [takeover.homeScore, " \u2014 ", takeover.awayScore] }), _jsxs(Text, { dimColor: true, children: [" ", awayTag(match.away.code)] })] })] }));
22
23
  }
package/dist/ui/Ticker.js CHANGED
@@ -3,13 +3,14 @@ import { Box, Text } from 'ink';
3
3
  import { formatKickoff } from './UpcomingSection.js';
4
4
  import { ACCENT, RED } from './Header.js';
5
5
  import { spacedCaps } from '../banner.js';
6
+ import { homeTag, awayTag } from './flags.js';
6
7
  function TickerTakeover({ t }) {
7
8
  const color = t.kind === 'redcard' ? RED : ACCENT;
8
9
  const label = t.kind === 'goal' ? 'G O A L' : t.kind === 'redcard' ? 'R E D' : 'V A R';
9
- return (_jsxs(Text, { bold: true, color: color, wrap: "truncate", children: [label, " \u00B7 ", t.who ? spacedCaps(t.who) : t.detail ?? '', " \u00B7 ", t.match.home.code, " ", t.homeScore, "\u2014", t.awayScore, " ", t.match.away.code] }));
10
+ return (_jsxs(Text, { bold: true, color: color, wrap: "truncate", children: [label, " \u00B7 ", t.who ? spacedCaps(t.who) : t.detail ?? '', " \u00B7 ", homeTag(t.match.home.code), " ", t.homeScore, "\u2014", t.awayScore, " ", awayTag(t.match.away.code)] }));
10
11
  }
11
12
  export function Ticker({ live, upcoming, takeover }) {
12
13
  if (takeover)
13
14
  return _jsx(TickerTakeover, { t: takeover });
14
- return (_jsxs(Box, { flexDirection: "column", children: [live.slice(0, 3).map((m) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: ACCENT, children: "\u23FA" }), _jsxs(Text, { dimColor: true, children: [" ", m.minute != null ? `${m.minute}'` : m.status === 'halftime' ? 'HT' : m.status === 'finished' ? 'FT' : '', " "] }), m.home.code, " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', "\u2014", m.awayScore ?? '–'] }), " ", m.away.code, m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u2696" }) : null] }, m.id))), upcoming[0] ? (_jsxs(Text, { dimColor: true, wrap: "truncate", children: ["\u25CB ", upcoming[0].home.code, "\u2014", upcoming[0].away.code, " ", formatKickoff(upcoming[0].startTimestamp)] })) : null] }));
15
+ return (_jsxs(Box, { flexDirection: "column", children: [live.slice(0, 3).map((m) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: ACCENT, children: "\u23FA" }), _jsxs(Text, { dimColor: true, children: [" ", m.minute != null ? `${m.minute}'` : m.status === 'halftime' ? 'HT' : m.status === 'finished' ? 'FT' : '', " "] }), homeTag(m.home.code), " ", _jsxs(Text, { bold: true, color: ACCENT, children: [m.homeScore ?? '–', "\u2014", m.awayScore ?? '–'] }), " ", awayTag(m.away.code), m.varInProgress ? _jsx(Text, { dimColor: true, children: " \u2696" }) : null] }, m.id))), upcoming[0] ? (_jsxs(Text, { dimColor: true, wrap: "truncate", children: ["\u25CB ", homeTag(upcoming[0].home.code), " \u2014 ", awayTag(upcoming[0].away.code), " ", formatKickoff(upcoming[0].startTimestamp)] })) : null] }));
15
16
  }
@@ -1,5 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { homeTag, awayTag } from './flags.js';
3
4
  export function formatKickoff(ts, now = Date.now()) {
4
5
  const d = new Date(ts * 1000);
5
6
  const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
@@ -15,7 +16,7 @@ export function UpcomingSection({ matches, compact }) {
15
16
  return null;
16
17
  if (compact) {
17
18
  const m = shown[0];
18
- return (_jsxs(Text, { dimColor: true, children: ["\u25CB ", m.home.code, "\u2014", m.away.code, " ", formatKickoff(m.startTimestamp)] }));
19
+ return (_jsxs(Text, { dimColor: true, children: ["\u25CB ", homeTag(m.home.code), " \u2014 ", awayTag(m.away.code), " ", formatKickoff(m.startTimestamp)] }));
19
20
  }
20
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "UPCOMING" }), shown.map((m) => (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["\u25CB ", formatKickoff(m.startTimestamp).padEnd(13)] }), m.home.name, " \u2014 ", m.away.name, m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null] }, m.id)))] }));
21
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "UPCOMING" }), shown.map((m) => (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["\u25CB ", formatKickoff(m.startTimestamp).padEnd(13)] }), homeTag(m.home.code), " \u2014 ", awayTag(m.away.code), m.group ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", m.group] }) : null] }, m.id)))] }));
21
22
  }
@@ -0,0 +1,43 @@
1
+ // Country flag emoji for a team, keyed on ESPN's 3-letter abbreviation
2
+ // (verified against the live FIFA World Cup feed — note ESPN uses MAR for
3
+ // Morocco). Bracket placeholders (1A, QFW1, RD16 W1, …) have no flag.
4
+ // ESPN abbreviation → ISO 3166-1 alpha-2.
5
+ const ISO2 = {
6
+ ALG: 'DZ', ARG: 'AR', AUS: 'AU', AUT: 'AT', BEL: 'BE', BIH: 'BA', BRA: 'BR',
7
+ CAN: 'CA', CIV: 'CI', COD: 'CD', COL: 'CO', CPV: 'CV', CRO: 'HR', CUW: 'CW',
8
+ CZE: 'CZ', ECU: 'EC', EGY: 'EG', ESP: 'ES', FRA: 'FR', GER: 'DE', GHA: 'GH',
9
+ HAI: 'HT', IRN: 'IR', IRQ: 'IQ', JOR: 'JO', JPN: 'JP', KOR: 'KR', KSA: 'SA',
10
+ MAR: 'MA', MEX: 'MX', NED: 'NL', NOR: 'NO', NZL: 'NZ', PAN: 'PA', PAR: 'PY',
11
+ POR: 'PT', QAT: 'QA', RSA: 'ZA', SEN: 'SN', SUI: 'CH', SWE: 'SE', TUN: 'TN',
12
+ TUR: 'TR', URU: 'UY', USA: 'US', UZB: 'UZ',
13
+ };
14
+ // Home nations have no ISO2 flag — use the subdivision tag sequence.
15
+ const SUBDIVISION = {
16
+ ENG: 'gbeng', SCO: 'gbsct', WAL: 'gbwls',
17
+ };
18
+ function regionalIndicator(iso2) {
19
+ return String.fromCodePoint(...[...iso2].map((c) => 0x1f1e6 + (c.charCodeAt(0) - 65)));
20
+ }
21
+ function subdivisionFlag(sub) {
22
+ return String.fromCodePoint(0x1f3f4, ...[...sub].map((c) => 0xe0000 + c.charCodeAt(0)), 0xe007f);
23
+ }
24
+ /** Flag emoji for a team code, or '' if unknown (e.g. bracket placeholders). */
25
+ export function flagFor(code) {
26
+ const iso2 = ISO2[code];
27
+ if (iso2)
28
+ return regionalIndicator(iso2);
29
+ const sub = SUBDIVISION[code];
30
+ if (sub)
31
+ return subdivisionFlag(sub);
32
+ return '';
33
+ }
34
+ /** Home side: "QAT 🇶🇦" (flag trails the code), or just the code if unmapped. */
35
+ export function homeTag(code) {
36
+ const f = flagFor(code);
37
+ return f ? `${code} ${f}` : code;
38
+ }
39
+ /** Away side: "🇨🇭 SUI" (flag leads the code), or just the code if unmapped. */
40
+ export function awayTag(code) {
41
+ const f = flagFor(code);
42
+ return f ? `${f} ${code}` : code;
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudial",
3
- "version": "0.3.0",
3
+ "version": "0.5.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"