claudial 0.6.4 → 0.7.1
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 +90 -78
- package/dist/api/backend.js +216 -0
- package/dist/api/device.js +28 -0
- package/dist/cli/follow.js +136 -0
- package/dist/engine/poller.js +9 -1
- package/dist/index.js +29 -4
- package/dist/predictions/client.js +6 -7
- package/dist/setup/index.js +11 -0
- package/dist/snapshot.js +10 -6
- package/dist/statusline/cache.js +5 -3
- package/dist/statusline/line.js +4 -4
- package/dist/statusline/run.js +22 -7
- package/dist/ui/App.js +39 -6
- package/dist/ui/Footer.js +1 -1
- package/dist/ui/Header.js +1 -1
- package/dist/ui/MatchRow.js +20 -13
- package/dist/ui/TakeoverView.js +3 -3
- package/dist/ui/Ticker.js +3 -3
- package/dist/ui/UpcomingSection.js +13 -4
- package/dist/ui/flags.js +21 -7
- package/dist/ui/notice.js +9 -0
- package/dist/ui/picker/Picker.js +152 -0
- package/dist/ui/picker/run.js +13 -0
- package/dist/ui/picker/state.js +120 -0
- package/package.json +5 -4
- package/dist/api/espn.js +0 -130
- package/dist/api/parse.js +0 -103
package/README.md
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
# ⚽ claudial
|
|
4
4
|
|
|
5
|
-
### Live
|
|
5
|
+
### Live football inside Claude Code
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
Your teams' scores sit under your prompt while you code.
|
|
8
|
+
When one of them scores, your terminal celebrates.
|
|
9
9
|
|
|
10
10
|
```
|
|
11
11
|
npm install -g claudial && claudial setup
|
|
@@ -16,121 +16,129 @@ npm install -g claudial && claudial setup
|
|
|
16
16
|
[](https://www.npmjs.com/package/claudial)
|
|
17
17
|
[](https://www.npmjs.com/package/claudial)
|
|
18
18
|
[](https://nodejs.org)
|
|
19
|
-
[](LICENSE)
|
|
20
19
|
[](tsconfig.json)
|
|
20
|
+

|
|
21
|
+
[](LICENSE)
|
|
21
22
|
[](https://github.com/lefProg/claudial/stargazers)
|
|
22
|
-
|
|
23
|
+
|
|
24
|
+
<br/>
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
**Premier League · La Liga · Serie A · Bundesliga · Ligue 1 · Champions League ·
|
|
29
|
+
Europa League · Conference League · Super League Greece · World Cup**
|
|
23
30
|
|
|
24
31
|
</div>
|
|
25
32
|
|
|
26
33
|
---
|
|
27
34
|
|
|
28
|
-
|
|
29
|
-
box, always visible while you work, lighting up the moment anyone scores:
|
|
35
|
+
## Get started
|
|
30
36
|
|
|
31
|
-
|
|
37
|
+
| You are… | Run |
|
|
38
|
+
|---|---|
|
|
39
|
+
| **New to claudial** | `npm install -g claudial`<br>`claudial setup` |
|
|
40
|
+
| **Back from the World Cup** (you have 0.6.x) | `npm install -g claudial@latest`<br>`claudial --version` (should print 0.7.0 or newer)<br>`claudial follow` |
|
|
32
41
|
|
|
33
|
-
|
|
42
|
+
Then restart Claude Code. World Cup users keep their status line as it is; `claudial follow`
|
|
43
|
+
picks your leagues and teams, because the new version starts following nothing.
|
|
34
44
|
|
|
35
|
-
|
|
45
|
+
Still seeing "WORLD CUP 2026" after updating? An old copy comes first on your PATH.
|
|
46
|
+
`which -a claudial` lists every copy; remove the old one with
|
|
47
|
+
`npm uninstall -g --prefix <its folder> claudial` (e.g. `--prefix ~/.npm-global`).
|
|
36
48
|
|
|
37
|
-
##
|
|
49
|
+
## The score, right under your prompt
|
|
38
50
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
while Claude works, with nothing to wire up by hand.
|
|
51
|
+
One command puts a live score line into Claude Code's status bar. It stays out
|
|
52
|
+
of the way while Claude works, and lights up the moment anyone scores:
|
|
42
53
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
claudial
|
|
46
|
-
```
|
|
54
|
+
<div align="center">
|
|
55
|
+
|
|
56
|
+

|
|
47
57
|
|
|
48
|
-
|
|
58
|
+
</div>
|
|
49
59
|
|
|
50
60
|
```
|
|
51
|
-
⚽
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
⚽ Liverpool 6—0 Manchester United 87' · main ← every match you follow
|
|
62
|
+
⚽ G O O O L · Liverpool 7—0 Manchester United · main ← for 15 s after a goal
|
|
63
|
+
🟥 R E D · CASEMIRO · Manchester United — Southampton · main ← for 15 s after a red card
|
|
64
|
+
○ Liverpool — Arsenal Sat 05:30 PM · main ← between matches: next kickoff
|
|
55
65
|
```
|
|
56
66
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
Your git branch stays at the end. **Already run a custom status line?** Setup
|
|
68
|
+
keeps it and appends the score, so nothing you had is lost. Pure Node, so it
|
|
69
|
+
works on macOS, Linux and Windows. Restart Claude Code after setup to see it.
|
|
60
70
|
|
|
61
|
-
|
|
62
|
-
existing one and appends the score, so nothing you had is lost.
|
|
71
|
+
## Pick your teams in five seconds
|
|
63
72
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
Prefer the full board beside Claude? `claudial` opens a live dashboard — every
|
|
67
|
-
match with scores, minutes and scorers, upcoming fixtures in your local time,
|
|
68
|
-
all auto-refreshing while you work. And when a goal goes in — anywhere in the
|
|
69
|
-
tournament — the whole screen takes over:
|
|
73
|
+
Setup ends by asking what you follow. Type a name, tick it with space, press
|
|
74
|
+
esc to save:
|
|
70
75
|
|
|
71
76
|
```
|
|
77
|
+
claudial · what do you follow?
|
|
72
78
|
|
|
79
|
+
search › liver▏
|
|
73
80
|
|
|
74
|
-
|
|
75
|
-
█ ▄▄ █ █ █▀▀█ █
|
|
76
|
-
▀▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀
|
|
81
|
+
› ◉ Liverpool LIV · Premier League
|
|
77
82
|
|
|
78
|
-
|
|
83
|
+
Following Liverpool
|
|
84
|
+
↑↓ move · space follow · type to search · esc clear search · ctrl+c cancel
|
|
85
|
+
```
|
|
79
86
|
|
|
80
|
-
|
|
87
|
+
Follow a whole league or just your club. Change your mind any time: press `f`
|
|
88
|
+
in the dashboard, or run `claudial follow`.
|
|
81
89
|
|
|
90
|
+
## A goal takes over the whole screen
|
|
82
91
|
|
|
83
|
-
|
|
92
|
+
Want the full board beside Claude? `claudial` opens a live dashboard: every
|
|
93
|
+
match you follow with scores, minutes, scorers and cards, plus the next three
|
|
94
|
+
weeks of fixtures in your local time.
|
|
84
95
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
96
|
+
When a goal goes in, the server pushes it within a second, and the whole
|
|
97
|
+
screen becomes the goal: the scorer, the minute, the new score. Four seconds
|
|
98
|
+
of glory, then back to the board. Red cards get the same treatment.
|
|
88
99
|
|
|
89
100
|
## Usage
|
|
90
101
|
|
|
91
102
|
```
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
Or install it for good:
|
|
98
|
-
|
|
99
|
-
```
|
|
100
|
-
npm install -g claudial
|
|
101
|
-
claudial
|
|
103
|
+
claudial # the live dashboard
|
|
104
|
+
claudial --ticker # a 4-line strip for slim split panes
|
|
105
|
+
claudial | cat # plain snapshot for pipes and scripts
|
|
106
|
+
claudial follow # pick leagues and teams
|
|
102
107
|
```
|
|
103
108
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
|
107
|
-
|-----|-------------|
|
|
109
|
+
| Key | Action |
|
|
110
|
+
|-----|--------|
|
|
111
|
+
| `f` | pick what you follow |
|
|
108
112
|
| `r` | refresh now |
|
|
109
|
-
| `q` | quit
|
|
113
|
+
| `q` | quit |
|
|
114
|
+
|
|
115
|
+
Requires Node ≥ 18. No account, no sign-up, no API key.
|
|
110
116
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
117
|
+
Scripting it? `claudial setup --statusline --yes` installs without
|
|
118
|
+
prompts, and `claudial leagues`, `claudial teams <league>`,
|
|
119
|
+
`claudial follow <id>...`, `claudial unfollow <id>...` and `claudial following`
|
|
120
|
+
work without the UI. `claudial forget` deletes your device from the server.
|
|
114
121
|
|
|
115
122
|
## Status
|
|
116
123
|
|
|
117
|
-
|
|
124
|
+
claudial started as a World Cup 2026 side project. It now follows ten leagues
|
|
125
|
+
through its own server, written in Rust: one set of polls for everyone, goals
|
|
126
|
+
pushed to every terminal over server-sent events.
|
|
118
127
|
|
|
119
128
|
## Notes
|
|
120
129
|
|
|
121
|
-
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
- Match
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
scoreboard call serves all live matches at once. Please keep it that way.
|
|
130
|
+
- Privacy: the server stores a random device id, what you follow, and when it
|
|
131
|
+
last saw you. No accounts, emails, names or analytics, and your IP is never
|
|
132
|
+
written down. Inactive devices are deleted after 400 days; `claudial forget`
|
|
133
|
+
deletes yours right away.
|
|
134
|
+
- Match facts (scores, scorers, cards) come from ESPN's public site API, through
|
|
135
|
+
the claudial server. No logos or branding. Not affiliated with or endorsed by
|
|
136
|
+
ESPN, FIFA, UEFA or any league.
|
|
137
|
+
- Seeing `AR` instead of `🇦🇷` for national teams? Your terminal doesn't render
|
|
138
|
+
flag emoji. macOS terminals and [Konsole](https://konsole.kde.org/) do;
|
|
139
|
+
Windows Terminal doesn't.
|
|
140
|
+
- Not affiliated with Anthropic. The look is a love letter to
|
|
141
|
+
[Claude Code](https://claude.com/claude-code).
|
|
134
142
|
|
|
135
143
|
```js
|
|
136
144
|
// Canada — Bosnia & Herzegovina, 12 June 2026, was on while this was built.
|
|
@@ -140,8 +148,8 @@ v1 is being built live during the group stage. Follow the commits.
|
|
|
140
148
|
|
|
141
149
|
## Contributing
|
|
142
150
|
|
|
143
|
-
Issues and PRs welcome
|
|
144
|
-
|
|
151
|
+
Issues and PRs welcome: bug reports, new incident types, terminal quirks, all
|
|
152
|
+
of it. Keep the spirit playful.
|
|
145
153
|
|
|
146
154
|
```bash
|
|
147
155
|
git clone https://github.com/lefProg/claudial.git
|
|
@@ -159,10 +167,14 @@ npm test # vitest
|
|
|
159
167
|
|
|
160
168
|
<br/>
|
|
161
169
|
|
|
162
|
-
**If this made a goal feel a little louder, drop a
|
|
170
|
+
**If this made a goal feel a little louder, drop a ⭐. It helps others find it.**
|
|
163
171
|
|
|
164
172
|
[Report a bug](https://github.com/lefProg/claudial/issues) ·
|
|
165
173
|
[Request a feature](https://github.com/lefProg/claudial/issues) ·
|
|
166
174
|
[npm](https://www.npmjs.com/package/claudial)
|
|
167
175
|
|
|
176
|
+
<br/>
|
|
177
|
+
|
|
178
|
+
**YNWA** 🔴
|
|
179
|
+
|
|
168
180
|
</div>
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { deviceId } from './device.js';
|
|
2
|
+
// Where the server lives: the deployed VPS. Plain HTTP until it has a domain and
|
|
3
|
+
// TLS; scripts/check-release.mjs blocks an npm publish until this is https://.
|
|
4
|
+
// CLAUDIAL_API_URL always wins (local dev: http://localhost:8080).
|
|
5
|
+
export const DEFAULT_API_URL = 'http://161.97.67.12:30083';
|
|
6
|
+
export function apiUrl(env = process.env) {
|
|
7
|
+
return (env.CLAUDIAL_API_URL || DEFAULT_API_URL).replace(/\/+$/, '');
|
|
8
|
+
}
|
|
9
|
+
/** Thrown for any non-2xx answer, carrying the server's own error message. */
|
|
10
|
+
export class ApiError extends Error {
|
|
11
|
+
status;
|
|
12
|
+
constructor(status, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.status = status;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const TIMEOUT_MS = 8_000;
|
|
18
|
+
export async function api(method, path, body, fetchImpl = fetch) {
|
|
19
|
+
const res = await fetchImpl(`${apiUrl()}${path}`, {
|
|
20
|
+
method,
|
|
21
|
+
headers: {
|
|
22
|
+
Authorization: `Bearer ${deviceId()}`,
|
|
23
|
+
Accept: 'application/json',
|
|
24
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
25
|
+
},
|
|
26
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
27
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
28
|
+
});
|
|
29
|
+
const text = await res.text();
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
let message = `server answered ${res.status}`;
|
|
32
|
+
try {
|
|
33
|
+
message = JSON.parse(text).error ?? message;
|
|
34
|
+
}
|
|
35
|
+
catch { /* not JSON */ }
|
|
36
|
+
throw new ApiError(res.status, message);
|
|
37
|
+
}
|
|
38
|
+
return (text ? JSON.parse(text) : undefined);
|
|
39
|
+
}
|
|
40
|
+
// ── Adapting server matches to the client's Match ───────────────────────────
|
|
41
|
+
export function toMatch(m) {
|
|
42
|
+
const national = m.league === 'fifa.world';
|
|
43
|
+
const status = m.status === 'postponed' ? 'finished' : m.status;
|
|
44
|
+
return {
|
|
45
|
+
id: Number(m.id),
|
|
46
|
+
group: null,
|
|
47
|
+
home: { name: m.home.name, code: m.home.code, national },
|
|
48
|
+
away: { name: m.away.name, code: m.away.code, national },
|
|
49
|
+
homeScore: m.homeScore,
|
|
50
|
+
awayScore: m.awayScore,
|
|
51
|
+
status,
|
|
52
|
+
statusText: m.status === 'postponed' ? (m.statusText || 'Postponed') : m.statusText,
|
|
53
|
+
minute: m.minute,
|
|
54
|
+
startTimestamp: m.startTimestamp,
|
|
55
|
+
varInProgress: false,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function toIncidents(m) {
|
|
59
|
+
return m.incidents.map((i) => ({ ...i, homeScore: null, awayScore: null }));
|
|
60
|
+
}
|
|
61
|
+
// One /v1/state request serves every caller in a tick (dashboard or statusline):
|
|
62
|
+
// the in-flight promise is shared, and the answer is reused for a few seconds.
|
|
63
|
+
const CACHE_MS = 5_000;
|
|
64
|
+
let cached = null;
|
|
65
|
+
export function fetchState(now = Date.now()) {
|
|
66
|
+
if (MOCK)
|
|
67
|
+
return Promise.resolve(mockState());
|
|
68
|
+
if (cached && now - cached.at < CACHE_MS)
|
|
69
|
+
return cached.state;
|
|
70
|
+
const state = api('GET', '/v1/state');
|
|
71
|
+
cached = { at: now, state };
|
|
72
|
+
state.catch(() => { if (cached?.state === state)
|
|
73
|
+
cached = null; });
|
|
74
|
+
return state;
|
|
75
|
+
}
|
|
76
|
+
/** Forget the cached state (after the event stream says something changed). */
|
|
77
|
+
export function invalidateState() { cached = null; }
|
|
78
|
+
const isLive = (m) => m.status === 'live' || m.status === 'halftime';
|
|
79
|
+
// Kept for call-site compatibility; the server needs no season id.
|
|
80
|
+
export async function resolveSeasonId() {
|
|
81
|
+
return 2026;
|
|
82
|
+
}
|
|
83
|
+
export async function fetchLive() {
|
|
84
|
+
if (MOCK)
|
|
85
|
+
mockLiveCalls++;
|
|
86
|
+
return (await fetchState()).matches.map(toMatch).filter(isLive);
|
|
87
|
+
}
|
|
88
|
+
export async function fetchUpcoming(_seasonId) {
|
|
89
|
+
return (await fetchState()).matches
|
|
90
|
+
.map(toMatch)
|
|
91
|
+
.filter((m) => m.status === 'upcoming')
|
|
92
|
+
.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
93
|
+
}
|
|
94
|
+
export async function fetchRecent(_seasonId) {
|
|
95
|
+
return (await fetchState()).matches
|
|
96
|
+
.map(toMatch)
|
|
97
|
+
.filter((m) => m.status === 'finished')
|
|
98
|
+
.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
99
|
+
}
|
|
100
|
+
export async function fetchIncidents(eventId) {
|
|
101
|
+
const m = (await fetchState()).matches.find((x) => Number(x.id) === eventId);
|
|
102
|
+
return m ? toIncidents(m) : [];
|
|
103
|
+
}
|
|
104
|
+
// Red cards in live matches, from the same /v1/state answer.
|
|
105
|
+
export async function fetchLiveRedCards() {
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const sm of (await fetchState()).matches) {
|
|
108
|
+
const m = toMatch(sm);
|
|
109
|
+
if (!isLive(m))
|
|
110
|
+
continue;
|
|
111
|
+
for (const inc of sm.incidents) {
|
|
112
|
+
if (inc.kind === 'redCard') {
|
|
113
|
+
out.push({ id: inc.id, homeCode: m.home.code, awayCode: m.away.code, homeName: m.home.name, awayName: m.away.name, player: inc.playerShort ?? inc.player ?? '?', minute: inc.minute, national: m.home.national !== false });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
// ── Subscriptions ────────────────────────────────────────────────────────────
|
|
120
|
+
export const listLeagues = () => api('GET', '/v1/leagues');
|
|
121
|
+
export const listTeams = (slug) => api('GET', `/v1/leagues/${encodeURIComponent(slug)}/teams`);
|
|
122
|
+
export const getSettings = () => api('GET', '/v1/me/settings');
|
|
123
|
+
export const putSettings = (s) => api('PUT', '/v1/me/settings', s);
|
|
124
|
+
export const forgetDevice = () => api('DELETE', '/v1/me/settings');
|
|
125
|
+
/**
|
|
126
|
+
* A new device follows nothing until the user picks something. True when this
|
|
127
|
+
* device follows no league and no team, so the UI can say how to start.
|
|
128
|
+
*/
|
|
129
|
+
export async function followsNothing() {
|
|
130
|
+
if (MOCK)
|
|
131
|
+
return false;
|
|
132
|
+
const s = await getSettings();
|
|
133
|
+
return s.leagues.length === 0 && s.teams.length === 0;
|
|
134
|
+
}
|
|
135
|
+
// ── Push: the server's event stream ─────────────────────────────────────────
|
|
136
|
+
/** Three missed 15 s heartbeats. */
|
|
137
|
+
const STALL_MS = 45_000;
|
|
138
|
+
/**
|
|
139
|
+
* Hold GET /v1/events open and call `onEvent` with each event name. Reconnects
|
|
140
|
+
* with backoff after errors or when the server ends the stream. Returns a stop
|
|
141
|
+
* function. The caller refetches /v1/state on each event.
|
|
142
|
+
*/
|
|
143
|
+
export function subscribeEvents(onEvent, fetchImpl = fetch, stallMs = STALL_MS) {
|
|
144
|
+
let stopped = false;
|
|
145
|
+
let controller = null;
|
|
146
|
+
let failures = 0;
|
|
147
|
+
const loop = async () => {
|
|
148
|
+
while (!stopped) {
|
|
149
|
+
controller = new AbortController();
|
|
150
|
+
try {
|
|
151
|
+
const res = await fetchImpl(`${apiUrl()}/v1/events`, {
|
|
152
|
+
headers: { Authorization: `Bearer ${deviceId()}`, Accept: 'text/event-stream' },
|
|
153
|
+
signal: controller.signal,
|
|
154
|
+
});
|
|
155
|
+
if (!res.ok || !res.body)
|
|
156
|
+
throw new ApiError(res.status, `events answered ${res.status}`);
|
|
157
|
+
failures = 0;
|
|
158
|
+
// The server writes a heartbeat every 15 s. Silence for longer than that
|
|
159
|
+
// means a dead connection nobody closed (a sleeping laptop, a NAT drop):
|
|
160
|
+
// abort and reconnect instead of waiting forever (final review N9).
|
|
161
|
+
const current = controller;
|
|
162
|
+
let stall = setTimeout(() => current.abort(), stallMs);
|
|
163
|
+
const decoder = new TextDecoder();
|
|
164
|
+
let buf = '';
|
|
165
|
+
for await (const chunk of res.body) {
|
|
166
|
+
clearTimeout(stall);
|
|
167
|
+
stall = setTimeout(() => current.abort(), stallMs);
|
|
168
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
169
|
+
let cut;
|
|
170
|
+
while ((cut = buf.indexOf('\n\n')) >= 0) {
|
|
171
|
+
const frame = buf.slice(0, cut);
|
|
172
|
+
buf = buf.slice(cut + 2);
|
|
173
|
+
const name = frame.split('\n').find((l) => l.startsWith('event:'))?.slice(6).trim();
|
|
174
|
+
if (name)
|
|
175
|
+
onEvent(name);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
clearTimeout(stall);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
failures++;
|
|
182
|
+
}
|
|
183
|
+
if (stopped)
|
|
184
|
+
return;
|
|
185
|
+
const wait = Math.min(1_000 * 2 ** Math.min(failures, 6), 60_000);
|
|
186
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
void loop();
|
|
190
|
+
return () => { stopped = true; controller?.abort(); };
|
|
191
|
+
}
|
|
192
|
+
// ── Mock mode for demos (CLAUDIAL_MOCK) ──────────────────────────────────────
|
|
193
|
+
// A fake live Argentina–England match. The first live fetch is 1–1; from the
|
|
194
|
+
// second fetch on, Harry Kane has scored to make it 1–2 — so the next poll (or
|
|
195
|
+
// pressing `r`) fires the goal takeover with his name. Inert unless the env var
|
|
196
|
+
// is set. Run: `CLAUDIAL_MOCK=1 claudial`, then press `r`.
|
|
197
|
+
const MOCK = !!process.env.CLAUDIAL_MOCK;
|
|
198
|
+
let mockLiveCalls = 0;
|
|
199
|
+
function mockState() {
|
|
200
|
+
const scored = mockLiveCalls >= 2; // Kane's goal lands on the 2nd live fetch
|
|
201
|
+
const goal = (id, minute, player, short, isHome) => ({ id, kind: 'goal', minute, player, playerShort: short, detail: null, isHome });
|
|
202
|
+
return {
|
|
203
|
+
generatedAt: Math.floor(Date.now() / 1000),
|
|
204
|
+
matches: [{
|
|
205
|
+
id: '9001', league: 'fifa.world',
|
|
206
|
+
home: { id: '202', name: 'Argentina', code: 'ARG' }, away: { id: '448', name: 'England', code: 'ENG' },
|
|
207
|
+
homeScore: 1, awayScore: scored ? 2 : 1, status: 'live', statusText: scored ? "78'" : "76'",
|
|
208
|
+
minute: scored ? 78 : 76, startTimestamp: Math.floor(Date.now() / 1000) - 78 * 60,
|
|
209
|
+
incidents: [
|
|
210
|
+
goal('9001-137-600-1', 10, 'Lionel Messi', 'L. Messi', true),
|
|
211
|
+
goal('9001-137-2400-2', 40, 'Jude Bellingham', 'J. Bellingham', false),
|
|
212
|
+
...(scored ? [goal('9001-137-4680-3', 78, 'Harry Kane', 'H. Kane', false)] : []),
|
|
213
|
+
],
|
|
214
|
+
}],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
// This device's identity for the claudial server: a random UUID made once and
|
|
6
|
+
// kept in ~/.config/claudial/device-id. It is the only credential; there are no
|
|
7
|
+
// accounts. Deleting the file makes the server see a brand-new device.
|
|
8
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
export function configDir(env = process.env) {
|
|
10
|
+
return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'claudial');
|
|
11
|
+
}
|
|
12
|
+
/** Read the device id, creating it (mode 0600) on first use. */
|
|
13
|
+
export function deviceId(dir = configDir()) {
|
|
14
|
+
const file = join(dir, 'device-id');
|
|
15
|
+
if (existsSync(file)) {
|
|
16
|
+
const id = readFileSync(file, 'utf8').trim();
|
|
17
|
+
if (UUID.test(id))
|
|
18
|
+
return id;
|
|
19
|
+
}
|
|
20
|
+
mkdirSync(dir, { recursive: true });
|
|
21
|
+
const id = randomUUID();
|
|
22
|
+
writeFileSync(file, `${id}\n`, { mode: 0o600 });
|
|
23
|
+
try {
|
|
24
|
+
chmodSync(file, 0o600);
|
|
25
|
+
}
|
|
26
|
+
catch { /* best effort */ }
|
|
27
|
+
return id;
|
|
28
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ApiError, apiUrl, forgetDevice, getSettings, listLeagues, listTeams, putSettings, } from '../api/backend.js';
|
|
4
|
+
import { configDir } from '../api/device.js';
|
|
5
|
+
// `claudial leagues | teams | follow | unfollow | following | forget`:
|
|
6
|
+
// what this device follows on the claudial server.
|
|
7
|
+
export const FOLLOW_COMMANDS = ['leagues', 'teams', 'follow', 'unfollow', 'following', 'forget'];
|
|
8
|
+
export const USAGE = `usage:
|
|
9
|
+
claudial follow pick your leagues and teams from a list
|
|
10
|
+
claudial leagues the leagues you can follow (✓ = following)
|
|
11
|
+
claudial teams <league> [search] a league's teams and their ids, e.g. claudial teams gre.1 pana
|
|
12
|
+
claudial follow <league|team id>... e.g. claudial follow gre.1 443
|
|
13
|
+
claudial unfollow <league|team id>...
|
|
14
|
+
claudial following what this device follows
|
|
15
|
+
claudial forget delete this device from the server`;
|
|
16
|
+
/** Pure: apply follow/unfollow targets to settings. Slugs are leagues, digits are team ids. */
|
|
17
|
+
export function applyTargets(s, targets, follow, known) {
|
|
18
|
+
const leagues = new Set(s.leagues);
|
|
19
|
+
const teams = new Set(s.teams);
|
|
20
|
+
for (const t of targets) {
|
|
21
|
+
if (/^\d+$/.test(t)) {
|
|
22
|
+
if (follow) {
|
|
23
|
+
teams.add(t);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
teams.delete(t);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else if (known.some((l) => l.slug === t)) {
|
|
30
|
+
if (follow) {
|
|
31
|
+
leagues.add(t);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
leagues.delete(t);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
throw new Error(`"${t}" is neither a league slug (see: claudial leagues) nor a team id (see: claudial teams <league>)`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { leagues: [...leagues], teams: [...teams] };
|
|
42
|
+
}
|
|
43
|
+
/** Pure: case- and accent-insensitive match on a team's name or code. */
|
|
44
|
+
export function matchesSearch(t, search) {
|
|
45
|
+
const fold = (x) => x.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
|
46
|
+
const q = fold(search);
|
|
47
|
+
return fold(t.name).includes(q) || fold(t.code).includes(q);
|
|
48
|
+
}
|
|
49
|
+
/** One line for a failed command: the server's own message, or a plain "can't reach it". */
|
|
50
|
+
export function describeError(e) {
|
|
51
|
+
if (e instanceof ApiError)
|
|
52
|
+
return `server said: ${e.message}`;
|
|
53
|
+
// fetch rejects with a TypeError when the network fails, and a Timeout/AbortError on our 8 s limit.
|
|
54
|
+
const name = e instanceof Error ? e.name : '';
|
|
55
|
+
if (e instanceof TypeError || name === 'TimeoutError' || name === 'AbortError') {
|
|
56
|
+
return `can't reach the claudial server (${apiUrl()}). Check your connection and try again.`;
|
|
57
|
+
}
|
|
58
|
+
return e instanceof Error ? e.message : String(e);
|
|
59
|
+
}
|
|
60
|
+
export async function runFollow(args, out = console.log) {
|
|
61
|
+
const [cmd, ...rest] = args;
|
|
62
|
+
try {
|
|
63
|
+
switch (cmd) {
|
|
64
|
+
case 'leagues': {
|
|
65
|
+
const [leagues, s] = await Promise.all([listLeagues(), getSettings()]);
|
|
66
|
+
for (const l of leagues)
|
|
67
|
+
out(`${s.leagues.includes(l.slug) ? '✓' : ' '} ${l.slug.padEnd(18)} ${l.name}`);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
case 'teams': {
|
|
71
|
+
const [league, search] = rest;
|
|
72
|
+
if (!league) {
|
|
73
|
+
out(USAGE);
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
const [teams, s] = await Promise.all([listTeams(league), getSettings()]);
|
|
77
|
+
const shown = search ? teams.filter((t) => matchesSearch(t, search)) : teams;
|
|
78
|
+
for (const t of shown)
|
|
79
|
+
out(`${s.teams.includes(t.id) ? '✓' : ' '} ${t.id.padStart(7)} ${t.code.padEnd(10)} ${t.name}`);
|
|
80
|
+
if (shown.length === 0)
|
|
81
|
+
out('no team matches');
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
case 'follow':
|
|
85
|
+
case 'unfollow': {
|
|
86
|
+
if (rest.length === 0 && cmd === 'follow' && process.stdin.isTTY) {
|
|
87
|
+
const { runPicker } = await import('../ui/picker/run.js');
|
|
88
|
+
await runPicker();
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
if (rest.length === 0) {
|
|
92
|
+
out(USAGE);
|
|
93
|
+
return 2;
|
|
94
|
+
}
|
|
95
|
+
const [leagues, s] = await Promise.all([listLeagues(), getSettings()]);
|
|
96
|
+
const next = applyTargets(s, rest, cmd === 'follow', leagues);
|
|
97
|
+
const saved = await putSettings(next);
|
|
98
|
+
out(`following ${saved.leagues.length} league(s) and ${saved.teams.length} team(s)`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
case 'following': {
|
|
102
|
+
const [leagues, s] = await Promise.all([listLeagues(), getSettings()]);
|
|
103
|
+
const name = new Map(leagues.map((l) => [l.slug, l.name]));
|
|
104
|
+
out(s.leagues.length ? `leagues: ${s.leagues.map((l) => `${l} (${name.get(l) ?? '?'})`).join(', ')}` : 'leagues: none');
|
|
105
|
+
if (s.teams.length === 0) {
|
|
106
|
+
out('teams: none');
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
// Settings hold team ids only; look their names up in every league's list.
|
|
110
|
+
const lists = await Promise.all(leagues.map((l) => listTeams(l.slug).catch(() => [])));
|
|
111
|
+
const team = new Map();
|
|
112
|
+
for (const t of lists.flat())
|
|
113
|
+
if (!team.has(t.id))
|
|
114
|
+
team.set(t.id, t);
|
|
115
|
+
out(`teams: ${s.teams.map((id) => { const t = team.get(id); return t ? `${t.name} (${id})` : id; }).join(', ')}`);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
case 'forget': {
|
|
119
|
+
await forgetDevice();
|
|
120
|
+
// A new identity next time. `onboarded` was written by versions that auto-followed.
|
|
121
|
+
const dir = configDir();
|
|
122
|
+
rmSync(join(dir, 'device-id'), { force: true });
|
|
123
|
+
rmSync(join(dir, 'onboarded'), { force: true });
|
|
124
|
+
out('the server no longer has anything about this device');
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
default:
|
|
128
|
+
out(USAGE);
|
|
129
|
+
return 2;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
out(`claudial: ${describeError(e)}`);
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
package/dist/engine/poller.js
CHANGED
|
@@ -31,7 +31,12 @@ export function startPoller(deps, opts = {}) {
|
|
|
31
31
|
try {
|
|
32
32
|
const matches = await deps.fetchLive();
|
|
33
33
|
const scoreChanges = diffGoals(prev, matches);
|
|
34
|
+
// A match that left the live list has finished (or was postponed): refresh
|
|
35
|
+
// the finished/upcoming lists now, or it vanishes until the next fixtures tick.
|
|
36
|
+
const ended = prev.some((p) => !matches.some((m) => m.id === p.id));
|
|
34
37
|
prev = matches;
|
|
38
|
+
if (ended)
|
|
39
|
+
void refreshFixtures();
|
|
35
40
|
deps.dispatch({ type: 'live', matches, at: Date.now() });
|
|
36
41
|
failures = 0;
|
|
37
42
|
for (const m of matches) {
|
|
@@ -98,7 +103,7 @@ export function startPoller(deps, opts = {}) {
|
|
|
98
103
|
liveTimer = setTimeout(liveTick, delay);
|
|
99
104
|
}
|
|
100
105
|
}
|
|
101
|
-
async function
|
|
106
|
+
async function refreshFixtures() {
|
|
102
107
|
try {
|
|
103
108
|
const { upcoming, recent } = await deps.fetchFixtures();
|
|
104
109
|
deps.dispatch({ type: 'fixtures', upcoming, recent });
|
|
@@ -106,6 +111,9 @@ export function startPoller(deps, opts = {}) {
|
|
|
106
111
|
catch {
|
|
107
112
|
deps.dispatch({ type: 'stale' });
|
|
108
113
|
}
|
|
114
|
+
}
|
|
115
|
+
async function fixturesTick() {
|
|
116
|
+
await refreshFixtures();
|
|
109
117
|
// Predictions ride the gentle fixtures cadence. fetchPredictions is
|
|
110
118
|
// fail-silent (returns [] when disabled or on error), so this never
|
|
111
119
|
// disturbs the board.
|