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 +45 -78
- package/dist/api/espn.js +55 -0
- package/dist/index.js +43 -2
- package/dist/setup/Wizard.js +10 -60
- package/dist/setup/apply.js +2 -1
- package/dist/setup/index.js +5 -5
- package/dist/setup/settings.js +26 -5
- package/dist/snapshot.js +3 -2
- package/dist/statusline/anim.js +18 -0
- package/dist/statusline/cache.js +51 -1
- package/dist/statusline/line.js +7 -6
- package/dist/statusline/run.js +9 -3
- package/dist/ui/MatchRow.js +5 -4
- package/dist/ui/TakeoverView.js +3 -2
- package/dist/ui/Ticker.js +3 -2
- package/dist/ui/UpcomingSection.js +3 -2
- package/dist/ui/flags.js +43 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
# ⚽ claudial
|
|
4
4
|
|
|
5
|
-
### Live World Cup 2026 in your terminal — goal
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
39
|
-
France 0 — 0 Senegal
|
|
32
|
+

|
|
40
33
|
|
|
41
|
-
|
|
42
|
-
18:00 Brazil — Croatia
|
|
43
|
-
21:00 Spain — Morocco
|
|
34
|
+
</div>
|
|
44
35
|
|
|
45
|
-
|
|
46
|
-
```
|
|
36
|
+
## Claude Code, World Cup edition
|
|
47
37
|
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
70
|
-
workstation — Claude working on top, every live score ticking in a strip
|
|
71
|
-
below, forever.
|
|
63
|
+
## The full dashboard
|
|
72
64
|
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
148
|
-
substitutions, injury time — and
|
|
149
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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;
|
package/dist/setup/Wizard.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
24
|
-
|
|
25
|
-
|
|
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" }) }),
|
|
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
|
}
|
package/dist/setup/apply.js
CHANGED
|
@@ -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;
|
package/dist/setup/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
15
|
-
|
|
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());
|
package/dist/setup/settings.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/dist/statusline/cache.js
CHANGED
|
@@ -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
|
}
|
package/dist/statusline/line.js
CHANGED
|
@@ -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'" (
|
|
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—
|
|
17
|
+
/** The next kickoff: "○ BRA 🇧🇷 — 🇲🇦 MAR Sun 01:00". */
|
|
17
18
|
function upcomingLine(match, now) {
|
|
18
|
-
return `○ ${match.home.code}
|
|
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
|
package/dist/statusline/run.js
CHANGED
|
@@ -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
|
|
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/dist/ui/MatchRow.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
}
|
package/dist/ui/TakeoverView.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
}
|
package/dist/ui/flags.js
ADDED
|
@@ -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
|
+
"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"
|