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.
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/package.json +53 -0
- package/src/ansi.ts +36 -0
- package/src/api.ts +157 -0
- package/src/ask-bus.ts +172 -0
- package/src/cdp.ts +85 -0
- package/src/commands/_lib.ts +88 -0
- package/src/commands/agent-setup.ts +57 -0
- package/src/commands/analyze.ts +81 -0
- package/src/commands/ask.ts +138 -0
- package/src/commands/bracket.ts +56 -0
- package/src/commands/fav.ts +47 -0
- package/src/commands/fixtures.ts +52 -0
- package/src/commands/highlights.ts +32 -0
- package/src/commands/live.ts +175 -0
- package/src/commands/me.ts +65 -0
- package/src/commands/next.ts +40 -0
- package/src/commands/predict.ts +121 -0
- package/src/commands/recap.ts +66 -0
- package/src/commands/results.ts +37 -0
- package/src/commands/schedule.ts +34 -0
- package/src/commands/scorers.ts +35 -0
- package/src/commands/setup.ts +40 -0
- package/src/commands/stats.ts +82 -0
- package/src/commands/table.ts +47 -0
- package/src/commands/teams.ts +66 -0
- package/src/commands/today.ts +58 -0
- package/src/commands/watch.ts +230 -0
- package/src/config.ts +144 -0
- package/src/espn.ts +307 -0
- package/src/events.ts +85 -0
- package/src/format.ts +180 -0
- package/src/index.ts +71 -0
- package/src/liveness.ts +82 -0
- package/src/match-detect.ts +136 -0
- package/src/match-util.ts +19 -0
- package/src/notify.ts +94 -0
- package/src/overlay.ts +625 -0
- package/src/recap.ts +111 -0
- package/src/sports/fifa.ts +139 -0
- package/src/stream.ts +160 -0
- package/src/types.ts +91 -0
|
@@ -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 };
|