sportsing 0.1.1 → 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 +173 -16
  2. package/package.json +12 -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 +106 -40
  42. package/src/watch-route.ts +95 -0
  43. package/src/watchability.ts +183 -0
package/README.md CHANGED
@@ -1,25 +1,49 @@
1
1
  # ⚽ sportsing
2
2
 
3
- Sports in your terminal — the FIFA World Cup 2026 schedule, favorites, live
4
- scores, **ambient fav-alerts**, browser streaming, highlights, stats, and AI
5
- analysis. No npm dependencies; ships as a self-contained [Bun](https://bun.sh)
6
- binary.
3
+ Sports in your terminal — the FIFA World Cup 2026, the **NBA**, and the
4
+ **NHL**: schedules, favorites across every sport, live scores, standings, playoff
5
+ brackets, **where to watch**, **ambient fav-alerts**, browser streaming,
6
+ highlights, stats, and AI analysis. Runs on [Bun](https://bun.sh); its one runtime dependency,
7
+ [ui-leaf](https://www.npmjs.com/package/@openthink/ui-leaf), powers the
8
+ browser-streaming window and is installed (with its native binary) automatically.
7
9
 
8
10
  ```
9
11
  sportsing fifa today
10
- sportsing fifa next --team USA
11
- sportsing today # the `fifa` prefix is optional during the World Cup
12
+ sportsing nba schedule --team UTAH
13
+ sportsing nhl next --team UTAH
14
+ sportsing today # your favorite teams across every sport (fifa, nba, nhl)
12
15
  ```
13
16
 
17
+ > **Upgrading from 0.1.x?** Bare commands other than `today` / `next` / `me` now
18
+ > need a sport prefix (`sportsing serve` → `sportsing fifa serve`), and favorites
19
+ > are now stored per sport. See **Upgrading to 0.2.0** below.
20
+
14
21
  ## Install
15
22
 
23
+ From npm (needs [Bun](https://bun.sh) on your `PATH` — the CLI runs on it):
24
+
25
+ ```sh
26
+ bun install -g sportsing # or: npm install -g sportsing
27
+ sportsing --version
28
+ ```
29
+
30
+ From source:
31
+
16
32
  ```sh
17
- bun install # dev deps (TypeScript, types)
33
+ bun install # deps: ui-leaf (+ its browser binary) and dev types
18
34
  bun run build # compiles a standalone binary → dist/sportsing
19
35
  ```
20
36
 
21
- Then run `./dist/sportsing …`, or put it on your `PATH`. For live data, add a
22
- free [football-data.org](https://www.football-data.org) API key:
37
+ Then run `./dist/sportsing …`, or put it on your `PATH`.
38
+
39
+ > **Streaming from the compiled binary.** `bun run build` produces a single
40
+ > file, but it can't embed ui-leaf's ~64MB native browser binary. Running from
41
+ > source (`bun run src/index.ts …`) or installing from npm resolves that binary
42
+ > automatically; for the standalone `dist/sportsing`, point streaming at an
43
+ > installed copy with `UI_LEAF_BINARY_PATH=/path/to/ui-leaf-bin`. Everything
44
+ > except the `watch` browser window works with no binary present.
45
+
46
+ For live data, add a free [football-data.org](https://www.football-data.org) API key:
23
47
 
24
48
  ```sh
25
49
  sportsing fifa setup # paste your key (or set FOOTBALL_DATA_API_KEY)
@@ -30,7 +54,8 @@ scores or tables).
30
54
 
31
55
  ## Commands
32
56
 
33
- `sportsing fifa <command>` (or just `sportsing <command>` during the Cup):
57
+ `sportsing fifa <command>` (bare `sportsing today|next|me` cover your favorites
58
+ across every sport — see **Your teams, every sport** below):
34
59
 
35
60
  | Command | What it does |
36
61
  |---|---|
@@ -49,7 +74,121 @@ scores or tables).
49
74
  Run `sportsing fifa help` for the full list (`serve` and `ask` are the AI-bus
50
75
  commands; `ask` is low-level plumbing that `serve` wraps).
51
76
 
52
- ## Watch
77
+ ## NBA
78
+
79
+ `sportsing nba <command>` — the NBA via ESPN's free API (no key), preseason
80
+ through playoffs. Times are local; preseason games are tagged `PRE`, postseason
81
+ (incl. play-in) `POST`.
82
+
83
+ | Command | What it does |
84
+ |---|---|
85
+ | `today [--tomorrow\|--yesterday\|--offset N]` | One day's games |
86
+ | `next` | Next game + countdown |
87
+ | `schedule` | A team's whole season by day (league-wide: next 7 days) |
88
+ | `results` | Finished games, newest first (league-wide: last 7 days) |
89
+ | `standings [--conference X] [--division X]` | Regular-season standings, favorites marked ★ |
90
+ | `season [team]` | Your favorites' season: record, home/away splits, playoff race |
91
+ | `bracket [--season YYYY]` | Playoff bracket (projected before the postseason) |
92
+ | `fav [add\|rm\|list]` | Manage NBA favorites (`fav add UTAH`, `fav add Jazz`) |
93
+ | `watch [team] [--wait] [--provider X] [--url L] [--smoke]` | Open the game where you can watch it — see **Where to watch** below |
94
+ | `live [--notify [--quiet]]` | Auto-refreshing live board; `--notify` alerts for favorites — see **Live fav-alerts** below |
95
+ | `analyze <team> [team]` | AI read of the live or latest game: shooting, boards, turnovers, leaders (answered by `serve`; `--prompt` prints it) |
96
+ | `predict <team> [team]` | AI prediction for the next game from both teams' recent form |
97
+ | `recap <team> [team]` | AI "here's what you missed": lead changes, runs, close-late baskets, quarter scores |
98
+
99
+ `--team X` (abbreviation or name; NBA.com codes like `UTA` work too) picks one
100
+ team for `today` / `next` / `schedule` / `results`; `--mine` limits them to your
101
+ NBA favorites. NBA favorites are separate from FIFA ones.
102
+
103
+ `today` / `next` / `schedule` show a **WATCH** column once you've set your
104
+ subscriptions (see **Where to watch**). `sportsing nba watch` opens Jazz games on
105
+ Fubo by default.
106
+
107
+ `sportsing nba watch [team] --overlay` opens Fubo's NBA hub with a live-stats
108
+ overlay (same gear / settings / spoiler-delay as the FIFA one) that follows the
109
+ game you open, recognized from the page title's English team names. NBA panels:
110
+ score & clock, FG% / 3P%, leaders (PTS / REB / AST), fouls, timeouts. On an NHL
111
+ game page (`sportsing nhl watch --provider fubo --overlay`): score & clock,
112
+ shots on goal, power plays, faceoff %, goalie saves. Both have **Get caught up**
113
+ (a recap up to your delayed stream's moment, via `/loop sportsing serve`). Panel choices are saved per
114
+ provider and sport (`overlayPanels["fubo:nba"]`). `--overlay --smoke` checks the
115
+ overlay renders, then tears the window down.
116
+
117
+ ## NHL
118
+
119
+ `sportsing nhl <command>` — the same commands as `nba`, for the NHL (ESPN, no
120
+ key), preseason through playoffs, tagged `PRE` / `POST` the same way. Live and
121
+ final status uses hockey periods: `12:34 - 2nd`, `End of 3rd`, `OT`, `SO`,
122
+ `Final/OT`, `Final/SO`.
123
+
124
+ `--team X` takes an abbreviation or name — `UTAH`, `UTA`, `Mammoth`, and
125
+ NHL.com codes like `TBL` / `LAK` all work. NHL favorites (`fav add UTAH`) are
126
+ separate from NBA and FIFA ones, so the Jazz and the Mammoth can both be `UTAH`.
127
+
128
+ `standings`, `season`, `bracket`, `watch`, and `analyze` / `predict` / `recap`
129
+ work exactly as for the NBA — the AI prompts speak hockey (goaltending, special
130
+ teams, every goal and penalty). There's
131
+ no default NHL streaming provider: `nhl watch` follows **Where to watch** — an
132
+ over-the-air game prints its channel (e.g. Utah 16) and opens nothing.
133
+
134
+ ## Where to watch (NBA / NHL)
135
+
136
+ Tell sportsing what you can watch with and where you live, once — it applies to
137
+ every sport:
138
+
139
+ ```sh
140
+ sportsing subscriptions set fubo nba-league-pass local-ota # whichever you have (or `none`)
141
+ sportsing subscriptions market utah # home market (default: utah)
142
+ sportsing subscriptions # show them (alias: subs)
143
+ ```
144
+
145
+ From each game's broadcasts (ESPN), sportsing then works out how *you* can watch
146
+ it:
147
+
148
+ - **WATCH column** in `today` / `next` / `schedule`: the service (`Fubo`,
149
+ `NBA League Pass`) or over-the-air channel, `✗` if you can't watch it, `?` if it
150
+ can't tell. With no subscriptions set, a one-line hint replaces the column.
151
+ - **`<sport> watch`** acts on the same answer: opens Fubo or League Pass, prints
152
+ the channel for an over-the-air-only game (nothing to open; exits 0), or says
153
+ why a game isn't watchable (exits 1). `--provider` / `--url` override it.
154
+ - **League Pass** covers out-of-market NBA games only — blacked out when a
155
+ home-market team plays or the game has a national broadcast. National
156
+ exclusives you don't carry are flagged.
157
+
158
+ ## Your teams, every sport
159
+
160
+ Bare `today`, `next`, and `me` (no sport prefix) aggregate your favorite teams
161
+ across every sport, each row tagged with its sport:
162
+
163
+ ```sh
164
+ sportsing nba fav add UTAH && sportsing nhl fav add UTAH
165
+ sportsing today # the Jazz and the Mammoth today (--tomorrow, --yesterday, --offset N)
166
+ sportsing next # each favorite's next game + countdown, soonest first
167
+ sportsing me # last result + next game per favorite team
168
+ ```
169
+
170
+ Bare `live --notify` alerts across every sport too (see **Live fav-alerts**).
171
+ Every other command needs a sport — a bare one (e.g. `sportsing standings`)
172
+ prints the sports that have it and exits non-zero.
173
+
174
+ Favorites are stored per sport as `<sport>:<team>` (`nba:UTAH`, `nhl:UTAH`,
175
+ `fifa:USA`) in `~/.config/sportsing/config.json`; add and remove them with each
176
+ sport's `fav` command.
177
+
178
+ ## Upgrading to 0.2.0
179
+
180
+ - **Breaking — bare commands need a sport.** In 0.1.x every bare command ran as a
181
+ FIFA command (`sportsing serve` meant `sportsing fifa serve`). Now only `today`,
182
+ `next`, and `me` work bare (across every sport); everything else needs its
183
+ prefix: `sportsing fifa serve`, `sportsing fifa live --notify`,
184
+ `sportsing fifa watch USA`, `sportsing fifa fav add USA`, and so on. Update any
185
+ scripts, aliases, or launchd jobs that call a bare command.
186
+ - **Favorites are scoped by sport.** Existing unprefixed favorites (`"USA"`) are
187
+ read as FIFA favorites (`fifa:USA`) — nothing is lost. The file is rewritten in
188
+ the prefixed form the next time you add or remove a favorite. Downgrading to
189
+ 0.1.x after that would see `fifa:USA` as a team name, so don't mix versions.
190
+
191
+ ## Watch (FIFA)
53
192
 
54
193
  `sportsing fifa watch [team] [team]` opens the broadcast in your own browser
55
194
  (your real Chrome, via [ui-leaf](https://www.npmjs.com/package/@openthink/ui-leaf)):
@@ -95,6 +234,8 @@ Turn `live` into an ambient alerter that pings you when your favorite teams play
95
234
  sportsing fifa fav add USA # set up favorites first
96
235
  sportsing fifa live --notify # live board + OS notifications
97
236
  sportsing fifa live --notify --quiet & # headless: alerts only, backgroundable
237
+ sportsing nba live --notify # same for the NBA (and `nhl live`)
238
+ sportsing live --notify --quiet & # one alerter for every sport with a favorite
98
239
  ```
99
240
 
100
241
  Each refresh diffs the latest scores against the previous tick and raises an OS
@@ -106,6 +247,20 @@ full-time alerts exactly once:
106
247
  - **Goal** — the scorer and the resulting scoreline (with a sound).
107
248
  - **Full time** — the final scoreline.
108
249
 
250
+ NBA and NHL alert on what matters per sport, not every basket:
251
+
252
+ - **NHL** — puck drop, every goal (with a sound), end of each period, overtime /
253
+ shootout, final.
254
+ - **NBA** — tip-off, lead changes, a close finish (≤ 5 points with ≤ 5:00 left in
255
+ the 4th or OT, once per game), final.
256
+ - **Tip-off / puck drop** are click-to-watch, routed like `<sport> watch` (see
257
+ **Where to watch**): a game on Fubo or League Pass opens on click; an
258
+ over-the-air-only game names its channel instead (nothing to click into).
259
+
260
+ Bare `sportsing live --notify` polls every sport you have a favorite in from one
261
+ process; a sport that can't load (e.g. FIFA without an API key) is reported and
262
+ the rest keep alerting. Without `--quiet` it logs each alert as a line.
263
+
109
264
  Flags:
110
265
 
111
266
  - **`--notify`** — fire the alerts. With no favorites set, it warns and runs the
@@ -117,7 +272,7 @@ Flags:
117
272
 
118
273
  ### Click-to-watch requires `terminal-notifier`
119
274
 
120
- Clickable kickoff notifications use
275
+ Clickable kickoff / tip-off / puck-drop notifications use
121
276
  [`terminal-notifier`](https://github.com/julienXX/terminal-notifier) (macOS):
122
277
 
123
278
  ```sh
@@ -127,7 +282,8 @@ brew install terminal-notifier
127
282
  Notifications **degrade gracefully** when it's absent: on macOS they fall back to
128
283
  `osascript` (plain banner, no click action); on Linux to `notify-send`; otherwise
129
284
  to a terminal bell. Nothing errors — you just don't get the one-click-to-watch
130
- behavior without `terminal-notifier`.
285
+ behavior without `terminal-notifier`. A click opens a Terminal window running
286
+ `watch` (Ctrl-C or close the stream window to stop).
131
287
 
132
288
  ## AI (analyze / predict / overlay "Ask Claude" + "Get caught up")
133
289
 
@@ -157,18 +313,19 @@ prints this recipe; `sportsing fifa` and the watch nag point at it.
157
313
  ### Low-level primitive — `serve`
158
314
 
159
315
  `sportsing fifa serve` is the bare answerer loop (it powers `analyze` / `predict`
160
- too). `agent-setup` supersedes the old manual two-step for the agent-driven flow,
316
+ / `recap` too — for every sport: the bus is shared, so one `fifa serve` answers
317
+ NBA and NHL questions as well). `agent-setup` supersedes the old manual two-step for the agent-driven flow,
161
318
  but `serve` remains the primitive if you want to compose it yourself:
162
319
 
163
320
  ```sh
164
321
  sportsing fifa watch --wait # (backgrounded) opens the game when it's live
165
- /loop sportsing serve # answer-only loop — no watch supervision
322
+ /loop sportsing fifa serve # answer-only loop — no watch supervision
166
323
  ```
167
324
 
168
325
  > **Run the answerer in a minimal-tool session.** Whether via `agent-setup` or
169
326
  > `serve`, the loop reads **untrusted** text (viewer questions + raw API fields)
170
327
  > into a tool-capable Claude session. Give that session no MCP/file tools and only
171
- > the `sportsing ask --reply` Bash capability, so a prompt-injection in a question
328
+ > the `sportsing fifa ask --reply` Bash capability, so a prompt-injection in a question
172
329
  > can't reach anything dangerous. `serve` prints this reminder each tick.
173
330
 
174
331
  ## Development
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sportsing",
3
- "version": "0.1.1",
4
- "description": "Sportsing — the FIFA World Cup 2026 in your terminal: schedule, favorites, live scores, ambient fav-alerts, browser streaming, highlights, stats, and AI analysis.",
3
+ "version": "0.2.0",
4
+ "description": "Sportsing — sports in your terminal: the FIFA World Cup 2026, the NBA, and the NHL. Schedules, favorites across every sport, live scores, standings, where-to-watch, browser streaming, highlights, stats, and AI analysis.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "sportsing": "src/index.ts"
@@ -34,6 +34,10 @@
34
34
  "world-cup",
35
35
  "wc2026",
36
36
  "soccer",
37
+ "nba",
38
+ "nhl",
39
+ "basketball",
40
+ "hockey",
37
41
  "football",
38
42
  "cli",
39
43
  "terminal",
@@ -45,6 +49,12 @@
45
49
  "publishConfig": {
46
50
  "access": "public"
47
51
  },
52
+ "dependencies": {
53
+ "@openthink/ui-leaf": "^1.5.1"
54
+ },
55
+ "trustedDependencies": [
56
+ "@openthink/ui-leaf"
57
+ ],
48
58
  "devDependencies": {
49
59
  "@types/node": "^25.9.2",
50
60
  "bun-types": "^1.3.14",
package/src/alerts.ts ADDED
@@ -0,0 +1,47 @@
1
+ // Ambient favorite-team alerts, sport-neutral. Each sport turns successive
2
+ // score polls into `Alert`s (FIFA: events.ts; NBA/NHL: league-alerts.ts); an
3
+ // `Alerter` wraps that with its own fetch so bare `sportsing live --notify`
4
+ // can poll every sport with a favorite from one process.
5
+
6
+ import { notify, type NotifyOptions } from "./notify.ts";
7
+
8
+ /** How often `live` refreshes and alerters poll. */
9
+ export const LIVE_REFRESH_MS = 60_000;
10
+
11
+ /** One OS notification to raise. */
12
+ export interface Alert {
13
+ title: string;
14
+ body: string;
15
+ options: NotifyOptions;
16
+ }
17
+
18
+ /** One sport's favorite-team alerter. */
19
+ export interface Alerter {
20
+ /** Sport label, e.g. "NBA". */
21
+ label: string;
22
+ /** The favorite teams it alerts for, for the startup line. */
23
+ teams: string[];
24
+ /** Fetch the latest scores and return the new alerts since the previous
25
+ * poll. The first poll only sets the baseline (a game already under way
26
+ * when you start raises no start alert). */
27
+ poll: () => Promise<Alert[]>;
28
+ }
29
+
30
+ /** Raise an alert as a desktop notification. */
31
+ export function raise(a: Alert): void {
32
+ notify(a.title, a.body, a.options);
33
+ }
34
+
35
+ /**
36
+ * Poll every alerter once, concurrently. A sport whose poll fails is handed to
37
+ * `onError` and contributes nothing this round; the others' alerts still come
38
+ * back, in alerter order.
39
+ */
40
+ export async function pollAlerters(alerters: Alerter[], onError: (a: Alerter, e: unknown) => void): Promise<Alert[]> {
41
+ const settled = await Promise.allSettled(alerters.map((a) => a.poll()));
42
+ return settled.flatMap((r, i) => {
43
+ if (r.status === "fulfilled") return r.value;
44
+ onError(alerters[i]!, r.reason);
45
+ return [];
46
+ });
47
+ }
package/src/api.ts CHANGED
@@ -131,7 +131,7 @@ export async function getOpenFootballMatches(): Promise<Match[]> {
131
131
  if (!res.ok)
132
132
  throw new ApiError(
133
133
  res.status,
134
- "Could not fetch the offline fixture schedule — network unavailable. Run `sportsing setup` to add a free API key for live data.",
134
+ "Could not fetch the offline fixture schedule — network unavailable. Run `sportsing fifa setup` to add a free API key for live data.",
135
135
  );
136
136
  const data = (await res.json()) as any;
137
137
  const out: Match[] = [];
@@ -0,0 +1,39 @@
1
+ // Click-to-watch for start-of-game notifications: the shell command a
2
+ // notification runs when clicked (terminal-notifier `-execute`).
3
+ //
4
+ // `watch` is interactive — it blocks until its stream window is closed, and
5
+ // refuses to run without a controlling TTY. terminal-notifier runs `-execute`
6
+ // through /bin/sh with no TTY, so the click opens a Terminal window that runs
7
+ // `watch` there (a TTY, and a Ctrl-C to stop it).
8
+
9
+ /** POSIX single-quote a string so it's safe as one shell argument. */
10
+ export function shQuote(s: string): string {
11
+ return `'${s.replace(/'/g, `'\\''`)}'`;
12
+ }
13
+
14
+ /**
15
+ * argv that re-runs this sportsing: the binary itself when compiled
16
+ * (`bun build --compile`; the entry lives in Bun's virtual /$bunfs), else the
17
+ * Bun runtime plus the entry script (the npm install runs src/index.ts).
18
+ */
19
+ export function selfInvocation(main = Bun.main, execPath = process.execPath): string[] {
20
+ const compiled = main.startsWith("/$bunfs/") || main.includes("~BUN");
21
+ return compiled ? [execPath] : [execPath, main];
22
+ }
23
+
24
+ /** `<exe…> <sport> watch <team>`, shell-quoted. */
25
+ export function watchCommand(exe: string[], sport: string, team: string): string {
26
+ return `${exe.map(shQuote).join(" ")} ${sport} watch ${shQuote(team)}`;
27
+ }
28
+
29
+ /** Escape a string for an AppleScript double-quoted literal. */
30
+ function osaString(s: string): string {
31
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
32
+ }
33
+
34
+ /** A shell command that runs `cmd` in a new macOS Terminal window and brings
35
+ * Terminal to the front. */
36
+ export function inTerminal(cmd: string): string {
37
+ const script = (line: string) => `-e ${shQuote(`tell application "Terminal" to ${line}`)}`;
38
+ return `osascript ${script(`do script ${osaString(cmd)}`)} ${script("activate")}`;
39
+ }
@@ -43,7 +43,7 @@ export async function withFallback<T>(
43
43
  console.error(
44
44
  c.yellow(
45
45
  "No API key set — showing the offline schedule (no live scores/tables).\n" +
46
- "Run `sportsing setup` for live data.",
46
+ "Run `sportsing fifa setup` for live data.",
47
47
  ) + "\n",
48
48
  );
49
49
  const matches = await getOpenFootballMatches();
@@ -70,19 +70,35 @@ export function getFlag(args: string[], flag: string): string | null {
70
70
  }
71
71
 
72
72
  /**
73
- * If `--mine` is present in args, narrow `matches` to games involving a favorite
74
- * team. Returns the literal `"no-favorites"` when `--mine` was asked for but no
75
- * favorites are set, so the caller can show a helpful hint. Without `--mine`,
76
- * returns the list unchanged.
73
+ * The favorites `--mine` should filter by for `sport`: null when `--mine` is
74
+ * absent, the literal `"no-favorites"` when it was asked for but that sport has
75
+ * none set (so the caller can show a hint), else that sport's favorite names.
76
+ */
77
+ export async function mineFavorites(args: string[], sport: string): Promise<string[] | null | "no-favorites"> {
78
+ if (!args.includes("--mine")) return null;
79
+ const favs = await getFavorites(sport);
80
+ return favs.length === 0 ? "no-favorites" : favs;
81
+ }
82
+
83
+ /**
84
+ * If `--mine` is present in args, narrow football-data `matches` (the FIFA
85
+ * shape) to games involving a FIFA favorite. Returns `"no-favorites"` when
86
+ * `--mine` was asked for but none are set. Without `--mine`, returns the list
87
+ * unchanged. ESPN-league commands filter `Game`s by team id instead (league.ts).
77
88
  */
78
89
  export async function applyMine(matches: Match[], args: string[]): Promise<Match[] | "no-favorites"> {
79
- if (!args.includes("--mine")) return matches;
80
- const favs = (await getFavorites()).map((f) => f.toLowerCase());
81
- if (favs.length === 0) return "no-favorites";
82
- return matches.filter((m) => favs.some((n) => matchHasTeam(m, n)));
90
+ const favs = await mineFavorites(args, "fifa");
91
+ if (favs === null) return matches;
92
+ if (favs === "no-favorites") return favs;
93
+ const needles = favs.map((f) => f.toLowerCase());
94
+ return matches.filter((m) => needles.some((n) => matchHasTeam(m, n)));
83
95
  }
84
96
 
85
- /** Shared message for `--mine` with no favorites configured. */
86
- export function noFavoritesHint(): void {
87
- console.log(c.dim("No favorite teams yet — add one with ") + c.bold("sportsing fifa fav add USA"));
97
+ /** Example team for "add a favorite" hints, per sport. */
98
+ export const EXAMPLE_TEAM: Record<string, string> = { fifa: "USA", nba: "UTAH", nhl: "UTAH" };
99
+
100
+ /** Shared message for `--mine` with no favorites configured for `sport`. */
101
+ export function noFavoritesHint(sport = "fifa"): void {
102
+ const example = EXAMPLE_TEAM[sport] ?? "<team>";
103
+ console.log(c.dim("No favorite teams yet — add one with ") + c.bold(`sportsing ${sport} fav add ${example}`));
88
104
  }
@@ -36,7 +36,7 @@ export async function analyze(args: string[]) {
36
36
  }
37
37
 
38
38
  process.stderr.write(c.dim("Posted to the ask bus — waiting for your Claude agent to answer…\n"));
39
- process.stderr.write(c.dim("(keep one serving: /loop sportsing serve)\n"));
39
+ process.stderr.write(c.dim("(keep one serving: /loop sportsing fifa serve)\n"));
40
40
  const id = await postQuestion({
41
41
  source: "analyze",
42
42
  question: prompt,
@@ -47,7 +47,7 @@ export async function analyze(args: string[]) {
47
47
  const analysis = await waitForAnswer(id, 180_000);
48
48
  if (analysis === null) {
49
49
  console.error(c.yellow("No Claude agent answered within 3 minutes."));
50
- console.error(c.dim("Start a serving agent in another Claude session, then retry: /loop sportsing serve"));
50
+ console.error(c.dim("Start a serving agent in another Claude session, then retry: /loop sportsing fifa serve"));
51
51
  console.error(c.dim("Or run with --prompt to get the prompt and analyze elsewhere."));
52
52
  process.exitCode = 1;
53
53
  return;
@@ -26,7 +26,7 @@ export async function ask(args: string[]): Promise<void> {
26
26
  // self-contained PROMPT (not data): it blocks for the next pending question and
27
27
  // emits instructions telling the reading agent to answer it and run the exact
28
28
  // `ask --reply` command. Designed to be dropped into a Claude session as
29
- // `/loop sportsing serve` — each tick serves one question; the model IS the
29
+ // `/loop sportsing fifa serve` — each tick serves one question; the model IS the
30
30
  // answerer, so no `claude -p` is ever spawned.
31
31
  export async function serve(args: string[]): Promise<void> {
32
32
  const waitSec = numFlag(args, "--wait") ?? 60;