sportsing 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.
@@ -0,0 +1,82 @@
1
+ import { c } from "../ansi.ts";
2
+ import { findEvent, getMatchStats, type EspnTeamStats } from "../espn.ts";
3
+
4
+ // `sportsing fifa stats <team> [team] [--json]` — per-match statistics
5
+ // (possession, shots, passes, cards…) from ESPN. Resolves the most recent
6
+ // played match for the given team(s). --json emits the raw structured data
7
+ // for a Claude agent to analyze.
8
+
9
+ // Curated display order + labels for the stats we surface (ESPN stat `name` →
10
+ // human label). Anything not listed is omitted from the table view (but kept
11
+ // in --json).
12
+ const ROWS: { name: string; label: string; suffix?: string }[] = [
13
+ { name: "possessionPct", label: "Possession", suffix: "%" },
14
+ { name: "totalShots", label: "Shots" },
15
+ { name: "shotsOnTarget", label: "On target" },
16
+ { name: "wonCorners", label: "Corners" },
17
+ { name: "foulsCommitted", label: "Fouls" },
18
+ { name: "yellowCards", label: "Yellow cards" },
19
+ { name: "redCards", label: "Red cards" },
20
+ { name: "offsides", label: "Offsides" },
21
+ { name: "saves", label: "Saves" },
22
+ { name: "accuratePasses", label: "Accurate passes" },
23
+ { name: "totalPasses", label: "Total passes" },
24
+ ];
25
+
26
+ export async function stats(args: string[]) {
27
+ const json = args.includes("--json");
28
+ const terms = args.filter((a) => !a.startsWith("--"));
29
+ if (terms.length === 0) {
30
+ console.error(c.red("Usage: sportsing fifa stats <team> [team] [--json]"));
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+
35
+ const ev = await findEvent(terms, { playedOnly: true });
36
+ if (!ev) {
37
+ console.log(c.dim(`No played match found for "${terms.join(" ")}". Stats appear once a match kicks off.`));
38
+ return;
39
+ }
40
+
41
+ const teamStats = await getMatchStats(ev.id);
42
+
43
+ if (json) {
44
+ console.log(JSON.stringify({ event: ev, teams: teamStats }, null, 2));
45
+ return;
46
+ }
47
+
48
+ if (teamStats.length < 2) {
49
+ console.log(c.dim("No statistics available for this match yet."));
50
+ return;
51
+ }
52
+
53
+ const home = ev.competitors.find((t) => t.homeAway === "home");
54
+ const away = ev.competitors.find((t) => t.homeAway === "away");
55
+ const homeStats = byAbbr(teamStats, home?.abbreviation) ?? teamStats[0]!;
56
+ const awayStats = byAbbr(teamStats, away?.abbreviation) ?? teamStats[1]!;
57
+
58
+ const score = home && away ? `${home.score} – ${away.score}` : "";
59
+ console.log(
60
+ c.bold(c.cyan(`⚽ ${homeStats.team} ${score} ${awayStats.team}`)) + " " + c.dim(ev.detail),
61
+ );
62
+ console.log();
63
+
64
+ const W = 16;
65
+ const col = (s: string) => s.padStart(7);
66
+ console.log(c.dim("".padEnd(W)) + col(homeStats.abbreviation) + " " + col(awayStats.abbreviation));
67
+ for (const row of ROWS) {
68
+ const h = lookup(homeStats, row.name);
69
+ const a = lookup(awayStats, row.name);
70
+ if (h === null && a === null) continue;
71
+ const fmt = (v: string | null) => (v === null ? "—" : v + (row.suffix ?? ""));
72
+ console.log(row.label.padEnd(W) + col(fmt(h)) + " " + col(fmt(a)));
73
+ }
74
+ }
75
+
76
+ function byAbbr(all: EspnTeamStats[], abbr?: string): EspnTeamStats | undefined {
77
+ return abbr ? all.find((t) => t.abbreviation === abbr) : undefined;
78
+ }
79
+
80
+ function lookup(t: EspnTeamStats, name: string): string | null {
81
+ return t.stats.find((s) => s.name === name)?.value ?? null;
82
+ }
@@ -0,0 +1,47 @@
1
+ import { c } from "../ansi.ts";
2
+ import { getStandings, NoKeyError } from "../api.ts";
3
+ import { standingsTable, groupName } from "../format.ts";
4
+
5
+ export async function table(args: string[]) {
6
+ const filter = args.find((a) => !a.startsWith("--"))?.toUpperCase() ?? null;
7
+
8
+ let standings;
9
+ try {
10
+ standings = (await getStandings()).standings;
11
+ } catch (e) {
12
+ if (e instanceof NoKeyError) {
13
+ console.error(
14
+ c.yellow("Group tables need live data. Run `sportsing setup` to add a free API key."),
15
+ );
16
+ return;
17
+ }
18
+ throw e;
19
+ }
20
+
21
+ const groups = standings.filter((s) => s.type === "TOTAL" && s.group);
22
+
23
+ if (groups.length === 0) {
24
+ console.log(
25
+ c.dim(
26
+ "No standings yet — the group stage hasn't produced results. Check back after kickoff (Jun 11).",
27
+ ),
28
+ );
29
+ return;
30
+ }
31
+
32
+ console.log(c.bold(c.cyan("⚽ World Cup 2026 — Group Standings")));
33
+ console.log(c.dim("Green = advancing (top 2 per group)\n"));
34
+
35
+ const wanted = groups.filter((g) => {
36
+ if (!filter) return true;
37
+ const letter = g.group?.replace("GROUP_", "");
38
+ return letter === filter || groupName(g.group).toUpperCase().includes(filter);
39
+ });
40
+
41
+ if (wanted.length === 0) {
42
+ console.log(c.yellow(`No group "${filter}". Groups run A–L.`));
43
+ return;
44
+ }
45
+
46
+ console.log(wanted.map((g) => standingsTable(g.group, g.table)).join("\n\n"));
47
+ }
@@ -0,0 +1,66 @@
1
+ import { c } from "../ansi.ts";
2
+ import { getMatches } from "../api.ts";
3
+ import { groupName } from "../format.ts";
4
+ import { withFallback, getFlag } from "./_lib.ts";
5
+
6
+ // `sportsing fifa teams [--group A] [--json]` — the teams in the tournament,
7
+ // grouped by group. Derived from the fixture list (so it works with or without
8
+ // an API key), keyed by name; group + TLA filled in from group-stage matches.
9
+ interface TeamInfo {
10
+ name: string;
11
+ tla: string | null;
12
+ group: string | null; // e.g. "GROUP_A"
13
+ }
14
+
15
+ export async function teams(args: string[]) {
16
+ const json = args.includes("--json");
17
+ const groupArg = getFlag(args, "--group"); // throws if --group has no value
18
+
19
+ const matches = await withFallback(
20
+ async () => (await getMatches({})).matches,
21
+ (all) => all,
22
+ );
23
+
24
+ const byName = new Map<string, TeamInfo>();
25
+ for (const m of matches) {
26
+ const g = m.stage === "GROUP_STAGE" ? m.group : null;
27
+ for (const t of [m.homeTeam, m.awayTeam]) {
28
+ if (!t.name) continue;
29
+ const cur = byName.get(t.name) ?? { name: t.name, tla: null, group: null };
30
+ if (!cur.tla && t.tla) cur.tla = t.tla;
31
+ if (!cur.group && g) cur.group = g;
32
+ byName.set(t.name, cur);
33
+ }
34
+ }
35
+
36
+ let list = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
37
+
38
+ if (groupArg) {
39
+ const want = `GROUP_${groupArg.toUpperCase()}`;
40
+ list = list.filter((t) => t.group === want);
41
+ if (list.length === 0) {
42
+ console.log(c.yellow(`No teams found in group ${groupArg.toUpperCase()}.`));
43
+ return;
44
+ }
45
+ }
46
+
47
+ if (json) {
48
+ console.log(JSON.stringify(list, null, 2));
49
+ return;
50
+ }
51
+
52
+ console.log(c.bold(c.cyan(`⚽ World Cup 2026 — Teams (${list.length})`)));
53
+
54
+ const groups = new Map<string, TeamInfo[]>();
55
+ for (const t of list) {
56
+ const key = t.group ?? "ZZZ_unknown"; // sort unknowns last
57
+ (groups.get(key) ?? groups.set(key, []).get(key)!).push(t);
58
+ }
59
+ for (const key of [...groups.keys()].sort()) {
60
+ const label = key === "ZZZ_unknown" ? "Unknown group" : groupName(key);
61
+ console.log("\n" + c.bold(label));
62
+ for (const t of groups.get(key)!) {
63
+ console.log(" " + t.name + (t.tla ? c.dim(` (${t.tla})`) : ""));
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,58 @@
1
+ import { c } from "../ansi.ts";
2
+ import { getMatches } from "../api.ts";
3
+ import { matchLine, stageLabel, heading } from "../format.ts";
4
+ import { ymd, addDays, localDateOf, withFallback, sortByDate, applyMine, noFavoritesHint } from "./_lib.ts";
5
+ import type { Match } from "../types.ts";
6
+
7
+ export async function today(args: string[]) {
8
+ const offset = parseOffset(args);
9
+ const day = new Date();
10
+ day.setDate(day.getDate() + offset);
11
+ const date = ymd(day);
12
+
13
+ // A local calendar day straddles two UTC days, so query ±1 day and then
14
+ // filter to matches whose *local* date is the one we want. The API filters
15
+ // by UTC date, which would otherwise pull in late games from the day before.
16
+ const fetched = await withFallback(
17
+ async () =>
18
+ (await getMatches({ dateFrom: ymd(addDays(day, -1)), dateTo: ymd(addDays(day, 1)) })).matches.filter(
19
+ (m) => localDateOf(m.utcDate) === date,
20
+ ),
21
+ (all) => all.filter((m) => localDateOf(m.utcDate) === date),
22
+ );
23
+
24
+ const mine = await applyMine(fetched, args);
25
+ if (mine === "no-favorites") return noFavoritesHint();
26
+ const matches = mine;
27
+
28
+ const label =
29
+ offset === 0 ? "Today" : offset === 1 ? "Tomorrow" : offset === -1 ? "Yesterday" : date;
30
+ console.log(c.bold(c.cyan(`⚽ World Cup 2026 — ${label} (${date})`)));
31
+
32
+ if (matches.length === 0) {
33
+ console.log(c.dim("\nNo matches scheduled."));
34
+ return;
35
+ }
36
+ printGrouped(matches);
37
+ }
38
+
39
+ /** Group matches by stage/group and print, sorted by kickoff. */
40
+ export function printGrouped(matches: Match[]) {
41
+ const buckets = new Map<string, Match[]>();
42
+ for (const m of matches) {
43
+ const key = stageLabel(m);
44
+ (buckets.get(key) ?? buckets.set(key, []).get(key)!).push(m);
45
+ }
46
+ for (const [name, ms] of buckets) {
47
+ console.log(heading(name));
48
+ for (const m of ms.sort(sortByDate)) console.log(" " + matchLine(m));
49
+ }
50
+ }
51
+
52
+ function parseOffset(args: string[]): number {
53
+ if (args.includes("--tomorrow")) return 1;
54
+ if (args.includes("--yesterday")) return -1;
55
+ const i = args.indexOf("--offset");
56
+ if (i >= 0 && args[i + 1]) return parseInt(args[i + 1]!, 10) || 0;
57
+ return 0;
58
+ }
@@ -0,0 +1,230 @@
1
+ import { c } from "../ansi.ts";
2
+ import { findCurrentMatch, resolveWatchTarget, type EspnEvent } from "../espn.ts";
3
+ import { getStreamProvider } from "../config.ts";
4
+ import { PROVIDERS, launchStream, spawnStreamWindow } from "../stream.ts";
5
+ import { runOverlayStream, type WatchLang } from "../overlay.ts";
6
+ import { freePort, attachToPage } from "../cdp.ts";
7
+ import { writeWatchPidfile } from "../liveness.ts";
8
+ import { getFlag } from "./_lib.ts";
9
+
10
+ // `sportsing fifa watch [team] [team] [--wait] [--provider peacock|fubo] [--url <link>] [--overlay] [--lang english|spanish]`
11
+ // Opens the broadcast in a persistent-profile Chrome window via ui-leaf.
12
+ // --wait block until the match is live, then open it (deep-links to the
13
+ // game with the overlay). With no team, waits for the NEXT match —
14
+ // i.e. `sportsing fifa watch --wait` = "open the next game when it
15
+ // goes live". Polls ESPN's state (the prompt live signal), not the
16
+ // lagging football-data feed behind `live`.
17
+ // --url jump straight to a specific game link (skips the hub)
18
+ // --provider override the configured default (config.streamProvider, else fubo)
19
+ // --overlay inject a live-stats panel onto the page via CDP (interactive; needs ui-leaf >=1.5)
20
+ // --lang preferred broadcast language (english|spanish, default english) — biases
21
+ // deep-linking on providers (Fubo) that carry both Fox & Telemundo airings
22
+ export async function watch(args: string[]) {
23
+ const url = getFlag(args, "--url"); // throws if --url has no value
24
+ const providerFlag = getFlag(args, "--provider");
25
+ const overlay = args.includes("--overlay");
26
+ const wait = args.includes("--wait");
27
+ const sizeFlag = getFlag(args, "--size"); // e.g. --size 660x500
28
+ const windowSize = parseSize(sizeFlag);
29
+ if (sizeFlag && !windowSize) {
30
+ console.warn(c.yellow(`Ignoring --size "${sizeFlag}" — expected WxH, e.g. 660x500. Opening at the default size.`));
31
+ }
32
+
33
+ // Preferred broadcast language (default english). Validated like --provider.
34
+ const langFlag = getFlag(args, "--lang"); // throws if --lang has no value
35
+ let lang: WatchLang = "english";
36
+ if (langFlag) {
37
+ const v = langFlag.toLowerCase();
38
+ if (v === "english" || v === "spanish") {
39
+ lang = v; // positive check narrows v to WatchLang
40
+ } else {
41
+ console.error(c.red(`Unknown language "${langFlag}". Known: english, spanish.`));
42
+ process.exitCode = 1;
43
+ return;
44
+ }
45
+ }
46
+
47
+ const terms = positionalTerms(args);
48
+
49
+ // Default to Fubo (English/Fox). Peacock is Spanish-only (Telemundo).
50
+ const key = (providerFlag ?? (await getStreamProvider()) ?? "fubo").toLowerCase();
51
+ const provider = PROVIDERS[key];
52
+ if (!provider) {
53
+ console.error(c.red(`Unknown provider "${key}". Known: ${Object.keys(PROVIDERS).join(", ")}.`));
54
+ process.exitCode = 1;
55
+ return;
56
+ }
57
+
58
+ // `watch` opens a window and BLOCKS until you close it (or Ctrl-C). With no
59
+ // controlling TTY — an agent/build smoke-test, `< /dev/null` — nothing ever
60
+ // closes it, so it would hang forever holding a Chrome + ui-leaf process tree.
61
+ // Guard that: without a TTY, either run a bounded --smoke (open → confirm → tear
62
+ // down → exit 0) or refuse with a message naming the alternatives.
63
+ // --smoke is bounded (open → confirm → tear down → exit) and works in ANY
64
+ // context, TTY or not — an operator in a terminal can smoke-test too.
65
+ if (args.includes("--smoke")) return smokeWatch(url ?? provider.hub, provider.label, windowSize);
66
+ // With no controlling TTY, watch would block forever with nothing to close it —
67
+ // refuse, UNLESS `--supervised --wait`: the agent-setup supervisor deliberately
68
+ // backgrounds `watch --wait --supervised` and reaps it via the pidfile, so the
69
+ // long block is intentional and managed. We require --wait too because only the
70
+ // --wait path writes the pidfile — `--supervised` without it would still hang
71
+ // unreaped, so it doesn't earn the bypass. The guard otherwise catches ACCIDENTAL
72
+ // non-interactive hangs (a build smoke-test).
73
+ const supervised = args.includes("--supervised") && wait;
74
+ if (process.stdin.isTTY !== true && !supervised) {
75
+ console.error(c.yellow("`watch` is interactive — it opens a stream window and blocks until you close it, so it isn't usable in scripts or smoke-tests."));
76
+ console.error(c.dim("Run it in a terminal; use `--smoke` to just confirm the window opens; or `--supervised` for a pidfile-managed background watcher (what `/loop agent-setup` uses)."));
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+
81
+ // --wait: block until a match is live, then open it. The whole point is to
82
+ // open THE GAME, which needs the deep-link path (overlay), so --wait always
83
+ // opens with the overlay + auto-navigation regardless of --overlay.
84
+ if (wait) {
85
+ // Record liveness so a supervisor (agent-setup --check / /loop agent-setup)
86
+ // can detect a silent death and restart. Removed on exit/SIGINT/SIGTERM.
87
+ writeWatchPidfile();
88
+ const ev = await waitForLive(terms);
89
+ await runOverlayStream(url ?? provider.hub, provider.label, ev, { deepLink: !url, windowSize, lang });
90
+ return;
91
+ }
92
+
93
+ // --overlay needs a resolved match (for the panel's stats + head-to-head).
94
+ if (overlay) {
95
+ if (terms.length === 0) {
96
+ console.error(c.red("Usage: sportsing fifa watch <team> [team] --overlay [--provider] [--url] (or add --wait to wait for the next game)"));
97
+ process.exitCode = 1;
98
+ return;
99
+ }
100
+ const ev = await findCurrentMatch(terms);
101
+ if (!ev) {
102
+ console.error(c.yellow(`No match found for "${terms.join(" ")}".`));
103
+ process.exitCode = 1;
104
+ return;
105
+ }
106
+ // deep-link only when we opened the hub (no explicit --url to honor).
107
+ await runOverlayStream(url ?? provider.hub, provider.label, ev, { deepLink: !url, windowSize, lang });
108
+ return;
109
+ }
110
+
111
+ // Direct link wins — open it straight away (no match lookup needed).
112
+ if (url) {
113
+ await launchStream(url, provider.label, { windowSize });
114
+ return;
115
+ }
116
+
117
+ if (terms.length === 0) {
118
+ console.error(c.red("Usage: sportsing fifa watch <team> [team] [--wait] [--provider peacock|fubo] [--url <link>] [--overlay] [--lang english|spanish] [--smoke] [--supervised]"));
119
+ console.error(c.dim("For a hands-off agent-driven session (open the game + answer Ask Claude / catchup), use /loop agent-setup — see sportsing fifa agent-setup"));
120
+ process.exitCode = 1;
121
+ return;
122
+ }
123
+
124
+ // Resolve the match for context (and to fail clearly on a bad team name).
125
+ const ev = await findCurrentMatch(terms);
126
+ if (!ev) {
127
+ console.error(c.yellow(`No match found for "${terms.join(" ")}".`));
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+
132
+ // No per-game deep-link API exists for these providers, so open the provider's
133
+ // hub and let the user pick the game (use --url for a known direct link).
134
+ console.log(c.dim(`${ev.name} — opening ${provider.label}'s hub (use --url for a direct game link).`));
135
+ await launchStream(provider.hub, provider.label, { windowSize });
136
+ }
137
+
138
+ /** Poll ESPN until the target match goes live, then return it. The target is the
139
+ * live-or-soonest match for `terms` (or any match if `terms` is empty). Polls
140
+ * faster as kickoff nears. Blocks indefinitely (Ctrl-C to stop) — meant to be
141
+ * left running. */
142
+ async function waitForLive(terms: string[]): Promise<EspnEvent> {
143
+ const who = terms.length ? `"${terms.join(" ")}"` : "the next match";
144
+ console.log(c.bold(c.cyan(`⌛ Waiting for ${who} to go live…`)) + c.dim(" (Ctrl-C to stop)"));
145
+ let lastName = "";
146
+ for (;;) {
147
+ let target: EspnEvent | null = null;
148
+ try {
149
+ target = await resolveWatchTarget(terms);
150
+ } catch (e) {
151
+ console.error(c.dim(" (data fetch failed, retrying) " + (e instanceof Error ? e.message : String(e))));
152
+ }
153
+
154
+ if (target?.state === "in") {
155
+ console.log(c.green(`● ${target.name} is LIVE — opening…`));
156
+ return target;
157
+ }
158
+
159
+ let pollMs = 60_000;
160
+ if (!target) {
161
+ console.log(c.dim(` Nothing scheduled for ${who} yet — checking again in 60s.`));
162
+ } else {
163
+ if (target.name !== lastName) {
164
+ console.log(c.dim(` Next up: ${target.name}`));
165
+ lastName = target.name;
166
+ }
167
+ const ms = Date.parse(target.date) - Date.now();
168
+ const eta = ms > 0 ? `kicks off in ${fmtEta(ms)}` : "at/just past kickoff — waiting for it to flip live";
169
+ console.log(c.dim(` ${eta}.`));
170
+ if (ms <= 5 * 60_000) pollMs = 15_000; // tighten near kickoff (and once past it)
171
+ else if (ms <= 15 * 60_000) pollMs = 30_000;
172
+ }
173
+ await new Promise((r) => setTimeout(r, pollMs));
174
+ }
175
+ }
176
+
177
+ /** Coarse human ETA: "1h 4m", "12m 30s", or "45s". */
178
+ function fmtEta(ms: number): string {
179
+ const s = Math.max(0, Math.floor(ms / 1000));
180
+ const h = Math.floor(s / 3600);
181
+ const m = Math.floor((s % 3600) / 60);
182
+ const ss = s % 60;
183
+ return h ? `${h}h ${m}m` : m ? `${m}m ${ss}s` : `${ss}s`;
184
+ }
185
+
186
+ /**
187
+ * `watch --smoke`: prove the window-launch path works without blocking. Opens the
188
+ * stream window with a debug port, confirms Chrome came up + CDP is reachable,
189
+ * then tears it down and exits. Bounded (CDP attach has its own timeout) and
190
+ * leaves no survivors — win.close() reaps the ui-leaf/Chrome tree.
191
+ */
192
+ async function smokeWatch(url: string, label: string, windowSize: { width: number; height: number } | undefined): Promise<void> {
193
+ const port = await freePort();
194
+ const win = await spawnStreamWindow(url, label, { debugPort: port, windowSize });
195
+ if (!win) {
196
+ process.exitCode = 1; // ui-leaf missing — spawnStreamWindow already explained
197
+ return;
198
+ }
199
+ try {
200
+ const session = await attachToPage(port, 15_000); // confirms the window + CDP came up
201
+ session.close();
202
+ console.log(c.green(`✓ watch --smoke: ${label} window opened and CDP attached — tearing it down.`));
203
+ } catch (e) {
204
+ console.error(c.yellow(`watch --smoke: the window/CDP didn't come up in time — ${e instanceof Error ? e.message : String(e)}`));
205
+ process.exitCode = 1;
206
+ } finally {
207
+ win.close();
208
+ }
209
+ }
210
+
211
+ /** Parse a `WxH` size string into a window size, or undefined if absent/invalid. */
212
+ function parseSize(s: string | null): { width: number; height: number } | undefined {
213
+ const m = s?.match(/^(\d+)x(\d+)$/);
214
+ return m ? { width: Number(m[1]), height: Number(m[2]) } : undefined;
215
+ }
216
+
217
+ /** Positional args, dropping flags and the values consumed by --url / --provider / --size. */
218
+ function positionalTerms(args: string[]): string[] {
219
+ const out: string[] = [];
220
+ for (let i = 0; i < args.length; i++) {
221
+ const a = args[i]!;
222
+ if (a === "--url" || a === "--provider" || a === "--size" || a === "--lang") {
223
+ i++; // skip its value
224
+ continue;
225
+ }
226
+ if (a.startsWith("--")) continue;
227
+ out.push(a);
228
+ }
229
+ return out;
230
+ }
package/src/config.ts ADDED
@@ -0,0 +1,144 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { mkdir, chmod } from "fs/promises";
4
+
5
+ const CONFIG_DIR = join(homedir(), ".config", "sportsing");
6
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
7
+ export const CACHE_DIR = join(homedir(), ".cache", "sportsing");
8
+
9
+ // Pre-rebrand location (was `sportsball`). Read as a one-time fallback so existing
10
+ // favorites / API key survive the rename; the next writeConfig() persists forward
11
+ // to CONFIG_FILE. Cache (bus/pidfile/ESPN) is ephemeral, so it isn't migrated.
12
+ const LEGACY_CONFIG_FILE = join(homedir(), ".config", "sportsball", "config.json");
13
+
14
+ interface Config {
15
+ apiKey?: string;
16
+ favorites?: string[];
17
+ /** Preferred streaming provider for `fifa watch` (peacock | fubo). */
18
+ streamProvider?: string;
19
+ /** Calibrated overlay delay (seconds) per provider, to sync stats to the stream. */
20
+ streamDelay?: Record<string, number>;
21
+ /** Overlay panel choices (the gear/settings) — per provider → { panel: on }. */
22
+ overlayPanels?: Record<string, Record<string, boolean>>;
23
+ }
24
+
25
+ /** Default overlay panel visibility — nothing on by default, so a fresh stream
26
+ * shows JUST the floating gear; every panel is opt-in via the settings modal. */
27
+ export const OVERLAY_PANEL_DEFAULTS: Record<string, boolean> = {
28
+ score: false, // score · clock · favorite win%
29
+ stats: false, // possession / shots / on-target
30
+ winprob: false, // 3-way win-probability breakdown
31
+ odds: false, // raw 3-way odds line
32
+ h2h: false, // head-to-head button
33
+ events: false, // live match events (goals/cards/subs)
34
+ scores: false, // other live matches
35
+ ask: false, // "Ask Claude" — routed through the external agent bus
36
+ catchup: false, // "Get caught up" recap button — routed through the external agent bus
37
+ };
38
+
39
+ async function readConfig(): Promise<Config> {
40
+ try {
41
+ return await Bun.file(CONFIG_FILE).json();
42
+ } catch {
43
+ try {
44
+ // One-time fallback to the pre-rebrand config; writeConfig migrates it forward.
45
+ return await Bun.file(LEGACY_CONFIG_FILE).json();
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+ }
51
+
52
+ async function writeConfig(cfg: Config): Promise<void> {
53
+ await mkdir(CONFIG_DIR, { recursive: true });
54
+ await Bun.write(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n");
55
+ // The config holds the user's football-data.org API key — keep it owner-only
56
+ // (0600) so other local users can't read it. Best-effort (no-op on Windows).
57
+ await chmod(CONFIG_FILE, 0o600).catch(() => {});
58
+ }
59
+
60
+ /** Resolve the football-data.org API key from env or config file. */
61
+ export async function getApiKey(): Promise<string | null> {
62
+ const env = process.env.FOOTBALL_DATA_API_KEY?.trim();
63
+ if (env) return env;
64
+ const cfg = await readConfig();
65
+ return cfg.apiKey?.trim() || null;
66
+ }
67
+
68
+ export async function setApiKey(key: string): Promise<void> {
69
+ const cfg = await readConfig();
70
+ cfg.apiKey = key.trim();
71
+ await writeConfig(cfg);
72
+ }
73
+
74
+ /** Preferred streaming provider for `fifa watch`, or null if unset. */
75
+ export async function getStreamProvider(): Promise<string | null> {
76
+ const cfg = await readConfig();
77
+ return cfg.streamProvider?.trim().toLowerCase() || null;
78
+ }
79
+
80
+ export async function setStreamProvider(provider: string): Promise<void> {
81
+ const cfg = await readConfig();
82
+ cfg.streamProvider = provider.trim().toLowerCase();
83
+ await writeConfig(cfg);
84
+ }
85
+
86
+ /** Calibrated overlay delay (seconds) for a provider, or null if not set. */
87
+ export async function getStreamDelay(provider: string): Promise<number | null> {
88
+ const cfg = await readConfig();
89
+ const v = cfg.streamDelay?.[provider.trim().toLowerCase()];
90
+ return typeof v === "number" ? v : null;
91
+ }
92
+
93
+ export async function setStreamDelay(provider: string, seconds: number): Promise<void> {
94
+ const cfg = await readConfig();
95
+ cfg.streamDelay = { ...(cfg.streamDelay ?? {}), [provider.trim().toLowerCase()]: Math.max(0, Math.round(seconds)) };
96
+ await writeConfig(cfg);
97
+ }
98
+
99
+ /** Overlay panel visibility for a provider (defaults merged with saved choices). */
100
+ export async function getOverlayPanels(provider: string): Promise<Record<string, boolean>> {
101
+ const cfg = await readConfig();
102
+ const saved = cfg.overlayPanels?.[provider.trim().toLowerCase()] ?? {};
103
+ return { ...OVERLAY_PANEL_DEFAULTS, ...saved };
104
+ }
105
+
106
+ export async function setOverlayPanel(provider: string, key: string, on: boolean): Promise<void> {
107
+ const cfg = await readConfig();
108
+ const p = provider.trim().toLowerCase();
109
+ const all = cfg.overlayPanels ?? {};
110
+ cfg.overlayPanels = { ...all, [p]: { ...(all[p] ?? {}), [key]: on } };
111
+ await writeConfig(cfg);
112
+ }
113
+
114
+ /** Favorite teams, in the order they were added (as the user typed them). */
115
+ export async function getFavorites(): Promise<string[]> {
116
+ const cfg = await readConfig();
117
+ return cfg.favorites ?? [];
118
+ }
119
+
120
+ /** Add a favorite team. No-op (added=false) if an equal name already exists. */
121
+ export async function addFavorite(team: string): Promise<{ added: boolean; favorites: string[] }> {
122
+ const name = team.trim();
123
+ const cfg = await readConfig();
124
+ const favorites = cfg.favorites ?? [];
125
+ const exists = favorites.some((f) => f.toLowerCase() === name.toLowerCase());
126
+ if (!exists) favorites.push(name);
127
+ cfg.favorites = favorites;
128
+ await writeConfig(cfg);
129
+ return { added: !exists, favorites };
130
+ }
131
+
132
+ /** Remove a favorite team (case-insensitive). removed=false if it wasn't there. */
133
+ export async function removeFavorite(team: string): Promise<{ removed: boolean; favorites: string[] }> {
134
+ const cfg = await readConfig();
135
+ const favorites = cfg.favorites ?? [];
136
+ const i = favorites.findIndex((f) => f.toLowerCase() === team.trim().toLowerCase());
137
+ const removed = i >= 0;
138
+ if (removed) favorites.splice(i, 1);
139
+ cfg.favorites = favorites;
140
+ await writeConfig(cfg);
141
+ return { removed, favorites };
142
+ }
143
+
144
+ export { CONFIG_FILE };