sportsing 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +160 -12
  2. package/package.json +6 -2
  3. package/src/alerts.ts +47 -0
  4. package/src/api.ts +1 -1
  5. package/src/click-to-watch.ts +39 -0
  6. package/src/commands/_lib.ts +28 -12
  7. package/src/commands/analyze.ts +2 -2
  8. package/src/commands/ask.ts +1 -1
  9. package/src/commands/cross.ts +354 -0
  10. package/src/commands/fav.ts +19 -16
  11. package/src/commands/league-ai.ts +201 -0
  12. package/src/commands/league-bracket.ts +150 -0
  13. package/src/commands/league.ts +839 -0
  14. package/src/commands/live.ts +57 -42
  15. package/src/commands/me.ts +1 -1
  16. package/src/commands/predict.ts +2 -2
  17. package/src/commands/scorers.ts +1 -1
  18. package/src/commands/setup.ts +3 -3
  19. package/src/commands/subscriptions.ts +66 -0
  20. package/src/commands/table.ts +1 -1
  21. package/src/commands/watch.ts +19 -11
  22. package/src/config.ts +166 -28
  23. package/src/espn.ts +606 -33
  24. package/src/format.ts +87 -0
  25. package/src/game-events.ts +239 -0
  26. package/src/game.ts +51 -0
  27. package/src/index.ts +35 -16
  28. package/src/league-ai.ts +359 -0
  29. package/src/league-alerts.ts +139 -0
  30. package/src/league-detect.ts +104 -0
  31. package/src/league-overlay.ts +351 -0
  32. package/src/league-panels.ts +159 -0
  33. package/src/overlay.ts +96 -51
  34. package/src/playoff-bracket.ts +384 -0
  35. package/src/recap.ts +37 -8
  36. package/src/season.ts +182 -0
  37. package/src/sports/fifa.ts +9 -4
  38. package/src/sports/nba.ts +20 -0
  39. package/src/sports/nhl.ts +20 -0
  40. package/src/standings.ts +130 -0
  41. package/src/stream.ts +49 -8
  42. package/src/watch-route.ts +95 -0
  43. package/src/watchability.ts +183 -0
package/src/format.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { c, pad, visibleLen } from "./ansi.ts";
2
2
  import type { Match, StandingRow, Stage } from "./types.ts";
3
+ import type { Game, GameCompetitor } from "./game.ts";
3
4
 
4
5
  export function teamLabel(t: { name: string | null; tla?: string | null }): string {
5
6
  return t.tla || t.name || "TBD";
@@ -177,4 +178,90 @@ export function relativeTime(iso: string): string {
177
178
  return diff >= 0 ? `in ${s}` : `${s} ago`;
178
179
  }
179
180
 
181
+ // --- Sport-neutral Game lines (NBA/NHL) ---
182
+
183
+ /** Season-phase tag: "PRE" for preseason, "POST" for postseason (incl. the
184
+ * NBA play-in), "" for the regular season. Plain text, for tests/alignment. */
185
+ export function phaseTag(g: Game): string {
186
+ return g.seasonType === "preseason" ? "PRE" : g.seasonType === "postseason" ? "POST" : "";
187
+ }
188
+
189
+ /** How a league names its periods. Leagues that set this get live/final
190
+ * status rendered from `period` + `clock` in their own terms (hockey:
191
+ * 1st/2nd/3rd/OT/SO) instead of the source's status text. */
192
+ export interface PeriodNaming {
193
+ /** Periods in regulation (NHL: 3). Later periods are overtime. */
194
+ regulation: number;
195
+ /** True if a regular-season/preseason tie after one OT goes to a shootout
196
+ * (NHL). Postseason overtime never ends in one. */
197
+ shootout: boolean;
198
+ }
199
+
200
+ const ordinal = (n: number): string => {
201
+ const tens = n % 100;
202
+ const suffix = tens >= 11 && tens <= 13 ? "th" : ["th", "st", "nd", "rd"][n % 10] ?? "th";
203
+ return n + suffix;
204
+ };
205
+
206
+ /** Name of period `g.period` under `naming`: "1st".."3rd", then "OT" (playoff
207
+ * multi-OT: "2OT", "3OT", …), or "SO" for a shootout. "" before the game. */
208
+ export function periodLabel(g: Pick<Game, "period" | "seasonType">, naming: PeriodNaming): string {
209
+ const p = g.period;
210
+ if (p <= 0) return "";
211
+ if (p <= naming.regulation) return ordinal(p);
212
+ const ot = p - naming.regulation;
213
+ if (naming.shootout && g.seasonType !== "postseason" && ot > 1) return "SO";
214
+ return ot === 1 ? "OT" : `${ot}OT`;
215
+ }
216
+
217
+ /** Plain status text from period + clock: "12:34 - 2nd", "End of 2nd", "SO",
218
+ * "Final", "Final/OT", "Final/SO". Only for live and finished games. A live
219
+ * game with no clock at all falls back to the source's text — only an
220
+ * explicit "0:00" means the period has ended. */
221
+ export function periodStatus(g: Game, naming: PeriodNaming): string {
222
+ const label = periodLabel(g, naming);
223
+ if (g.state === "post") return g.period > naming.regulation ? `Final/${label}` : "Final";
224
+ if (!label) return g.detail;
225
+ if (label === "SO") return "SO";
226
+ if (!g.clock) return g.detail || label;
227
+ return g.clock !== "0:00" ? `${g.clock} - ${label}` : `End of ${label}`;
228
+ }
229
+
230
+ /** Status column: local start time before the game, a LIVE badge + the clock
231
+ * text during it, the final text after it. Clock/final text comes from the
232
+ * league's period naming when given, else the source's own status text. */
233
+ function gameStatus(g: Game, naming?: PeriodNaming): string {
234
+ if (g.state === "pre") return c.cyan(fmtTimeOnly(g.date));
235
+ const text = naming ? periodStatus(g, naming) : g.detail;
236
+ if (g.state === "in") return c.bgGreen(c.bold(" LIVE ")) + (text ? c.green(" " + text) : "");
237
+ return c.dim(text || "Final");
238
+ }
239
+
240
+ /** Score as a number for comparison; NaN when not yet scored. */
241
+ const scoreNum = (s: string) => (s === "" ? NaN : Number(s));
242
+
243
+ function side(t: GameCompetitor, other: GameCompetitor, g: Game): string {
244
+ const label = t.abbreviation || t.name;
245
+ if (g.state === "pre") return pad(label, 5) + " ";
246
+ const won = g.state === "post" && scoreNum(t.score) > scoreNum(other.score);
247
+ const text = pad(label, 5) + pad(t.score, 4, "right");
248
+ return won ? c.bold(c.white(text)) : text;
249
+ }
250
+
251
+ /** One game as an aligned line, US style (away @ home):
252
+ * "UTAH 109 @ DEN 97 Final PRE". `naming` renders the status in the
253
+ * league's period terms (see `PeriodNaming`). */
254
+ export function gameLine(g: Game, naming?: PeriodNaming): string {
255
+ const tag = phaseTag(g);
256
+ const tagStr = tag === "PRE" ? c.yellow(tag) : tag === "POST" ? c.magenta(tag) : "";
257
+ return (
258
+ side(g.away, g.home, g) +
259
+ c.dim(" @ ") +
260
+ side(g.home, g.away, g) +
261
+ " " +
262
+ pad(gameStatus(g, naming), 12) +
263
+ (tagStr ? " " + tagStr : "")
264
+ ).trimEnd();
265
+ }
266
+
180
267
  export { visibleLen };
@@ -0,0 +1,239 @@
1
+ // Per-sport live event diffing over sport-neutral `Game` snapshots (NBA, NHL).
2
+ //
3
+ // The World Cup path keeps its own football-data `Match` differ (events.ts);
4
+ // this is the ESPN-backed sibling. Each sport gets a rule set, because what is
5
+ // alert-worthy differs: every NHL goal matters, every NBA basket would be noise
6
+ // (so NBA alerts on tip-off, lead changes, a close finish, and the final).
7
+ //
8
+ // Pure apart from the caller-owned `GameMemory`: the live tick holds the
9
+ // previous snapshot plus one memory map per sport, and feeds successive polls
10
+ // in here. Two NBA rules need more than two snapshots — a lead change bridged
11
+ // by a tie (home leads → tied → away leads) and "close-late fires once per
12
+ // game" — so they remember per-game facts in that map. Diffing a snapshot
13
+ // against itself yields nothing, with or without memory.
14
+
15
+ import type { Game } from "./game.ts";
16
+
17
+ export type GameEventKind =
18
+ | "puck-drop"
19
+ | "tip-off"
20
+ | "goal"
21
+ | "period-end"
22
+ | "overtime"
23
+ | "shootout"
24
+ | "lead-change"
25
+ | "close-late"
26
+ | "final";
27
+
28
+ export type Side = "home" | "away";
29
+
30
+ export interface GameEvent {
31
+ kind: GameEventKind;
32
+ gameId: string;
33
+ /** Short fixture label, away @ home, e.g. "UTAH @ DEN". */
34
+ fixture: string;
35
+ home: string;
36
+ away: string;
37
+ /** Score at the moment of the event. */
38
+ score: { home: number; away: number };
39
+ /** Period the event belongs to — for `period-end`, the period that ended. */
40
+ period: number;
41
+ /** Source status text of the newer snapshot, e.g. "End of 1st", "Final/SO". */
42
+ detail: string;
43
+ /** `goal`: which side scored. */
44
+ scoringSide?: Side;
45
+ /** `lead-change`: which side now leads. */
46
+ leader?: Side;
47
+ }
48
+
49
+ /** Per-game facts a rule needs beyond the two snapshots being diffed. */
50
+ export interface GameMemo {
51
+ /** NBA: the last side seen leading (ties don't clear it). */
52
+ leader?: Side;
53
+ /** NBA: close-late already fired (or was already true at baseline). */
54
+ closeLate?: boolean;
55
+ }
56
+
57
+ /** Caller-owned, keyed by game id; one map per sport. Entries are tiny and
58
+ * are never pruned here — a game can drop out of one poll and come back. */
59
+ export type GameMemory = Map<string, GameMemo>;
60
+
61
+ /** What a rule emits; `diffGames` stamps on the shared game fields. */
62
+ type Emit = Pick<GameEvent, "kind" | "period"> & Partial<Pick<GameEvent, "scoringSide" | "leader">>;
63
+
64
+ /** One alert rule: the events for a single game moving `before` → `after`. */
65
+ type Rule = (before: Game, after: Game, memo: GameMemo) => Emit[];
66
+
67
+ // ── shared helpers ──────────────────────────────────────────────────────────
68
+
69
+ /** Numeric score, treating "" (not started) or junk as 0. */
70
+ const pts = (score: string): number => Number(score) || 0;
71
+
72
+ function scoreOf(g: Game): { home: number; away: number } {
73
+ return { home: pts(g.home.score), away: pts(g.away.score) };
74
+ }
75
+
76
+ function leaderOf(g: Game): Side | undefined {
77
+ const s = scoreOf(g);
78
+ return s.home > s.away ? "home" : s.away > s.home ? "away" : undefined;
79
+ }
80
+
81
+ /** Seconds left on a game clock ("4:21", "0:00", NBA's sub-minute "45.3"),
82
+ * or undefined when there is no parseable clock. */
83
+ export function clockSeconds(clock: string): number | undefined {
84
+ const m = /^\s*(?:(\d+):)?(\d+(?:\.\d+)?)\s*$/.exec(clock);
85
+ if (!m) return undefined;
86
+ return Number(m[1] ?? 0) * 60 + Number(m[2]);
87
+ }
88
+
89
+ /** Live with the clock run out — between periods (or at the final buzzer,
90
+ * before the source flips the game to "post"). */
91
+ const atPeriodEnd = (g: Game): boolean => g.state === "in" && clockSeconds(g.clock) === 0;
92
+
93
+ // Shared rules.
94
+
95
+ const start =
96
+ (kind: "puck-drop" | "tip-off"): Rule =>
97
+ (b, a) =>
98
+ b.state === "pre" && a.state === "in" ? [{ kind, period: a.period }] : [];
99
+
100
+ const final: Rule = (b, a) => (b.state === "in" && a.state === "post" ? [{ kind: "final", period: a.period }] : []);
101
+
102
+ // ── NHL ─────────────────────────────────────────────────────────────────────
103
+
104
+ const NHL_REGULATION_PERIODS = 3;
105
+
106
+ /** A shootout is "period 5" outside the playoffs; playoff OT never ends in one. */
107
+ const inShootout = (g: Game): boolean => g.seasonType !== "postseason" && g.period > NHL_REGULATION_PERIODS + 1;
108
+
109
+ /**
110
+ * One goal event per side whose score rose (a multi-goal burst between polls
111
+ * collapses into one event with the resulting score). Skipped around a
112
+ * shootout: ESPN credits the shootout winner's +1 only when the game goes
113
+ * final, and that isn't a goal.
114
+ */
115
+ const nhlGoals: Rule = (b, a) => {
116
+ if (inShootout(b) || inShootout(a)) return [];
117
+ const was = scoreOf(b);
118
+ const now = scoreOf(a);
119
+ const out: Emit[] = [];
120
+ if (now.home > was.home) out.push({ kind: "goal", period: a.period, scoringSide: "home" });
121
+ if (now.away > was.away) out.push({ kind: "goal", period: a.period, scoringSide: "away" });
122
+ return out;
123
+ };
124
+
125
+ /**
126
+ * End of a period: the clock hit 0:00 in a new snapshot, or the period number
127
+ * advanced without us ever seeing the 0:00 snapshot (a poll gap). The
128
+ * game-ending period is covered by `final`, and a shootout has no period end.
129
+ */
130
+ const nhlPeriodEnd: Rule = (b, a) => {
131
+ if (b.state !== "in" || a.state !== "in") return [];
132
+ const out: Emit[] = [];
133
+ if (a.period > b.period && !atPeriodEnd(b) && !inShootout(b)) out.push({ kind: "period-end", period: b.period });
134
+ if (atPeriodEnd(a) && !inShootout(a) && !(atPeriodEnd(b) && b.period === a.period)) {
135
+ out.push({ kind: "period-end", period: a.period });
136
+ }
137
+ return out;
138
+ };
139
+
140
+ /** Entering overtime (each playoff OT period) or the shootout. */
141
+ const nhlOvertime: Rule = (b, a) => {
142
+ if (b.state !== "in" || a.state !== "in" || a.period <= b.period || a.period <= NHL_REGULATION_PERIODS) return [];
143
+ return [{ kind: inShootout(a) ? "shootout" : "overtime", period: a.period }];
144
+ };
145
+
146
+ // ── NBA ─────────────────────────────────────────────────────────────────────
147
+
148
+ const NBA_FOURTH = 4;
149
+ const CLOSE_LATE_MARGIN = 5;
150
+ const CLOSE_LATE_SECONDS = 5 * 60;
151
+
152
+ /**
153
+ * The lead switched sides. Ties don't count as a change, but they don't reset
154
+ * the memory either, so home leads → tied → away leads (across polls) is one
155
+ * lead change. Only while live; the final covers a last-gasp swing.
156
+ */
157
+ const nbaLeadChange: Rule = (b, a, memo) => {
158
+ const was = leaderOf(b);
159
+ const now = leaderOf(a);
160
+ memo.leader ??= was;
161
+ const changed = a.state === "in" && now !== undefined && now !== was && memo.leader !== undefined && now !== memo.leader;
162
+ if (now) memo.leader = now;
163
+ return changed ? [{ kind: "lead-change", period: a.period, leader: now }] : [];
164
+ };
165
+
166
+ /** Live, in the 4th or any OT, ≤ 5:00 left, margin ≤ 5. */
167
+ function isCloseLate(g: Game): boolean {
168
+ if (g.state !== "in" || g.period < NBA_FOURTH) return false;
169
+ const left = clockSeconds(g.clock);
170
+ if (left === undefined || left > CLOSE_LATE_SECONDS) return false;
171
+ const s = scoreOf(g);
172
+ return Math.abs(s.home - s.away) <= CLOSE_LATE_MARGIN;
173
+ }
174
+
175
+ /**
176
+ * Close game late — once per game. If the earlier snapshot already qualified
177
+ * (we started watching mid-crunch), that's the baseline: remember it, no alert.
178
+ */
179
+ const nbaCloseLate: Rule = (b, a, memo) => {
180
+ if (memo.closeLate) return [];
181
+ if (isCloseLate(b)) {
182
+ memo.closeLate = true;
183
+ return [];
184
+ }
185
+ if (!isCloseLate(a)) return [];
186
+ memo.closeLate = true;
187
+ return [{ kind: "close-late", period: a.period }];
188
+ };
189
+
190
+ // ── rule sets + differ ──────────────────────────────────────────────────────
191
+
192
+ /** Alert rules per ESPN-backed sport, in emission order. */
193
+ export const GAME_RULES = {
194
+ nhl: [start("puck-drop"), nhlGoals, nhlPeriodEnd, nhlOvertime, final],
195
+ nba: [start("tip-off"), nbaLeadChange, nbaCloseLate, final],
196
+ } satisfies Record<string, Rule[]>;
197
+
198
+ export type GameSport = keyof typeof GAME_RULES;
199
+
200
+ /** True if `sport` has an alert rule set. */
201
+ export function isGameSport(sport: string): sport is GameSport {
202
+ return Object.hasOwn(GAME_RULES, sport);
203
+ }
204
+
205
+ /**
206
+ * Diff two `Game` snapshots into events using `sport`'s rule set.
207
+ *
208
+ * A game must appear in both snapshots (keyed by id) to diff a transition;
209
+ * brand-new entries are skipped until there is a prior state. Narrowing to
210
+ * favourite teams is the caller's job (filter `cur` first). Pass the same
211
+ * `memory` map on every tick for a sport so once-per-game rules hold across
212
+ * polls; omitted, each call starts fresh.
213
+ */
214
+ export function diffGames(prev: Game[], cur: Game[], sport: GameSport, memory: GameMemory = new Map()): GameEvent[] {
215
+ const rules: Rule[] = GAME_RULES[sport];
216
+ const prevById = new Map(prev.map((g) => [g.id, g]));
217
+ const events: GameEvent[] = [];
218
+
219
+ for (const g of cur) {
220
+ const before = prevById.get(g.id);
221
+ if (!before) continue;
222
+ let memo = memory.get(g.id);
223
+ if (!memo) memory.set(g.id, (memo = {}));
224
+
225
+ const base = {
226
+ gameId: g.id,
227
+ fixture: `${g.away.abbreviation} @ ${g.home.abbreviation}`,
228
+ home: g.home.abbreviation,
229
+ away: g.away.abbreviation,
230
+ score: scoreOf(g),
231
+ detail: g.detail,
232
+ };
233
+ for (const rule of rules) {
234
+ for (const e of rule(before, g, memo)) events.push({ ...base, ...e });
235
+ }
236
+ }
237
+
238
+ return events;
239
+ }
package/src/game.ts ADDED
@@ -0,0 +1,51 @@
1
+ // Sport-neutral game shape for the ESPN-backed leagues (NBA, NHL, …).
2
+ //
3
+ // `types.ts` is football-data.org's soccer shape (goals, groups, stages) and
4
+ // stays the World Cup path's model. NBA/NHL commands consume `Game` instead:
5
+ // periods + a clock, a season phase (preseason through postseason), and the
6
+ // per-game broadcast list that drives "can I watch it". Source-specific
7
+ // parsing lives with its source (ESPN → `toGame` in espn.ts); nothing here
8
+ // knows about any API.
9
+
10
+ /** "pre" (scheduled), "in" (live), "post" (finished). */
11
+ export type GameState = "pre" | "in" | "post";
12
+
13
+ export type SeasonPhase = "preseason" | "regular" | "postseason";
14
+
15
+ /** Who a broadcast is aimed at: everywhere, or one team's home market. */
16
+ export type BroadcastMarket = "national" | "home" | "away";
17
+
18
+ export interface Broadcast {
19
+ /** Network / service name as listed, e.g. "Utah 16", "ESPN+", "NBA TV". */
20
+ name: string;
21
+ market: BroadcastMarket;
22
+ }
23
+
24
+ export interface GameCompetitor {
25
+ /** Source team id (ESPN: Jazz "26", Mammoth "129764"); "" when unknown. */
26
+ id: string;
27
+ /** Full name, e.g. "Utah Jazz". */
28
+ name: string;
29
+ abbreviation: string;
30
+ /** Score as displayed; "" before the game starts. */
31
+ score: string;
32
+ }
33
+
34
+ export interface Game {
35
+ id: string;
36
+ /** ISO start time. */
37
+ date: string;
38
+ /** e.g. "Utah Jazz at Denver Nuggets". */
39
+ name: string;
40
+ state: GameState;
41
+ /** Short status text from the source, e.g. "Final/OT", "10/6 - 9:00 PM EDT". */
42
+ detail: string;
43
+ /** Current (live) or last (final) period; 0 before the game starts. */
44
+ period: number;
45
+ /** Game clock while live (e.g. "4:21"); "" when not in progress. */
46
+ clock: string;
47
+ seasonType: SeasonPhase;
48
+ home: GameCompetitor;
49
+ away: GameCompetitor;
50
+ broadcasts: Broadcast[];
51
+ }
package/src/index.ts CHANGED
@@ -1,15 +1,19 @@
1
1
  #!/usr/bin/env bun
2
2
  import { c } from "./ansi.ts";
3
3
  import { ApiError } from "./api.ts";
4
- import { fifa } from "./sports/fifa.ts";
4
+ import { SPORTS as REGISTRY, bare } from "./commands/cross.ts";
5
+ import { subscriptions } from "./commands/subscriptions.ts";
6
+ import pkg from "../package.json" with { type: "json" };
5
7
 
6
- const VERSION = "0.1.0";
8
+ // Single source of truth: package.json (a hardcoded copy drifted to a stale
9
+ // 0.1.0 while the package shipped 0.1.2). The JSON import is inlined by
10
+ // `bun build --compile`, and npm always ships package.json.
11
+ const VERSION: string = pkg.version;
7
12
 
8
- // Sport namespaces. Add a new sport by writing src/sports/<sport>.ts with a
9
- // dispatcher `(args: string[]) => unknown` and registering it here.
10
- const SPORTS: Record<string, (args: string[]) => unknown | Promise<unknown>> = {
11
- fifa,
12
- };
13
+ // Sport namespaces. Add a new sport by writing src/sports/<sport>.ts and
14
+ // registering it in SPORTS in src/commands/cross.ts (which also makes the bare
15
+ // cross-sport commands include it).
16
+ const SPORTS = Object.fromEntries(REGISTRY.map((s) => [s.key, s.run]));
13
17
 
14
18
  function help() {
15
19
  const b = c.bold;
@@ -17,18 +21,30 @@ function help() {
17
21
 
18
22
  ${b("USAGE")}
19
23
  sportsing <sport> <command> [options]
24
+ sportsing <today|next|me> ${c.dim("your favorite teams, every sport")}
25
+ sportsing live --notify [--quiet] ${c.dim("alerts for your teams, every sport")}
20
26
 
21
27
  ${b("SPORTS")}
22
28
  ${c.green("fifa")} FIFA World Cup 2026 ${c.dim("— sportsing fifa help")}
29
+ ${c.green("nba")} NBA, preseason through playoffs ${c.dim("— sportsing nba help")}
30
+ ${c.green("nhl")} NHL, preseason through playoffs ${c.dim("— sportsing nhl help")}
23
31
 
24
- ${b("NOTE")}
25
- During the World Cup, the ${b("fifa")} prefix is optional —
26
- ${c.dim("sportsing today")} is shorthand for ${c.dim("sportsing fifa today")}.
32
+ ${b("YOUR TEAMS")} ${c.dim("(favorites across every sport, each row tagged with its sport)")}
33
+ ${c.green("today")} Your teams' games today ${c.dim("(--tomorrow, --yesterday, --offset N)")}
34
+ ${c.green("next")} Each team's next game + countdown
35
+ ${c.green("me")} Dashboard: last result + next game per team
36
+ ${c.green("live --notify")} OS alerts for every sport with a favorite ${c.dim("(--quiet: no log; run with &)")}
37
+ ${c.dim("Add favorites per sport: sportsing nba fav add UTAH")}
38
+
39
+ ${b("SETTINGS")}
40
+ ${c.green("subscriptions")} What you can watch with + home market ${c.dim("— alias: subs")}
27
41
 
28
42
  ${b("EXAMPLES")}
29
43
  sportsing fifa today
30
44
  sportsing fifa next --team USA
31
- sportsing today ${c.dim("(= sportsing fifa today)")}
45
+ sportsing nba schedule --team UTAH
46
+ sportsing nhl next --team UTAH
47
+ sportsing today ${c.dim("(your teams, every sport)")}
32
48
  `);
33
49
  }
34
50
 
@@ -48,11 +64,14 @@ async function dispatch(): Promise<void> {
48
64
  return;
49
65
  }
50
66
 
51
- // Back-compat: while FIFA is the only sport, a bare `sportsing <command>`
52
- // runs as a FIFA command (`sportsing today` == `sportsing fifa today`).
53
- // An unknown token surfaces as "Unknown fifa command". Delete this line when
54
- // a second sport lands so bare commands require an explicit sport prefix.
55
- await fifa([first, ...rest]);
67
+ // Cross-sport settings (not tied to any one sport's namespace).
68
+ if (first === "subscriptions" || first === "subs") {
69
+ await subscriptions(rest);
70
+ return;
71
+ }
72
+
73
+ // Bare `today`/`next`/`me`/`live` run across every sport; anything else gets a hint.
74
+ await bare(first, rest);
56
75
  }
57
76
 
58
77
  async function main() {