soccer-cli 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/dist/index.js +802 -0
  4. package/package.json +60 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SpunkySarb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # ⚽ soccer-cli
2
+
3
+ Live football scores, **ball-by-ball commentary**, and a **live ASCII pitch** — right in your terminal. Built for the 2026 World Cup, works for every major league. **No API key required.**
4
+
5
+ ```
6
+ ⚽ FIFA WORLD CUP 2026 ● 1 LIVE · ⟳15s
7
+ MATCHES
8
+ ───────────────────────────────────────────────────────────
9
+ ▸ 🇲🇽 Mexico 2-1 South Africa 🇿🇦 ● 67'
10
+ 🇰🇷 South Korea 0-0 Czechia 🇨🇿 31'
11
+
12
+ 🇲🇽 Mexico 2 : 1 South Africa 🇿🇦 ● 67'
13
+ 58% Possession 42% · 12 Shots 7 · 5 On target 3
14
+ ╭────────────────────────────────────────────────────────╮
15
+ │ ·····┆ ★ ┆ ┆····· │
16
+ │▐ ★ ┆ ● ( • ) ┆ ▌│
17
+ │ ·····┆ ┆ ┆····· │
18
+ ╰────────────────────────────────────────────────────────╯
19
+ ● Mexico → ← South Africa ● 67' ★ GOAL
20
+ ```
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install -g soccer-cli
26
+ soccer
27
+ ```
28
+
29
+ …or run it without installing:
30
+
31
+ ```bash
32
+ npx soccer-cli
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ```bash
38
+ soccer # FIFA World Cup (default)
39
+ soccer --league eng.1 # Premier League
40
+ soccer --date 20260611 # a specific day's fixtures
41
+ soccer --once # print one snapshot and exit (great for piping)
42
+ soccer --help
43
+ ```
44
+
45
+ ### Keys
46
+
47
+ | Key | Action |
48
+ |-----|--------|
49
+ | `↑` / `↓` (or `j` / `k`) | Move between matches |
50
+ | `p` | Toggle the live pitch |
51
+ | `r` | Force refresh |
52
+ | `q` / `Esc` | Quit |
53
+
54
+ ### Leagues
55
+
56
+ Any ESPN soccer slug works — a few common ones:
57
+
58
+ `fifa.world` (World Cup) · `eng.1` (Premier League) · `esp.1` (La Liga) · `ger.1` (Bundesliga) · `ita.1` (Serie A) · `fra.1` (Ligue 1) · `uefa.champions` · `usa.1` (MLS) · `bra.1` (Brasileirão)
59
+
60
+ ## What you get
61
+
62
+ - **Live scores** that auto-refresh
63
+ - **Ball-by-ball commentary** in a clean, word-wrapped table
64
+ - **A live pitch** plotting where the action is — goals (★), the latest play (●), and a fading trail of recent events
65
+ - **Match stats** — possession, shots, on-target, corners, fouls
66
+ - **Country flags** for every nation
67
+
68
+ ## Data
69
+
70
+ Powered by ESPN's public (unofficial) feed. Not affiliated with ESPN or FIFA. Endpoints are undocumented and may change without notice.
71
+
72
+ ## License
73
+
74
+ MIT © [SpunkySarb](https://github.com/SpunkySarb)
package/dist/index.js ADDED
@@ -0,0 +1,802 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/index.ts
4
+ import process2 from "process";
5
+
6
+ // src/app.ts
7
+ import logUpdate from "log-update";
8
+ import readline from "readline";
9
+ import process from "process";
10
+
11
+ // src/flags.ts
12
+ import { createRequire } from "module";
13
+ import countries from "i18n-iso-countries";
14
+ var require2 = createRequire(import.meta.url);
15
+ try {
16
+ countries.registerLocale(require2("i18n-iso-countries/langs/en.json"));
17
+ } catch {
18
+ }
19
+ var WHITE = "\u{1F3F3}\uFE0F";
20
+ var FIFA_TO_ISO2 = {
21
+ // Hosts / CONCACAF
22
+ USA: "US",
23
+ MEX: "MX",
24
+ CAN: "CA",
25
+ CRC: "CR",
26
+ PAN: "PA",
27
+ HON: "HN",
28
+ JAM: "JM",
29
+ SLV: "SV",
30
+ GUA: "GT",
31
+ HAI: "HT",
32
+ TRI: "TT",
33
+ CUB: "CU",
34
+ CUW: "CW",
35
+ SUR: "SR",
36
+ // CONMEBOL
37
+ BRA: "BR",
38
+ ARG: "AR",
39
+ URU: "UY",
40
+ COL: "CO",
41
+ ECU: "EC",
42
+ PAR: "PY",
43
+ CHI: "CL",
44
+ PER: "PE",
45
+ BOL: "BO",
46
+ VEN: "VE",
47
+ // UEFA
48
+ FRA: "FR",
49
+ GER: "DE",
50
+ ESP: "ES",
51
+ POR: "PT",
52
+ NED: "NL",
53
+ ITA: "IT",
54
+ BEL: "BE",
55
+ CRO: "HR",
56
+ SUI: "CH",
57
+ DEN: "DK",
58
+ POL: "PL",
59
+ SRB: "RS",
60
+ AUT: "AT",
61
+ CZE: "CZ",
62
+ SWE: "SE",
63
+ NOR: "NO",
64
+ UKR: "UA",
65
+ TUR: "TR",
66
+ GRE: "GR",
67
+ HUN: "HU",
68
+ ROU: "RO",
69
+ RUS: "RU",
70
+ SVK: "SK",
71
+ SVN: "SI",
72
+ FIN: "FI",
73
+ ISL: "IS",
74
+ ALB: "AL",
75
+ BIH: "BA",
76
+ BUL: "BG",
77
+ MKD: "MK",
78
+ GEO: "GE",
79
+ IRL: "IE",
80
+ LUX: "LU",
81
+ MNE: "ME",
82
+ KVX: "XK",
83
+ // CAF
84
+ RSA: "ZA",
85
+ MAR: "MA",
86
+ SEN: "SN",
87
+ NGA: "NG",
88
+ EGY: "EG",
89
+ CMR: "CM",
90
+ GHA: "GH",
91
+ ALG: "DZ",
92
+ TUN: "TN",
93
+ CIV: "CI",
94
+ MLI: "ML",
95
+ BFA: "BF",
96
+ COD: "CD",
97
+ ANG: "AO",
98
+ CPV: "CV",
99
+ GUI: "GN",
100
+ GAB: "GA",
101
+ ZAM: "ZM",
102
+ KEN: "KE",
103
+ UGA: "UG",
104
+ NAM: "NA",
105
+ MTN: "MR",
106
+ EQG: "GQ",
107
+ BEN: "BJ",
108
+ MOZ: "MZ",
109
+ // AFC
110
+ JPN: "JP",
111
+ KOR: "KR",
112
+ KSA: "SA",
113
+ IRN: "IR",
114
+ AUS: "AU",
115
+ QAT: "QA",
116
+ IRQ: "IQ",
117
+ UAE: "AE",
118
+ UZB: "UZ",
119
+ JOR: "JO",
120
+ CHN: "CN",
121
+ OMA: "OM",
122
+ BHR: "BH",
123
+ SYR: "SY",
124
+ PRK: "KP",
125
+ THA: "TH",
126
+ VIE: "VN",
127
+ IDN: "ID",
128
+ IND: "IN",
129
+ LBN: "LB",
130
+ KGZ: "KG",
131
+ // OFC
132
+ NZL: "NZ",
133
+ NCL: "NC",
134
+ FIJ: "FJ",
135
+ TAH: "PF"
136
+ };
137
+ var SPECIAL = {
138
+ ENG: "\u{1F3F4}\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}",
139
+ // England
140
+ SCO: "\u{1F3F4}\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}",
141
+ // Scotland
142
+ WAL: "\u{1F3F4}\u{E0067}\u{E0062}\u{E0077}\u{E006C}\u{E0073}\u{E007F}"
143
+ // Wales
144
+ };
145
+ function iso2ToFlag(iso2) {
146
+ const up = iso2.toUpperCase();
147
+ if (up.length !== 2) return "";
148
+ const base = 127462;
149
+ return String.fromCodePoint(base + (up.charCodeAt(0) - 65)) + String.fromCodePoint(base + (up.charCodeAt(1) - 65));
150
+ }
151
+ function flagFor(code, name) {
152
+ const cc = code?.toUpperCase();
153
+ if (cc && SPECIAL[cc]) return SPECIAL[cc];
154
+ if (cc && FIFA_TO_ISO2[cc]) return iso2ToFlag(FIFA_TO_ISO2[cc]);
155
+ if (name) {
156
+ const a2 = countries.getAlpha2Code(name, "en");
157
+ if (a2) return iso2ToFlag(a2);
158
+ }
159
+ return WHITE;
160
+ }
161
+
162
+ // src/espn.ts
163
+ var BASE = "https://site.api.espn.com/apis/site/v2/sports/soccer";
164
+ var HEADERS = { "User-Agent": "worldcup-cli", Accept: "application/json" };
165
+ var EspnError = class extends Error {
166
+ };
167
+ async function getJson(url, signal) {
168
+ let res;
169
+ try {
170
+ res = await fetch(url, { headers: HEADERS, signal });
171
+ } catch (e) {
172
+ if (e?.name === "AbortError") throw e;
173
+ throw new EspnError(`Network error: ${e?.message ?? e}`);
174
+ }
175
+ if (!res.ok) throw new EspnError(`ESPN ${res.status} ${res.statusText}`);
176
+ return res.json();
177
+ }
178
+ function num(s) {
179
+ if (s === null || s === void 0 || s === "") return null;
180
+ const n = Number(s);
181
+ return Number.isFinite(n) ? n : null;
182
+ }
183
+ function toState(s) {
184
+ return s === "in" ? "in" : s === "post" ? "post" : "pre";
185
+ }
186
+ var STATE_ORDER = { in: 0, pre: 1, post: 2 };
187
+ function parseSide(c2) {
188
+ const t = c2?.team ?? {};
189
+ const abbr = t.abbreviation ?? t.shortDisplayName ?? "???";
190
+ return {
191
+ id: String(t.id ?? ""),
192
+ abbr,
193
+ name: t.displayName ?? t.name ?? abbr,
194
+ shortName: t.shortDisplayName ?? t.name ?? abbr,
195
+ score: num(c2?.score),
196
+ homeAway: c2?.homeAway === "away" ? "away" : "home",
197
+ flag: flagFor(abbr, t.displayName ?? t.name)
198
+ };
199
+ }
200
+ function parseMatch(e) {
201
+ const comp = e.competitions?.[0] ?? {};
202
+ const comps = comp.competitors ?? [];
203
+ const home = parseSide(comps.find((c2) => c2.homeAway === "home") ?? comps[0] ?? {});
204
+ const away = parseSide(comps.find((c2) => c2.homeAway === "away") ?? comps[1] ?? {});
205
+ const st = e.status?.type ?? {};
206
+ return {
207
+ id: String(e.id),
208
+ date: e.date ?? "",
209
+ state: toState(st.state),
210
+ clock: e.status?.displayClock ?? "",
211
+ shortDetail: st.shortDetail ?? st.description ?? "",
212
+ detail: st.detail ?? "",
213
+ completed: !!st.completed,
214
+ home,
215
+ away
216
+ };
217
+ }
218
+ async function fetchScoreboard(league2, dateYYYYMMDD, signal) {
219
+ const q = dateYYYYMMDD ? `?dates=${dateYYYYMMDD}` : "";
220
+ const data = await getJson(`${BASE}/${league2}/scoreboard${q}`, signal);
221
+ const matches = (data.events ?? []).map(parseMatch);
222
+ matches.sort((a, b) => {
223
+ const s = STATE_ORDER[a.state] - STATE_ORDER[b.state];
224
+ if (s !== 0) return s;
225
+ return (a.date || "").localeCompare(b.date || "");
226
+ });
227
+ return matches;
228
+ }
229
+ function classifyEvent(typeText, scoringPlay) {
230
+ const t = typeText.toLowerCase();
231
+ if (t.includes("own goal")) return "own-goal";
232
+ if (t.includes("penalty") && /miss|saved|no goal|off target/.test(t)) return "penalty-miss";
233
+ if (t.includes("penalty") && (scoringPlay || t.includes("scored") || t.includes("goal"))) return "penalty-goal";
234
+ if (t.includes("goal") || scoringPlay) return "goal";
235
+ if (t.includes("yellow") && t.includes("red")) return "yellow-red";
236
+ if (t.includes("red")) return "red";
237
+ if (t.includes("yellow")) return "yellow";
238
+ if (t.includes("substitution")) return "sub";
239
+ if (t.includes("var")) return "var";
240
+ return "other";
241
+ }
242
+ var WANTED_STATS = [
243
+ { name: "possessionPct", label: "Possession" },
244
+ { name: "totalShots", label: "Shots" },
245
+ { name: "shotsOnTarget", label: "On target" },
246
+ { name: "wonCorners", label: "Corners" },
247
+ { name: "foulsCommitted", label: "Fouls" },
248
+ { name: "saves", label: "Saves" }
249
+ ];
250
+ function fmtStat(name, v) {
251
+ if (v === void 0 || v === null) return "-";
252
+ if (name === "possessionPct") return `${v}%`;
253
+ return String(v);
254
+ }
255
+ function isGoalText(text) {
256
+ return /goal!/i.test(text) && !/disallowed|no goal|chalked off|ruled out/i.test(text);
257
+ }
258
+ async function fetchSummary(league2, eventId, signal) {
259
+ const data = await getJson(`${BASE}/${league2}/summary?event=${eventId}`, signal);
260
+ const hcomp = data.header?.competitions?.[0] ?? {};
261
+ const hcomps = hcomp.competitors ?? [];
262
+ const home = parseSide(hcomps.find((c2) => c2.homeAway === "home") ?? hcomps[0] ?? {});
263
+ const away = parseSide(hcomps.find((c2) => c2.homeAway === "away") ?? hcomps[1] ?? {});
264
+ const st = hcomp.status?.type ?? {};
265
+ const commentary = (data.commentary ?? []).map((c2) => {
266
+ const play = c2.play ?? {};
267
+ const px = play.fieldPositionX;
268
+ const py = play.fieldPositionY;
269
+ const hasPos = typeof px === "number" && typeof py === "number" && !(px === 0 && py === 0);
270
+ return {
271
+ sequence: Number(c2.sequence ?? 0),
272
+ time: c2.time?.displayValue ?? "",
273
+ text: c2.text ?? "",
274
+ isGoal: isGoalText(c2.text ?? ""),
275
+ x: hasPos ? px : void 0,
276
+ y: hasPos ? py : void 0,
277
+ teamId: play.team?.id ? String(play.team.id) : void 0,
278
+ playType: play.type?.text
279
+ };
280
+ }).sort((a, b) => b.sequence - a.sequence);
281
+ const keyEvents = (data.keyEvents ?? []).map((k) => {
282
+ const typeText = k.type?.text ?? "";
283
+ return {
284
+ id: String(k.id ?? ""),
285
+ kind: classifyEvent(typeText, !!k.scoringPlay),
286
+ typeText,
287
+ clock: k.clock?.displayValue ?? "",
288
+ teamId: k.team?.id ? String(k.team.id) : void 0,
289
+ team: k.team?.displayName,
290
+ player: k.participants?.[0]?.athlete?.displayName,
291
+ text: k.text ?? "",
292
+ scoringPlay: !!k.scoringPlay
293
+ };
294
+ });
295
+ const teams = data.boxscore?.teams ?? [];
296
+ const statsOf = (side, ha) => {
297
+ const byId = teams.find((t) => String(t.team?.id ?? "") === side.id);
298
+ if (byId) return byId.statistics ?? [];
299
+ const byHa = teams.find((t) => t.homeAway === ha);
300
+ return byHa?.statistics ?? [];
301
+ };
302
+ const homeStats = statsOf(home, "home");
303
+ const awayStats = statsOf(away, "away");
304
+ const val = (arr, name) => arr.find((s) => s.name === name)?.displayValue;
305
+ const stats = [];
306
+ for (const w of WANTED_STATS) {
307
+ const h = val(homeStats, w.name);
308
+ const a = val(awayStats, w.name);
309
+ if (h === void 0 && a === void 0) continue;
310
+ stats.push({ label: w.label, home: fmtStat(w.name, h), away: fmtStat(w.name, a) });
311
+ }
312
+ return {
313
+ id: String(eventId),
314
+ state: toState(st.state),
315
+ clock: hcomp.status?.displayClock ?? "",
316
+ shortDetail: st.shortDetail ?? "",
317
+ detail: st.detail ?? "",
318
+ venue: data.gameInfo?.venue?.fullName,
319
+ home,
320
+ away,
321
+ commentary,
322
+ keyEvents,
323
+ stats
324
+ };
325
+ }
326
+
327
+ // src/render.ts
328
+ import Table from "cli-table3";
329
+ import pc3 from "picocolors";
330
+ import stringWidth2 from "string-width";
331
+
332
+ // src/theme.ts
333
+ import pc from "picocolors";
334
+ var SYM = {
335
+ live: "\u25CF",
336
+ goal: "\u26BD",
337
+ yellow: "\u{1F7E8}",
338
+ // 🟨
339
+ red: "\u{1F7E5}",
340
+ // 🟥
341
+ sub: "\u{1F501}",
342
+ // 🔁
343
+ var: "\u{1F4FA}",
344
+ // 📺
345
+ dot: "\u2022",
346
+ refresh: "\u27F3",
347
+ ball: "\u26BD",
348
+ sel: "\u25B8"
349
+ };
350
+ var c = {
351
+ title: (s) => pc.bold(pc.magenta(s)),
352
+ live: (s) => pc.green(s),
353
+ finished: (s) => pc.cyan(s),
354
+ upcoming: (s) => pc.gray(s),
355
+ score: (s) => pc.bold(pc.white(s)),
356
+ dim: (s) => pc.gray(s),
357
+ goal: (s) => pc.bold(pc.green(s)),
358
+ yellow: (s) => pc.yellow(s),
359
+ red: (s) => pc.bold(pc.red(s)),
360
+ accent: (s) => pc.magenta(s),
361
+ sel: (s) => pc.bold(pc.magenta(s))
362
+ };
363
+ function commentaryColor(text, isGoal) {
364
+ const t = text.toLowerCase();
365
+ if (isGoal) return c.goal;
366
+ if (t.includes("red card")) return c.red;
367
+ if (t.includes("yellow card") || t.includes("booked")) return c.yellow;
368
+ if (t.includes("penalty")) return c.accent;
369
+ if (/(half time|full time|first half|second half|kick-?off|kicks off|begins|ends|lineups|added time)/.test(t)) {
370
+ return c.dim;
371
+ }
372
+ if (t.includes("substitution") || t.includes("replaces")) return pc.cyan;
373
+ return (s) => s;
374
+ }
375
+
376
+ // src/format.ts
377
+ function truncate(s, max) {
378
+ if (max <= 1) return s.slice(0, Math.max(0, max));
379
+ return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
380
+ }
381
+ function kickoffLocal(iso) {
382
+ if (!iso) return "";
383
+ const d = new Date(iso);
384
+ if (Number.isNaN(d.getTime())) return "";
385
+ return d.toLocaleString(void 0, {
386
+ weekday: "short",
387
+ hour: "numeric",
388
+ minute: "2-digit"
389
+ });
390
+ }
391
+
392
+ // src/pitch.ts
393
+ import pc2 from "picocolors";
394
+ import stringWidth from "string-width";
395
+ var HOME = pc2.cyan;
396
+ var AWAY = pc2.magenta;
397
+ var LINE = (s) => pc2.gray(s);
398
+ function mapEvent(x, y, isHome, IW, IH) {
399
+ const xAbs = isHome ? 1 - x : x;
400
+ const yAbs = isHome ? y : 1 - y;
401
+ const col = Math.max(0, Math.min(IW - 1, Math.round(xAbs * (IW - 1))));
402
+ const row = Math.max(0, Math.min(IH - 1, Math.round(yAbs * (IH - 1))));
403
+ return { col, row };
404
+ }
405
+ function renderPitch(detail, innerWidth) {
406
+ const IW = Math.max(34, Math.min(innerWidth - 4, 60));
407
+ const IH = 9;
408
+ const grid = Array.from({ length: IH }, () => Array.from({ length: IW }, () => " "));
409
+ const rmid = Math.floor(IH / 2);
410
+ const cmid = Math.floor(IW / 2);
411
+ const boxW = 6;
412
+ for (let r = 0; r < IH; r++) grid[r][cmid] = LINE("\u2506");
413
+ if (cmid - 3 > boxW) {
414
+ grid[rmid][cmid - 3] = LINE("(");
415
+ grid[rmid][cmid + 3] = LINE(")");
416
+ }
417
+ grid[rmid][cmid] = LINE("\u2022");
418
+ const r1 = rmid - 2;
419
+ const r2 = rmid + 2;
420
+ for (let r = r1; r <= r2; r++) {
421
+ if (r < 0 || r >= IH) continue;
422
+ grid[r][boxW] = LINE("\u2506");
423
+ grid[r][IW - 1 - boxW] = LINE("\u2506");
424
+ }
425
+ for (let cc = 1; cc < boxW; cc++) {
426
+ if (r1 >= 0) {
427
+ if (grid[r1][cc] === " ") grid[r1][cc] = LINE("\xB7");
428
+ if (grid[r1][IW - 1 - cc] === " ") grid[r1][IW - 1 - cc] = LINE("\xB7");
429
+ }
430
+ if (r2 < IH) {
431
+ if (grid[r2][cc] === " ") grid[r2][cc] = LINE("\xB7");
432
+ if (grid[r2][IW - 1 - cc] === " ") grid[r2][IW - 1 - cc] = LINE("\xB7");
433
+ }
434
+ }
435
+ for (let r = rmid - 1; r <= rmid + 1; r++) {
436
+ if (r < 0 || r >= IH) continue;
437
+ grid[r][0] = LINE("\u2590");
438
+ grid[r][IW - 1] = LINE("\u258C");
439
+ }
440
+ const events = detail.commentary.filter((c2) => c2.x != null && c2.y != null);
441
+ const homeId = detail.home.id;
442
+ const plot = (e, ch) => {
443
+ const { col, row } = mapEvent(e.x, e.y, e.teamId === homeId, IW, IH);
444
+ grid[row][col] = ch;
445
+ };
446
+ const recent = events.slice(0, 7);
447
+ for (let i = recent.length - 1; i >= 1; i--) {
448
+ const e = recent[i];
449
+ plot(e, (e.teamId === homeId ? HOME : AWAY)(pc2.dim("\u2219")));
450
+ }
451
+ for (const g of events.filter((e) => e.isGoal)) plot(g, pc2.bold(pc2.green("\u2605")));
452
+ const newest = recent[0];
453
+ if (newest) {
454
+ plot(newest, newest.isGoal ? pc2.bold(pc2.green("\u2605")) : pc2.bold((newest.teamId === homeId ? HOME : AWAY)("\u25CF")));
455
+ }
456
+ const top = LINE(` \u256D${"\u2500".repeat(IW)}\u256E`);
457
+ const bot = LINE(` \u2570${"\u2500".repeat(IW)}\u256F`);
458
+ const body = grid.map((r) => `${LINE(" \u2502")}${r.join("")}${LINE("\u2502")}`);
459
+ const latest = events[0];
460
+ let legend = ` ${HOME("\u25CF")} ${detail.home.flag} ${pc2.bold(truncate(detail.home.name, 16))} ${pc2.gray("\u2192")} ${pc2.gray("\u2190")} ${pc2.bold(truncate(detail.away.name, 16))} ${detail.away.flag} ${AWAY("\u25CF")}`;
461
+ if (latest) {
462
+ const tag = latest.isGoal ? pc2.bold(pc2.green("\u2605 GOAL")) : pc2.gray(latest.playType || "ball");
463
+ legend += ` ${pc2.gray(latest.time)} ${tag}`;
464
+ }
465
+ void stringWidth;
466
+ return [top, ...body, bot, legend].join("\n");
467
+ }
468
+
469
+ // src/render.ts
470
+ var CHARS = {
471
+ top: "\u2500",
472
+ "top-mid": "\u252C",
473
+ "top-left": "\u256D",
474
+ "top-right": "\u256E",
475
+ bottom: "\u2500",
476
+ "bottom-mid": "\u2534",
477
+ "bottom-left": "\u2570",
478
+ "bottom-right": "\u256F",
479
+ left: "\u2502",
480
+ "left-mid": "\u251C",
481
+ mid: "\u2500",
482
+ "mid-mid": "\u253C",
483
+ right: "\u2502",
484
+ "right-mid": "\u2524",
485
+ middle: "\u2502"
486
+ };
487
+ function makeTable(opts) {
488
+ return new Table({
489
+ chars: CHARS,
490
+ style: { head: [], border: ["gray"], "padding-left": 1, "padding-right": 1 },
491
+ ...opts
492
+ });
493
+ }
494
+ function padEndW(s, w) {
495
+ const d = w - stringWidth2(s);
496
+ return d > 0 ? s + " ".repeat(d) : s;
497
+ }
498
+ function padCenterW(s, w) {
499
+ const d = Math.max(0, w - stringWidth2(s));
500
+ const left = Math.floor(d / 2);
501
+ return " ".repeat(left) + s + " ".repeat(d - left);
502
+ }
503
+ function headerLine(s, width) {
504
+ const left = c.title(`${SYM.ball} ${s.title}`);
505
+ const live = s.matches.filter((m) => m.state === "in").length;
506
+ const liveTxt = live > 0 ? `${c.live(`${SYM.live} ${live} LIVE`)} ` : "";
507
+ const upd = s.updatedAt ? new Date(s.updatedAt).toLocaleTimeString() : "\u2014";
508
+ const cd = s.countdown != null ? ` ${SYM.refresh}${s.countdown}s` : "";
509
+ const right = `${liveTxt}${pc3.gray(`${s.matches.length} matches \xB7 ${upd}${cd}`)}`;
510
+ const pad = Math.max(1, width - stringWidth2(left) - stringWidth2(right));
511
+ return `${left}${" ".repeat(pad)}${right}`;
512
+ }
513
+ function fixturesList(s, width) {
514
+ const NAME = 14;
515
+ const COLW = NAME + 3;
516
+ const lines = [` ${pc3.bold(pc3.gray("MATCHES"))}`, pc3.gray(` ${"\u2500".repeat(Math.min(width - 2, 64))}`)];
517
+ s.matches.forEach((m, i) => {
518
+ const sel = i === s.selected;
519
+ const home = padEndW(`${m.home.flag} ${truncate(m.home.name, NAME)}`, COLW);
520
+ const away = padEndW(`${truncate(m.away.name, NAME)} ${m.away.flag}`, COLW);
521
+ const score = m.state === "pre" ? pc3.gray(" v ") : padCenterW(c.score(`${m.home.score ?? 0}-${m.away.score ?? 0}`), 5);
522
+ const status = m.state === "in" ? c.live(`${SYM.live} ${m.clock || "LIVE"}`) : m.state === "post" ? c.finished(m.shortDetail || "FT") : c.upcoming(kickoffLocal(m.date) || "Scheduled");
523
+ const arrow = sel ? c.sel(SYM.sel) : " ";
524
+ const row = ` ${arrow} ${sel ? c.sel(home) : home} ${score} ${sel ? c.sel(away) : away} ${status}`;
525
+ lines.push(row);
526
+ });
527
+ return lines.join("\n");
528
+ }
529
+ function scoreStrip(s, m) {
530
+ const d = s.detail && s.detail.id === m.id ? s.detail : null;
531
+ const home = d?.home ?? m.home;
532
+ const away = d?.away ?? m.away;
533
+ const state = d?.state ?? m.state;
534
+ const clock = d?.clock || m.clock;
535
+ const badge = state === "in" ? c.live(`${SYM.live} ${clock || "LIVE"}`) : state === "post" ? c.finished(d?.shortDetail || m.shortDetail || "FT") : c.upcoming(kickoffLocal(m.date) || "Scheduled");
536
+ const hs = home.score ?? "\u2013";
537
+ const as = away.score ?? "\u2013";
538
+ const hn = pc3.bold(truncate(home.name, 22));
539
+ const an = pc3.bold(truncate(away.name, 22));
540
+ return ` ${home.flag} ${hn} ${c.score(String(hs))} ${pc3.gray(":")} ${c.score(String(as))} ${an} ${away.flag} ${badge}`;
541
+ }
542
+ function statsStrip(d, width) {
543
+ if (!d || d.stats.length === 0) return "";
544
+ const sep = pc3.gray(" \xB7 ");
545
+ let line = " ";
546
+ let count = 0;
547
+ for (const st of d.stats) {
548
+ const piece = `${pc3.white(st.home)} ${pc3.gray(st.label)} ${pc3.white(st.away)}`;
549
+ const next = count === 0 ? line + piece : line + sep + piece;
550
+ if (stringWidth2(next) > width) break;
551
+ line = next;
552
+ count += 1;
553
+ }
554
+ return count > 0 ? line : "";
555
+ }
556
+ function commentaryTable(d, width, budget) {
557
+ const textCol = Math.max(30, width - 9);
558
+ const wrapW = textCol - 2;
559
+ const t = makeTable({
560
+ head: [pc3.gray("Min"), pc3.gray("Commentary")],
561
+ colWidths: [6, textCol],
562
+ wordWrap: true
563
+ });
564
+ let used = 0;
565
+ for (const line of d.commentary) {
566
+ const text = truncate(line.text, wrapW * 2);
567
+ const wrapped = Math.max(1, Math.ceil(stringWidth2(text) / wrapW));
568
+ if (used + wrapped > budget && used > 0) break;
569
+ used += wrapped;
570
+ t.push([c.accent(line.time), commentaryColor(line.text, line.isGoal)(text)]);
571
+ }
572
+ if (used === 0) return pc3.gray(" Awaiting first updates\u2026");
573
+ return t.toString();
574
+ }
575
+ function preNote(m, width) {
576
+ const ko = kickoffLocal(m.date) || m.detail || "TBD";
577
+ return [
578
+ ` ${pc3.gray("Kickoff:")} ${pc3.bold(ko)}`,
579
+ ` ${pc3.gray(truncate(m.detail || "", width))}`,
580
+ ` ${pc3.gray(`Live commentary & stats appear at kickoff ${SYM.ball}`)}`
581
+ ].join("\n");
582
+ }
583
+ function footer(s) {
584
+ const pitch = s.showPitch ? "[p] pitch\u2713" : "[p] pitch";
585
+ return pc3.gray(` [\u2191\u2193] navigate \xB7 ${pitch} \xB7 [q] quit \xB7 data: ESPN (unofficial) \xB7 ${s.league}`);
586
+ }
587
+ function renderFrame(s) {
588
+ const W = Math.min((s.cols || 80) - 2, 118);
589
+ if (s.error && s.matches.length === 0) {
590
+ return `${pc3.red(`\u26A0 Could not reach ESPN: ${s.error}`)}
591
+ ${pc3.gray("Will keep retrying\u2026")}`;
592
+ }
593
+ if (s.matches.length === 0) {
594
+ return `${headerLine(s, W)}
595
+ ${pc3.gray(" No matches found for this view.")}`;
596
+ }
597
+ const cur = s.matches[s.selected] ?? null;
598
+ const out = [headerLine(s, W), "", fixturesList(s, W)];
599
+ if (cur) {
600
+ out.push("", scoreStrip(s, cur));
601
+ const d = s.detail && s.detail.id === cur.id ? s.detail : null;
602
+ if (cur.state === "pre") {
603
+ out.push(preNote(cur, W));
604
+ } else if (s.detailLoading && !d) {
605
+ out.push(pc3.gray(" loading match\u2026"));
606
+ } else if (d) {
607
+ const stats = statsStrip(d, W);
608
+ if (stats) out.push(stats);
609
+ const hasPos = d.commentary.some((cl) => cl.x != null && cl.y != null);
610
+ const pitchH = s.showPitch && hasPos ? 12 : 0;
611
+ if (pitchH) out.push(renderPitch(d, W));
612
+ const overhead = 2 + (s.matches.length + 2) + 2 + (stats ? 1 : 0) + 1 + 4 + pitchH;
613
+ const budget = Math.min(40, Math.max(4, (s.rows || 24) - overhead));
614
+ out.push(commentaryTable(d, W, budget));
615
+ }
616
+ }
617
+ if (!s.once) out.push(footer(s));
618
+ return out.join("\n");
619
+ }
620
+
621
+ // src/app.ts
622
+ async function runApp(opts) {
623
+ const boardMs = Math.max(5, opts.refreshSec) * 1e3;
624
+ const detailMs = Math.max(5, Math.min(opts.refreshSec, 12)) * 1e3;
625
+ const state = {
626
+ matches: [],
627
+ detail: null,
628
+ selected: 0,
629
+ error: null,
630
+ updatedAt: null,
631
+ detailLoading: false,
632
+ countdown: null,
633
+ showPitch: true
634
+ };
635
+ const draw = () => logUpdate(
636
+ renderFrame({
637
+ title: opts.title,
638
+ league: opts.league,
639
+ once: opts.once,
640
+ matches: state.matches,
641
+ detail: state.detail,
642
+ selected: state.selected,
643
+ error: state.error,
644
+ updatedAt: state.updatedAt,
645
+ detailLoading: state.detailLoading,
646
+ countdown: state.countdown,
647
+ showPitch: state.showPitch,
648
+ cols: process.stdout.columns ?? (Number(process.env.COLUMNS) || 80),
649
+ rows: process.stdout.rows ?? (Number(process.env.LINES) || 24)
650
+ })
651
+ );
652
+ const loadBoard = async () => {
653
+ try {
654
+ const matches = await fetchScoreboard(opts.league, opts.date);
655
+ state.matches = matches;
656
+ state.error = null;
657
+ state.updatedAt = Date.now();
658
+ if (state.selected > matches.length - 1) state.selected = Math.max(0, matches.length - 1);
659
+ } catch (e) {
660
+ if (e?.name !== "AbortError") state.error = e?.message ?? String(e);
661
+ }
662
+ };
663
+ let detailReq = 0;
664
+ const loadDetail = async () => {
665
+ const cur = state.matches[state.selected];
666
+ if (!cur) {
667
+ state.detail = null;
668
+ return;
669
+ }
670
+ const id = cur.id;
671
+ const my = ++detailReq;
672
+ state.detailLoading = true;
673
+ try {
674
+ const detail = await fetchSummary(opts.league, id);
675
+ if (my !== detailReq) return;
676
+ state.detail = detail;
677
+ } catch {
678
+ } finally {
679
+ if (my === detailReq) state.detailLoading = false;
680
+ }
681
+ };
682
+ await loadBoard();
683
+ const liveIdx = state.matches.findIndex((m) => m.state === "in");
684
+ if (liveIdx >= 0) state.selected = liveIdx;
685
+ await loadDetail();
686
+ draw();
687
+ if (opts.once || !process.stdin.isTTY) {
688
+ logUpdate.done();
689
+ return;
690
+ }
691
+ state.countdown = Math.round(boardMs / 1e3);
692
+ const boardTimer = setInterval(async () => {
693
+ await loadBoard();
694
+ state.countdown = Math.round(boardMs / 1e3);
695
+ draw();
696
+ }, boardMs);
697
+ const detailTimer = setInterval(async () => {
698
+ await loadDetail();
699
+ draw();
700
+ }, detailMs);
701
+ const tick = setInterval(() => {
702
+ if (state.countdown != null) state.countdown = Math.max(0, state.countdown - 1);
703
+ draw();
704
+ }, 1e3);
705
+ const cleanup = () => {
706
+ clearInterval(boardTimer);
707
+ clearInterval(detailTimer);
708
+ clearInterval(tick);
709
+ process.stdin.off("keypress", onKey);
710
+ try {
711
+ process.stdin.setRawMode(false);
712
+ } catch {
713
+ }
714
+ logUpdate.done();
715
+ process.stdout.write("\n");
716
+ process.exit(0);
717
+ };
718
+ const reselect = async (delta) => {
719
+ const next = Math.min(state.matches.length - 1, Math.max(0, state.selected + delta));
720
+ if (next === state.selected) return;
721
+ state.selected = next;
722
+ state.detail = null;
723
+ draw();
724
+ await loadDetail();
725
+ draw();
726
+ };
727
+ const onKey = (_str, key) => {
728
+ if (!key) return;
729
+ if (key.name === "q" || key.name === "escape" || key.ctrl && key.name === "c") {
730
+ cleanup();
731
+ return;
732
+ }
733
+ if (key.name === "up" || key.name === "k") void reselect(-1);
734
+ else if (key.name === "down" || key.name === "j") void reselect(1);
735
+ else if (key.name === "p") {
736
+ state.showPitch = !state.showPitch;
737
+ draw();
738
+ } else if (key.name === "r") void loadBoard().then(draw);
739
+ };
740
+ readline.emitKeypressEvents(process.stdin);
741
+ process.stdin.setRawMode(true);
742
+ process.stdin.resume();
743
+ process.stdin.on("keypress", onKey);
744
+ }
745
+
746
+ // bin/index.ts
747
+ var argv = process2.argv.slice(2);
748
+ function opt(name) {
749
+ const i = argv.indexOf(name);
750
+ return i >= 0 ? argv[i + 1] : void 0;
751
+ }
752
+ function flag(name) {
753
+ return argv.includes(name);
754
+ }
755
+ if (flag("--help") || flag("-h")) {
756
+ console.log(`
757
+ \u26BD soccer \u2014 live football scores & commentary in your terminal
758
+
759
+ Usage
760
+ $ soccer [options]
761
+
762
+ Options
763
+ --league <slug> ESPN league slug (default: fifa.world)
764
+ e.g. eng.1, esp.1, ger.1, ita.1, uefa.champions
765
+ --date <YYYYMMDD> Show a specific day's fixtures
766
+ --refresh <sec> Auto-refresh interval (default: 15, min 5)
767
+ --once Print one snapshot and exit (good for piping/cron)
768
+ --title <text> Override the header title
769
+ -h, --help Show this help
770
+
771
+ Keys
772
+ \u2191/\u2193 or j/k Move between matches
773
+ r Force refresh
774
+ q or Esc Quit
775
+
776
+ Examples
777
+ $ soccer # FIFA World Cup, live
778
+ $ soccer --league eng.1 # Premier League
779
+ $ soccer --date 20260611 --once # snapshot of a given day
780
+
781
+ Data: ESPN's public (unofficial) feed. No API key required.
782
+ `);
783
+ process2.exit(0);
784
+ }
785
+ var league = opt("--league") ?? "fifa.world";
786
+ var date = opt("--date");
787
+ var refreshSec = Number(opt("--refresh") ?? "15") || 15;
788
+ var once = flag("--once") || !process2.stdin.isTTY;
789
+ var TITLES = {
790
+ "fifa.world": "FIFA WORLD CUP 2026",
791
+ "eng.1": "PREMIER LEAGUE",
792
+ "esp.1": "LA LIGA",
793
+ "ger.1": "BUNDESLIGA",
794
+ "ita.1": "SERIE A",
795
+ "fra.1": "LIGUE 1",
796
+ "uefa.champions": "CHAMPIONS LEAGUE"
797
+ };
798
+ var title = opt("--title") ?? TITLES[league] ?? league.toUpperCase();
799
+ runApp({ league, title, date, refreshSec, once }).catch((err) => {
800
+ console.error(err);
801
+ process2.exit(1);
802
+ });
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "soccer-cli",
3
+ "version": "0.1.0",
4
+ "description": "Live football scores, ball-by-ball commentary, and a live ASCII pitch in your terminal. Built for the 2026 World Cup, works for every major league. No API key required.",
5
+ "type": "module",
6
+ "bin": {
7
+ "soccer": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "scripts": {
16
+ "dev": "tsx bin/index.ts",
17
+ "build": "tsup",
18
+ "start": "node dist/index.js",
19
+ "typecheck": "tsc --noEmit",
20
+ "prepublishOnly": "npm run typecheck && npm run build"
21
+ },
22
+ "keywords": [
23
+ "soccer",
24
+ "football",
25
+ "world-cup",
26
+ "fifa",
27
+ "cli",
28
+ "tui",
29
+ "live-scores",
30
+ "commentary",
31
+ "terminal",
32
+ "espn"
33
+ ],
34
+ "author": "SpunkySarb <sarbsagar143@gmail.com>",
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/SpunkySarb/soccer-cli.git"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/SpunkySarb/soccer-cli/issues"
42
+ },
43
+ "homepage": "https://github.com/SpunkySarb/soccer-cli#readme",
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "dependencies": {
48
+ "cli-table3": "^0.6.5",
49
+ "i18n-iso-countries": "^7.13.0",
50
+ "log-update": "^6.1.0",
51
+ "picocolors": "^1.1.1",
52
+ "string-width": "^7.2.0"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^22.10.0",
56
+ "tsup": "^8.3.5",
57
+ "tsx": "^4.19.2",
58
+ "typescript": "^5.7.2"
59
+ }
60
+ }