sportsing 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -12
- package/package.json +6 -2
- package/src/alerts.ts +47 -0
- package/src/api.ts +1 -1
- package/src/click-to-watch.ts +39 -0
- package/src/commands/_lib.ts +28 -12
- package/src/commands/analyze.ts +2 -2
- package/src/commands/ask.ts +1 -1
- package/src/commands/cross.ts +354 -0
- package/src/commands/fav.ts +19 -16
- package/src/commands/league-ai.ts +201 -0
- package/src/commands/league-bracket.ts +150 -0
- package/src/commands/league.ts +839 -0
- package/src/commands/live.ts +57 -42
- package/src/commands/me.ts +1 -1
- package/src/commands/predict.ts +2 -2
- package/src/commands/scorers.ts +1 -1
- package/src/commands/setup.ts +3 -3
- package/src/commands/subscriptions.ts +66 -0
- package/src/commands/table.ts +1 -1
- package/src/commands/upgrade.ts +137 -0
- package/src/commands/watch.ts +19 -11
- package/src/config.ts +166 -28
- package/src/espn.ts +606 -33
- package/src/format.ts +87 -0
- package/src/game-events.ts +239 -0
- package/src/game.ts +51 -0
- package/src/index.ts +41 -16
- package/src/league-ai.ts +359 -0
- package/src/league-alerts.ts +139 -0
- package/src/league-detect.ts +104 -0
- package/src/league-overlay.ts +351 -0
- package/src/league-panels.ts +159 -0
- package/src/overlay.ts +96 -51
- package/src/playoff-bracket.ts +384 -0
- package/src/recap.ts +37 -8
- package/src/season.ts +182 -0
- package/src/sports/fifa.ts +9 -4
- package/src/sports/nba.ts +20 -0
- package/src/sports/nhl.ts +20 -0
- package/src/standings.ts +130 -0
- package/src/stream.ts +49 -8
- package/src/upgrade.ts +182 -0
- package/src/watch-route.ts +95 -0
- package/src/watchability.ts +183 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// Bare `sportsing today | next | me` — your favorite teams' games across every
|
|
2
|
+
// registered sport, each row tagged with its sport — and bare
|
|
3
|
+
// `sportsing live --notify`, one alerter polling every sport with a favorite. Any other bare command is
|
|
4
|
+
// per-sport: it gets a hint naming the sports that have it, never a silent
|
|
5
|
+
// fallback to one sport.
|
|
6
|
+
//
|
|
7
|
+
// SPORTS below is the one sport registry: src/index.ts builds its
|
|
8
|
+
// `sportsing <sport> …` namespaces from it, so a new sport is aggregated here
|
|
9
|
+
// as soon as it's routable.
|
|
10
|
+
|
|
11
|
+
import { c } from "../ansi.ts";
|
|
12
|
+
import { LIVE_REFRESH_MS, pollAlerters, raise, type Alerter } from "../alerts.ts";
|
|
13
|
+
import { getMatches } from "../api.ts";
|
|
14
|
+
import { getFavorites } from "../config.ts";
|
|
15
|
+
import { getTeams } from "../espn.ts";
|
|
16
|
+
import { fmtDate, gameLine, matchLine, relativeTime } from "../format.ts";
|
|
17
|
+
import type { GameState } from "../game.ts";
|
|
18
|
+
import { fifa, fifaHasCommand } from "../sports/fifa.ts";
|
|
19
|
+
import { NBA, nba } from "../sports/nba.ts";
|
|
20
|
+
import { NHL, nhl } from "../sports/nhl.ts";
|
|
21
|
+
import type { Match } from "../types.ts";
|
|
22
|
+
import { fifaAlerter } from "./live.ts";
|
|
23
|
+
import { EXAMPLE_TEAM, addDays, localDateOf, matchHasTeam, withFallback, ymd } from "./_lib.ts";
|
|
24
|
+
import {
|
|
25
|
+
favoriteIds,
|
|
26
|
+
gameHasTeam,
|
|
27
|
+
gamesOnDays,
|
|
28
|
+
leagueAlerter,
|
|
29
|
+
leagueHasCommand,
|
|
30
|
+
parseOffset,
|
|
31
|
+
teamSeason,
|
|
32
|
+
type LeagueConfig,
|
|
33
|
+
} from "./league.ts";
|
|
34
|
+
|
|
35
|
+
/** One game, sport-neutral: enough to sort, pick, and print it. */
|
|
36
|
+
export interface Row {
|
|
37
|
+
id: string;
|
|
38
|
+
/** ISO start time. */
|
|
39
|
+
start: string;
|
|
40
|
+
state: GameState;
|
|
41
|
+
/** The sport's own aligned game line. */
|
|
42
|
+
line: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One favorite team and its games, ascending by start. */
|
|
46
|
+
export interface TeamRows {
|
|
47
|
+
team: string;
|
|
48
|
+
rows: Row[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Sport {
|
|
52
|
+
/** CLI namespace and favorites key, e.g. "nba". */
|
|
53
|
+
key: string;
|
|
54
|
+
label: string;
|
|
55
|
+
icon: string;
|
|
56
|
+
/** `sportsing <key> …` dispatcher. */
|
|
57
|
+
run: (args: string[]) => unknown | Promise<unknown>;
|
|
58
|
+
/** True if `sportsing <key> <cmd>` exists. */
|
|
59
|
+
has: (cmd: string) => boolean;
|
|
60
|
+
/** Favorite teams' games on one local day; null when the sport has no favorites. */
|
|
61
|
+
favoritesOn: (day: Date) => Promise<Row[] | null>;
|
|
62
|
+
/** Each favorite team's schedule; null when the sport has no favorites. */
|
|
63
|
+
favoriteTeams: () => Promise<TeamRows[] | null>;
|
|
64
|
+
/** Favorite-team live alerter; null when the sport has no favorites. */
|
|
65
|
+
alerter: () => Promise<Alerter | null>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── FIFA (football-data `Match`) ─────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/** A football-data match status as a sport-neutral game state. */
|
|
71
|
+
export function matchState(m: Pick<Match, "status">): GameState {
|
|
72
|
+
if (m.status === "IN_PLAY" || m.status === "PAUSED") return "in";
|
|
73
|
+
if (m.status === "FINISHED" || m.status === "AWARDED") return "post";
|
|
74
|
+
return "pre";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const matchRow = (m: Match): Row => ({ id: String(m.id), start: m.utcDate, state: matchState(m), line: matchLine(m) });
|
|
78
|
+
|
|
79
|
+
const allMatches = () =>
|
|
80
|
+
withFallback(
|
|
81
|
+
async () => (await getMatches({})).matches,
|
|
82
|
+
(all) => all,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const FIFA: Sport = {
|
|
86
|
+
key: "fifa",
|
|
87
|
+
label: "FIFA",
|
|
88
|
+
icon: "⚽",
|
|
89
|
+
run: fifa,
|
|
90
|
+
has: fifaHasCommand,
|
|
91
|
+
alerter: fifaAlerter,
|
|
92
|
+
async favoritesOn(day) {
|
|
93
|
+
const favs = await getFavorites("fifa");
|
|
94
|
+
if (favs.length === 0) return null;
|
|
95
|
+
// football-data filters by UTC date; fetch ±1 day and keep the local day.
|
|
96
|
+
const date = ymd(day);
|
|
97
|
+
const onDay = (all: Match[]) => all.filter((m) => localDateOf(m.utcDate) === date);
|
|
98
|
+
const matches = await withFallback(
|
|
99
|
+
async () => onDay((await getMatches({ dateFrom: ymd(addDays(day, -1)), dateTo: ymd(addDays(day, 1)) })).matches),
|
|
100
|
+
onDay,
|
|
101
|
+
);
|
|
102
|
+
const needles = favs.map((f) => f.toLowerCase());
|
|
103
|
+
return matches.filter((m) => needles.some((n) => matchHasTeam(m, n))).map(matchRow);
|
|
104
|
+
},
|
|
105
|
+
async favoriteTeams() {
|
|
106
|
+
const favs = await getFavorites("fifa");
|
|
107
|
+
if (favs.length === 0) return null;
|
|
108
|
+
const matches = (await allMatches()).map((m) => ({ m, row: matchRow(m) }));
|
|
109
|
+
return favs.map((team) => ({
|
|
110
|
+
team,
|
|
111
|
+
rows: matches
|
|
112
|
+
.filter(({ m }) => matchHasTeam(m, team.toLowerCase()))
|
|
113
|
+
.map(({ row }) => row)
|
|
114
|
+
.sort(byStart),
|
|
115
|
+
}));
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ── ESPN leagues (sport-neutral `Game`) ──────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function league(cfg: LeagueConfig, run: Sport["run"]): Sport {
|
|
122
|
+
const favoriteTeamsOf = async () => {
|
|
123
|
+
const favs = await getFavorites(cfg.sport);
|
|
124
|
+
if (favs.length === 0) return null;
|
|
125
|
+
const teams = await getTeams(cfg.league);
|
|
126
|
+
return { teams, ids: favoriteIds(cfg, teams, favs) };
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
key: cfg.sport,
|
|
130
|
+
label: cfg.label,
|
|
131
|
+
icon: cfg.icon,
|
|
132
|
+
run,
|
|
133
|
+
has: leagueHasCommand,
|
|
134
|
+
alerter: () => leagueAlerter(cfg),
|
|
135
|
+
async favoritesOn(day) {
|
|
136
|
+
const favs = await favoriteTeamsOf();
|
|
137
|
+
if (!favs) return null;
|
|
138
|
+
const games = await gamesOnDays(cfg, day, 1);
|
|
139
|
+
return games
|
|
140
|
+
.filter((g) => gameHasTeam(g, favs.ids))
|
|
141
|
+
.map((g) => ({ id: g.id, start: g.date, state: g.state, line: gameLine(g, cfg.periods) }));
|
|
142
|
+
},
|
|
143
|
+
async favoriteTeams() {
|
|
144
|
+
const favs = await favoriteTeamsOf();
|
|
145
|
+
if (!favs) return null;
|
|
146
|
+
return Promise.all(
|
|
147
|
+
[...favs.ids].map(async (id) => ({
|
|
148
|
+
team: favs.teams.find((t) => t.id === id)!.name,
|
|
149
|
+
rows: (await teamSeason(cfg, id)).map((g) => ({
|
|
150
|
+
id: g.id,
|
|
151
|
+
start: g.date,
|
|
152
|
+
state: g.state,
|
|
153
|
+
line: gameLine(g, cfg.periods),
|
|
154
|
+
})),
|
|
155
|
+
})),
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Every registered sport, in display order. */
|
|
162
|
+
export const SPORTS: Sport[] = [FIFA, league(NBA, nba), league(NHL, nhl)];
|
|
163
|
+
|
|
164
|
+
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
const byStart = (a: Row, b: Row) => Date.parse(a.start) - Date.parse(b.start);
|
|
167
|
+
|
|
168
|
+
/** A sport tag + the game line, e.g. "🏀 NBA UTAH @ DEN …". */
|
|
169
|
+
export function tagged(sport: Pick<Sport, "icon" | "label">, line: string): string {
|
|
170
|
+
return `${sport.icon} ${c.bold(sport.label.padEnd(4))} ${line}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A team's most recent finished game and its next not-yet-started one. */
|
|
174
|
+
export function lastAndNext(rows: Row[], now: number): { last: Row | null; next: Row | null } {
|
|
175
|
+
const sorted = [...rows].sort(byStart);
|
|
176
|
+
return {
|
|
177
|
+
last: sorted.filter((r) => r.state === "post").at(-1) ?? null,
|
|
178
|
+
next: sorted.find((r) => r.state === "pre" && Date.parse(r.start) >= now) ?? null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Each team's next game, one copy per game (two favorites can meet), soonest first. */
|
|
183
|
+
export function nextPerTeam(teams: TeamRows[], now: number): Row[] {
|
|
184
|
+
const byId = new Map<string, Row>();
|
|
185
|
+
for (const t of teams) {
|
|
186
|
+
const n = lastAndNext(t.rows, now).next;
|
|
187
|
+
if (n) byId.set(n.id, n);
|
|
188
|
+
}
|
|
189
|
+
return [...byId.values()].sort(byStart);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* What to say for a bare command that can't run cross-sport: the per-sport
|
|
194
|
+
* forms of it (args kept), or "unknown" if no sport has it. `optionsGiven`
|
|
195
|
+
* marks a cross-sport command whose options only make sense per sport.
|
|
196
|
+
*/
|
|
197
|
+
export function bareHint(cmd: string, args: string[], sports: Pick<Sport, "key" | "has">[], optionsGiven = false): string[] {
|
|
198
|
+
const forms = sports.filter((s) => s.has(cmd)).map((s) => ["sportsing", s.key, cmd, ...args].join(" "));
|
|
199
|
+
if (forms.length === 0) return [`Unknown command: ${cmd}`, "Run `sportsing help` for usage."];
|
|
200
|
+
const why = optionsGiven ? `Bare \`${cmd}\` takes no options — add a sport:` : `\`${cmd}\` needs a sport:`;
|
|
201
|
+
return [why, " " + forms.join(" · ")];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** "Add a favorite" hint, one example per sport. */
|
|
205
|
+
export function noFavoritesHint(sports: Pick<Sport, "key">[]): string[] {
|
|
206
|
+
const examples = sports.map((s) => `sportsing ${s.key} fav add ${EXAMPLE_TEAM[s.key] ?? "<team>"}`);
|
|
207
|
+
return [
|
|
208
|
+
c.dim("No favorite teams yet — add one with ") + c.bold("sportsing <sport> fav add <team>"),
|
|
209
|
+
c.dim(" e.g. " + examples.join(" · ")),
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Commands ─────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Run `load` for every sport. A sport that fails is reported and skipped (and
|
|
217
|
+
* the exit code set) so the others still print; sports with no favorites
|
|
218
|
+
* (null) are dropped. Null when there's nothing to show: no sport has
|
|
219
|
+
* favorites (the add-a-favorite hint is printed), or every sport with
|
|
220
|
+
* favorites failed.
|
|
221
|
+
*/
|
|
222
|
+
async function perSport<T>(sports: Sport[], load: (s: Sport) => Promise<T | null>): Promise<{ sport: Sport; data: T }[] | null> {
|
|
223
|
+
const settled = await Promise.allSettled(sports.map(load));
|
|
224
|
+
const out: { sport: Sport; data: T }[] = [];
|
|
225
|
+
let failed = false;
|
|
226
|
+
settled.forEach((r, i) => {
|
|
227
|
+
const sport = sports[i]!;
|
|
228
|
+
if (r.status === "rejected") {
|
|
229
|
+
failed = true;
|
|
230
|
+
console.error(c.yellow(`Couldn't load ${sport.label}: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`));
|
|
231
|
+
process.exitCode = 1;
|
|
232
|
+
} else if (r.value !== null) {
|
|
233
|
+
out.push({ sport, data: r.value });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
if (out.length > 0) return out;
|
|
237
|
+
// Nothing to show: every sport failed (errors already printed) or none has favorites.
|
|
238
|
+
if (!failed) for (const l of noFavoritesHint(sports)) console.log(l);
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** `today [--tomorrow|--yesterday|--offset N]` — your teams' games on one local day. */
|
|
243
|
+
export async function today(sports: Sport[], args: string[]): Promise<void> {
|
|
244
|
+
const offset = parseOffset(args);
|
|
245
|
+
const day = addDays(new Date(), offset);
|
|
246
|
+
const loaded = await perSport(sports, (s) => s.favoritesOn(day));
|
|
247
|
+
if (!loaded) return;
|
|
248
|
+
const rows = loaded
|
|
249
|
+
.flatMap(({ sport, data }) => data.map((row) => ({ sport, row })))
|
|
250
|
+
.sort((a, b) => byStart(a.row, b.row));
|
|
251
|
+
|
|
252
|
+
const label = offset === 0 ? "Today" : offset === 1 ? "Tomorrow" : offset === -1 ? "Yesterday" : ymd(day);
|
|
253
|
+
console.log(c.bold(c.cyan(`★ Your teams — ${label} (${ymd(day)})`)));
|
|
254
|
+
if (rows.length === 0) {
|
|
255
|
+
console.log(c.dim("\nNo games for your teams."));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
console.log();
|
|
259
|
+
for (const { sport, row } of rows) console.log(" " + tagged(sport, row.line));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** `next` — each favorite team's next game, soonest first, with countdowns. */
|
|
263
|
+
export async function next(sports: Sport[]): Promise<void> {
|
|
264
|
+
const loaded = await perSport(sports, (s) => s.favoriteTeams());
|
|
265
|
+
if (!loaded) return;
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
const rows = loaded
|
|
268
|
+
.flatMap(({ sport, data }) => nextPerTeam(data, now).map((row) => ({ sport, row })))
|
|
269
|
+
.sort((a, b) => byStart(a.row, b.row));
|
|
270
|
+
|
|
271
|
+
console.log(c.bold(c.cyan("★ Your teams — Next up")));
|
|
272
|
+
if (rows.length === 0) {
|
|
273
|
+
console.log(c.dim("\nNo upcoming games for your teams."));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
for (const { sport, row } of rows) {
|
|
277
|
+
console.log("\n " + tagged(sport, row.line));
|
|
278
|
+
console.log(` ${c.bold(fmtDate(row.start))} ${c.green("— starts " + relativeTime(row.start))}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** `me` — every favorite team, every sport: last result and next game. */
|
|
283
|
+
export async function me(sports: Sport[]): Promise<void> {
|
|
284
|
+
const loaded = await perSport(sports, (s) => s.favoriteTeams());
|
|
285
|
+
if (!loaded) return;
|
|
286
|
+
const now = Date.now();
|
|
287
|
+
|
|
288
|
+
console.log(c.bold(c.cyan("★ My teams")));
|
|
289
|
+
for (const { sport, data } of loaded) {
|
|
290
|
+
for (const t of data) {
|
|
291
|
+
const { last, next } = lastAndNext(t.rows, now);
|
|
292
|
+
console.log("\n" + `${sport.icon} ${c.bold(t.team)} ${c.dim(sport.label)}`);
|
|
293
|
+
if (last) console.log(" " + c.dim("last ") + last.line);
|
|
294
|
+
if (next) console.log(" " + c.dim("next ") + next.line + c.green(" " + relativeTime(next.start)));
|
|
295
|
+
if (!last && !next) console.log(c.dim(" no games found"));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Startup line for bare `live`, e.g. "NBA: Utah Jazz · NHL: Utah Mammoth". */
|
|
301
|
+
export function alertersSummary(alerters: Pick<Alerter, "label" | "teams">[]): string {
|
|
302
|
+
return alerters.map((a) => `${a.label}: ${a.teams.join(", ")}`).join(" · ");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* `live --notify [--quiet]` — favorite-team alerts for every sport with a
|
|
307
|
+
* favorite, from one process. A sport that can't start (e.g. FIFA without an
|
|
308
|
+
* API key) or whose poll fails is reported and the rest carry on. There's no
|
|
309
|
+
* cross-sport board: without --quiet each alert is also logged to stdout.
|
|
310
|
+
*/
|
|
311
|
+
export async function live(sports: Sport[], args: string[]): Promise<void> {
|
|
312
|
+
const unknown = args.filter((a) => a !== "--notify" && a !== "--quiet");
|
|
313
|
+
if (!args.includes("--notify") || unknown.length > 0) {
|
|
314
|
+
console.error(c.red("Bare `live` is the cross-sport alerter: sportsing live --notify [--quiet]"));
|
|
315
|
+
console.error(c.dim("For a live board add a sport: " + sports.filter((s) => s.has("live")).map((s) => `sportsing ${s.key} live`).join(" · ")));
|
|
316
|
+
process.exitCode = 1;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const quiet = args.includes("--quiet");
|
|
320
|
+
const loaded = await perSport(sports, (s) => s.alerter());
|
|
321
|
+
if (!loaded) return;
|
|
322
|
+
const alerters = loaded.map(({ data }) => data);
|
|
323
|
+
console.error(c.dim(`Favorite-team alerts running (${alertersSummary(alerters)}) — Ctrl-C to stop.`));
|
|
324
|
+
|
|
325
|
+
const tick = async () => {
|
|
326
|
+
const alerts = await pollAlerters(alerters, (a, e) =>
|
|
327
|
+
console.error(c.yellow(`${a.label} alerts: ${e instanceof Error ? e.message : String(e)}`)),
|
|
328
|
+
);
|
|
329
|
+
for (const a of alerts) {
|
|
330
|
+
raise(a);
|
|
331
|
+
if (!quiet) console.log(`${c.dim(new Date().toLocaleTimeString())} ${c.bold(a.title)} ${a.body}`);
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
// The first poll only sets each sport's baseline.
|
|
335
|
+
await tick();
|
|
336
|
+
const interval = setInterval(() => void tick(), LIVE_REFRESH_MS);
|
|
337
|
+
process.on("SIGINT", () => {
|
|
338
|
+
clearInterval(interval);
|
|
339
|
+
process.exit(0);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Dispatch a bare `sportsing <cmd> …` (no sport prefix). */
|
|
344
|
+
export async function bare(cmd: string, args: string[]): Promise<void> {
|
|
345
|
+
if (cmd === "today" || cmd === "t") return today(SPORTS, args);
|
|
346
|
+
if (cmd === "live") return live(SPORTS, args);
|
|
347
|
+
const run = cmd === "next" || cmd === "n" ? next : cmd === "me" ? me : null;
|
|
348
|
+
if (run && args.length === 0) return run(SPORTS);
|
|
349
|
+
// Options on `next`/`me` (e.g. `--team`) are per-sport: say so rather than ignore them.
|
|
350
|
+
const [first, ...rest] = bareHint(cmd, args, SPORTS, run !== null);
|
|
351
|
+
console.error(c.red(first!));
|
|
352
|
+
for (const l of rest) console.error(c.dim(l));
|
|
353
|
+
process.exitCode = 1;
|
|
354
|
+
}
|
package/src/commands/fav.ts
CHANGED
|
@@ -1,47 +1,50 @@
|
|
|
1
1
|
import { c } from "../ansi.ts";
|
|
2
2
|
import { getFavorites, addFavorite, removeFavorite } from "../config.ts";
|
|
3
|
+
import { EXAMPLE_TEAM } from "./_lib.ts";
|
|
3
4
|
|
|
4
|
-
// `sportsing
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
5
|
+
// `sportsing <sport> fav [add|rm|list] [team]` — manage a sport's favorite teams.
|
|
6
|
+
// Favorites are scoped per sport (stored `<sport>:<team>`), so `nba:UTAH` and
|
|
7
|
+
// `nhl:UTAH` coexist. Team names are free-text (e.g. "USA", "Brazil") matched
|
|
8
|
+
// case-insensitively elsewhere; multi-word names work unquoted ("fav add South Korea").
|
|
9
|
+
export async function fav(args: string[], sport: string): Promise<void> {
|
|
8
10
|
const [sub, ...rest] = args;
|
|
9
11
|
const team = rest.join(" ").trim();
|
|
10
12
|
|
|
11
13
|
if (!sub || sub === "list") {
|
|
12
|
-
printList(await getFavorites());
|
|
14
|
+
printList(await getFavorites(sport), sport);
|
|
13
15
|
return;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
if (sub === "add") {
|
|
17
|
-
if (!team) return usage("fav add <team>");
|
|
18
|
-
const { added, favorites } = await addFavorite(team);
|
|
19
|
+
if (!team) return usage(sport, "fav add <team>");
|
|
20
|
+
const { added, favorites } = await addFavorite(sport, team);
|
|
19
21
|
console.log(added ? c.green(`★ Added ${team}.`) : c.yellow(`${team} is already a favorite.`));
|
|
20
|
-
printList(favorites);
|
|
22
|
+
printList(favorites, sport);
|
|
21
23
|
return;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
if (sub === "rm" || sub === "remove") {
|
|
25
|
-
if (!team) return usage("fav rm <team>");
|
|
26
|
-
const { removed, favorites } = await removeFavorite(team);
|
|
27
|
+
if (!team) return usage(sport, "fav rm <team>");
|
|
28
|
+
const { removed, favorites } = await removeFavorite(sport, team);
|
|
27
29
|
console.log(removed ? c.green(`Removed ${team}.`) : c.yellow(`"${team}" wasn't in your favorites.`));
|
|
28
|
-
printList(favorites);
|
|
30
|
+
printList(favorites, sport);
|
|
29
31
|
return;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
usage("fav <add|rm|list> [team]");
|
|
34
|
+
usage(sport, "fav <add|rm|list> [team]");
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
function printList(favorites: string[]): void {
|
|
37
|
+
function printList(favorites: string[], sport: string): void {
|
|
36
38
|
if (favorites.length === 0) {
|
|
37
|
-
|
|
39
|
+
const example = EXAMPLE_TEAM[sport] ?? "<team>";
|
|
40
|
+
console.log(c.dim("No favorite teams yet. Add one: ") + c.bold(`sportsing ${sport} fav add ${example}`));
|
|
38
41
|
return;
|
|
39
42
|
}
|
|
40
43
|
console.log(c.bold(c.cyan("★ Favorite teams")));
|
|
41
44
|
for (const f of favorites) console.log(" " + f);
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
function usage(form: string): void {
|
|
45
|
-
console.error(c.red(`Usage: sportsing
|
|
47
|
+
function usage(sport: string, form: string): void {
|
|
48
|
+
console.error(c.red(`Usage: sportsing ${sport} ${form}`));
|
|
46
49
|
process.exitCode = 1;
|
|
47
50
|
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// `sportsing <nba|nhl> analyze|predict|recap <team> [team] [--prompt]` — the
|
|
2
|
+
// AI commands for the ESPN-backed leagues. Same model as the World Cup versions
|
|
3
|
+
// (src/commands/analyze.ts, predict.ts, recap.ts): sportsing fetches + fences the
|
|
4
|
+
// data and posts the prompt to the ask bus for an EXTERNAL Claude agent (a
|
|
5
|
+
// `/loop sportsing fifa serve` session) — it never runs a model itself. --prompt
|
|
6
|
+
// prints the assembled prompt instead of posting it.
|
|
7
|
+
//
|
|
8
|
+
// The prompts and the stats they carry are per-sport (src/league-ai.ts). Teams
|
|
9
|
+
// and games come in through `LeagueGames` (built by league.ts), so every lookup
|
|
10
|
+
// is by ESPN team id within one league.
|
|
11
|
+
|
|
12
|
+
import { c } from "../ansi.ts";
|
|
13
|
+
import { postQuestion, waitForAnswer, isServing, type AskSource } from "../ask-bus.ts";
|
|
14
|
+
import type { EspnGameSummary, EspnTeam } from "../espn.ts";
|
|
15
|
+
import type { Game } from "../game.ts";
|
|
16
|
+
import { buildAnalyzePrompt, buildPredictPrompt, catchupInput, sportAiFor, teamForm, type SportAi } from "../league-ai.ts";
|
|
17
|
+
import { buildRecapPrompt, hasNotableEvents, requestRecap } from "../recap.ts";
|
|
18
|
+
import type { LeagueConfig } from "./league.ts";
|
|
19
|
+
|
|
20
|
+
/** One league's data, as the AI commands need it. */
|
|
21
|
+
export interface LeagueGames {
|
|
22
|
+
/** Resolve user input to a team in this league, or null. */
|
|
23
|
+
team(input: string): Promise<EspnTeam | null>;
|
|
24
|
+
/** Every game in one team's season (preseason through postseason), ascending. */
|
|
25
|
+
season(teamId: string): Promise<Game[]>;
|
|
26
|
+
/** Box score + play-by-play for one game, or null if ESPN has none. */
|
|
27
|
+
summary(gameId: string): Promise<EspnGameSummary | null>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ANSWER_TIMEOUT_MS = 180_000;
|
|
31
|
+
|
|
32
|
+
// ── Pure selection (unit-tested) ─────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** The game to analyze or recap: a live one if any, else the most recent finished one. */
|
|
35
|
+
export function pickPlayed(games: Game[]): Game | null {
|
|
36
|
+
const live = games.find((g) => g.state === "in");
|
|
37
|
+
if (live) return live;
|
|
38
|
+
return games.filter((g) => g.state === "post").sort((a, b) => Date.parse(b.date) - Date.parse(a.date))[0] ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The game to predict: the soonest one that hasn't started (never a finished one). */
|
|
42
|
+
export function pickUpcoming(games: Game[], now: number): Game | null {
|
|
43
|
+
return (
|
|
44
|
+
games
|
|
45
|
+
.filter((g) => g.state === "pre" && Date.parse(g.date) >= now)
|
|
46
|
+
.sort((a, b) => Date.parse(a.date) - Date.parse(b.date))[0] ?? null
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Only the games involving `teamId` (both sides checked; ids are league-scoped). */
|
|
51
|
+
export function withTeam(games: Game[], teamId: string): Game[] {
|
|
52
|
+
return games.filter((g) => g.home.id === teamId || g.away.id === teamId);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Shared plumbing ──────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Positional terms → one or two teams. The whole phrase is tried first (so
|
|
59
|
+
* "Utah Jazz" is one team), then each term on its own ("jazz nuggets").
|
|
60
|
+
* Throws a user-facing error for anything unresolvable.
|
|
61
|
+
*/
|
|
62
|
+
export async function resolveTerms(cfg: LeagueConfig, games: LeagueGames, terms: string[]): Promise<EspnTeam[]> {
|
|
63
|
+
const whole = await games.team(terms.join(" "));
|
|
64
|
+
if (whole) return [whole];
|
|
65
|
+
if (terms.length > 2) throw new Error(`Couldn't resolve "${terms.join(" ")}" to one or two ${cfg.label} teams.`);
|
|
66
|
+
const out: EspnTeam[] = [];
|
|
67
|
+
for (const t of terms) {
|
|
68
|
+
const team = await games.team(t);
|
|
69
|
+
if (!team) throw new Error(`Unknown ${cfg.label} team "${t}". Use an ESPN abbreviation or a team name.`);
|
|
70
|
+
out.push(team);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Setup {
|
|
76
|
+
ai: SportAi;
|
|
77
|
+
promptOnly: boolean;
|
|
78
|
+
teams: EspnTeam[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Parse args and resolve teams; null (with a usage error printed) if there's nothing to do. */
|
|
82
|
+
async function setup(cfg: LeagueConfig, games: LeagueGames, cmd: string, args: string[]): Promise<Setup | null> {
|
|
83
|
+
const ai = sportAiFor(cfg.league);
|
|
84
|
+
if (!ai) throw new Error(`${cmd} isn't available for ${cfg.label}.`);
|
|
85
|
+
const terms = args.filter((a) => !a.startsWith("--"));
|
|
86
|
+
if (terms.length === 0) {
|
|
87
|
+
console.error(c.red(`Usage: sportsing ${cfg.sport} ${cmd} <team> [team] [--prompt]`));
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return { ai, promptOnly: args.includes("--prompt"), teams: await resolveTerms(cfg, games, terms) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The first team's season, narrowed to games against the second team if given. */
|
|
95
|
+
async function candidateGames(games: LeagueGames, teams: EspnTeam[]): Promise<Game[]> {
|
|
96
|
+
const season = await games.season(teams[0]!.id);
|
|
97
|
+
return teams[1] ? withTeam(season, teams[1].id) : season;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const who = (teams: EspnTeam[]) => teams.map((t) => t.name).join(" vs ");
|
|
101
|
+
|
|
102
|
+
/** Post a prompt to the ask bus and print the answer (or the no-agent hint). */
|
|
103
|
+
async function ask(source: AskSource, prompt: string, context: string, hint: string, heading: string): Promise<void> {
|
|
104
|
+
process.stderr.write(c.dim("Posted to the ask bus — waiting for your Claude agent to answer…\n"));
|
|
105
|
+
process.stderr.write(c.dim("(keep one serving: /loop sportsing fifa serve — it answers every sport)\n"));
|
|
106
|
+
const id = await postQuestion({ source, question: prompt, context, hint, maxChars: null });
|
|
107
|
+
const answer = await waitForAnswer(id, ANSWER_TIMEOUT_MS);
|
|
108
|
+
if (answer === null) {
|
|
109
|
+
console.error(c.yellow("No Claude agent answered within 3 minutes."));
|
|
110
|
+
console.error(c.dim("Start a serving agent in another Claude session, then retry: /loop sportsing fifa serve (it answers every sport)"));
|
|
111
|
+
console.error(c.dim("Or run with --prompt to get the prompt and use it elsewhere."));
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
console.log(heading + "\n");
|
|
116
|
+
console.log(answer);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Commands ─────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/** `analyze <team> [team] [--prompt]` — a read of the live or latest finished game. */
|
|
122
|
+
export async function leagueAnalyze(cfg: LeagueConfig, games: LeagueGames, args: string[]): Promise<void> {
|
|
123
|
+
const s = await setup(cfg, games, "analyze", args);
|
|
124
|
+
if (!s) return;
|
|
125
|
+
const g = pickPlayed(await candidateGames(games, s.teams));
|
|
126
|
+
if (!g) {
|
|
127
|
+
console.log(c.dim(`No played ${cfg.label} game found for ${who(s.teams)}. Analysis needs a game that has started.`));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const summary = await games.summary(g.id);
|
|
131
|
+
if (!summary || summary.home.stats.length === 0 || summary.away.stats.length === 0) {
|
|
132
|
+
console.log(c.dim(`No team stats for ${g.name} yet — nothing to analyze.`));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const prompt = buildAnalyzePrompt(s.ai, g, summary);
|
|
136
|
+
if (s.promptOnly) return void console.log(prompt);
|
|
137
|
+
await ask(
|
|
138
|
+
"analyze",
|
|
139
|
+
prompt,
|
|
140
|
+
g.name,
|
|
141
|
+
"Follow the format requested in the prompt (a 4–6 sentence read, plain prose).",
|
|
142
|
+
c.bold(c.cyan(`${cfg.icon} ${g.name} — analysis`)) + " " + c.dim(summary.detail),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `predict <team> [team] [--prompt]` — the next game's likely outcome from both teams' form. */
|
|
147
|
+
export async function leaguePredict(cfg: LeagueConfig, games: LeagueGames, args: string[]): Promise<void> {
|
|
148
|
+
const s = await setup(cfg, games, "predict", args);
|
|
149
|
+
if (!s) return;
|
|
150
|
+
const first = await games.season(s.teams[0]!.id);
|
|
151
|
+
const pool = s.teams[1] ? withTeam(first, s.teams[1].id) : first;
|
|
152
|
+
const g = pickUpcoming(pool, Date.now());
|
|
153
|
+
if (!g) {
|
|
154
|
+
console.log(c.dim(`No upcoming ${cfg.label} game found for ${who(s.teams)}.`));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// Each side's form comes from its own season (the first team's is already in hand).
|
|
158
|
+
const seasonOf = (id: string) => (id === s.teams[0]!.id ? Promise.resolve(first) : games.season(id));
|
|
159
|
+
const [homeSeason, awaySeason] = await Promise.all([seasonOf(g.home.id), seasonOf(g.away.id)]);
|
|
160
|
+
const prompt = buildPredictPrompt(s.ai, g, teamForm(homeSeason, g.home.id), teamForm(awaySeason, g.away.id));
|
|
161
|
+
if (s.promptOnly) return void console.log(prompt);
|
|
162
|
+
await ask(
|
|
163
|
+
"predict",
|
|
164
|
+
prompt,
|
|
165
|
+
g.name,
|
|
166
|
+
"Follow the format requested in the prompt (scoreline, win probabilities, 2–3 sentences).",
|
|
167
|
+
c.bold(c.cyan(`${cfg.icon} ${g.name} — prediction`)),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** `recap <team> [team] [--prompt]` — "here's what you missed" for the live or latest game. */
|
|
172
|
+
export async function leagueRecap(cfg: LeagueConfig, games: LeagueGames, args: string[]): Promise<void> {
|
|
173
|
+
const s = await setup(cfg, games, "recap", args);
|
|
174
|
+
if (!s) return;
|
|
175
|
+
const g = pickPlayed(await candidateGames(games, s.teams));
|
|
176
|
+
if (!g) {
|
|
177
|
+
console.log(c.dim(`No ${cfg.label} game in progress or played for ${who(s.teams)} — nothing to recap.`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const summary = await games.summary(g.id);
|
|
181
|
+
if (!summary) {
|
|
182
|
+
console.log(c.dim(`No play-by-play for ${g.name} yet — nothing to recap.`));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const input = catchupInput(s.ai, g, summary);
|
|
186
|
+
if (s.promptOnly) return void console.log(buildRecapPrompt(input));
|
|
187
|
+
|
|
188
|
+
if (hasNotableEvents(input.events) && (await isServing())) {
|
|
189
|
+
process.stderr.write(c.dim("Posted to the ask bus — waiting for your Claude agent to answer…\n"));
|
|
190
|
+
}
|
|
191
|
+
const res = await requestRecap(input);
|
|
192
|
+
if (res.ok) {
|
|
193
|
+
console.log(c.bold(c.cyan(`${cfg.icon} ${input.fixture} — catch up`)) + " " + c.dim(`${input.scoreline} · ${input.detail}`) + "\n");
|
|
194
|
+
console.log(res.recap);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (res.reason === "empty") return void console.log(c.dim(res.message));
|
|
198
|
+
console.error(c.yellow(res.message));
|
|
199
|
+
console.error(c.dim("Or run with --prompt to get the prompt and recap elsewhere."));
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
}
|