bbdata-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1857 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/utils/http.ts
4
+ var DEFAULT_TIMEOUT = 3e4;
5
+ var DEFAULT_RETRIES = 2;
6
+ async function fetchWithRetry(url, options = {}) {
7
+ const { headers, timeout = DEFAULT_TIMEOUT, retries = DEFAULT_RETRIES } = options;
8
+ let lastError;
9
+ for (let attempt = 0; attempt <= retries; attempt++) {
10
+ try {
11
+ const controller = new AbortController();
12
+ const timer = setTimeout(() => controller.abort(), timeout);
13
+ const response = await fetch(url, {
14
+ headers,
15
+ signal: controller.signal
16
+ });
17
+ clearTimeout(timer);
18
+ if (!response.ok) {
19
+ throw new Error(`HTTP ${response.status}: ${response.statusText} \u2014 ${url}`);
20
+ }
21
+ return response;
22
+ } catch (error) {
23
+ lastError = error instanceof Error ? error : new Error(String(error));
24
+ if (attempt < retries) {
25
+ await new Promise((resolve) => setTimeout(resolve, 1e3 * (attempt + 1)));
26
+ }
27
+ }
28
+ }
29
+ throw lastError ?? new Error(`Failed to fetch: ${url}`);
30
+ }
31
+ async function fetchJson(url, options) {
32
+ const response = await fetchWithRetry(url, options);
33
+ return response.json();
34
+ }
35
+ async function fetchText(url, options) {
36
+ const response = await fetchWithRetry(url, options);
37
+ return response.text();
38
+ }
39
+
40
+ // src/utils/logger.ts
41
+ import chalk from "chalk";
42
+ var isTTY = process.stdout.isTTY ?? false;
43
+ var log = {
44
+ info(message) {
45
+ if (isTTY) {
46
+ process.stderr.write(chalk.blue("\u2139 ") + message + "\n");
47
+ }
48
+ },
49
+ success(message) {
50
+ if (isTTY) {
51
+ process.stderr.write(chalk.green("\u2713 ") + message + "\n");
52
+ }
53
+ },
54
+ warn(message) {
55
+ process.stderr.write(chalk.yellow("\u26A0 ") + message + "\n");
56
+ },
57
+ error(message) {
58
+ process.stderr.write(chalk.red("\u2717 ") + message + "\n");
59
+ },
60
+ debug(message) {
61
+ if (process.env.BBDATA_DEBUG) {
62
+ process.stderr.write(chalk.gray("\u22EF ") + message + "\n");
63
+ }
64
+ },
65
+ /** Print data to stdout — this is what agents/pipes consume */
66
+ data(content) {
67
+ process.stdout.write(content);
68
+ }
69
+ };
70
+
71
+ // src/adapters/mlb-stats-api.ts
72
+ var BASE_URL = "https://statsapi.mlb.com/api/v1";
73
+ var MlbStatsApiAdapter = class {
74
+ source = "mlb-stats-api";
75
+ description = "Official MLB Stats API \u2014 rosters, schedules, season stats (JSON)";
76
+ supports(query2) {
77
+ return true;
78
+ }
79
+ async resolvePlayer(name) {
80
+ log.debug(`MLB API: resolving player "${name}"`);
81
+ try {
82
+ const data = await fetchJson(
83
+ `${BASE_URL}/people/search?names=${encodeURIComponent(name)}&hydrate=currentTeam`
84
+ );
85
+ if (!data.people?.length) return null;
86
+ const player = data.people[0];
87
+ return {
88
+ mlbam_id: String(player.id),
89
+ name: player.fullName,
90
+ team: player.currentTeam?.abbreviation,
91
+ position: player.primaryPosition?.abbreviation
92
+ };
93
+ } catch (error) {
94
+ log.warn(`Failed to resolve player "${name}" via MLB API: ${error}`);
95
+ return null;
96
+ }
97
+ }
98
+ async fetch(query2, options) {
99
+ let playerId = query2.player_id;
100
+ let playerName = query2.player_name ?? "Unknown";
101
+ if (!playerId && query2.player_name) {
102
+ const resolved = await this.resolvePlayer(query2.player_name);
103
+ if (!resolved) {
104
+ throw new Error(`Player not found: "${query2.player_name}"`);
105
+ }
106
+ playerId = resolved.mlbam_id;
107
+ playerName = resolved.name;
108
+ }
109
+ const statGroup = query2.stat_type === "batting" ? "hitting" : query2.stat_type;
110
+ const url = playerId ? `${BASE_URL}/people/${playerId}/stats?stats=season&season=${query2.season}&group=${statGroup}` : `${BASE_URL}/stats?stats=season&season=${query2.season}&group=${statGroup}&gameType=R&limit=50&sortStat=onBasePlusSlugging`;
111
+ log.debug(`MLB API: fetching ${url}`);
112
+ const data = await fetchJson(url);
113
+ const stats = [];
114
+ for (const statGroup2 of data.stats ?? []) {
115
+ for (const split of statGroup2.splits ?? []) {
116
+ stats.push({
117
+ player_id: playerId ?? String(split.player?.id ?? ""),
118
+ player_name: playerName ?? split.player?.fullName ?? "Unknown",
119
+ team: split.team?.abbreviation ?? query2.team ?? "",
120
+ season: Number(split.season) || query2.season,
121
+ stat_type: query2.stat_type,
122
+ stats: split.stat
123
+ });
124
+ }
125
+ }
126
+ return {
127
+ data: stats,
128
+ source: this.source,
129
+ cached: false,
130
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
131
+ meta: {
132
+ rowCount: stats.length,
133
+ season: query2.season,
134
+ query: query2
135
+ }
136
+ };
137
+ }
138
+ };
139
+
140
+ // src/adapters/savant.ts
141
+ import { parse } from "csv-parse/sync";
142
+ var SAVANT_SEARCH_URL = "https://baseballsavant.mlb.com/statcast_search/csv";
143
+ var SavantAdapter = class {
144
+ source = "savant";
145
+ description = "Baseball Savant (Statcast) \u2014 pitch-level data via CSV export";
146
+ // Use MLB API for player resolution since Savant needs MLBAM IDs
147
+ mlbApi = new MlbStatsApiAdapter();
148
+ supports(query2) {
149
+ return !!(query2.player_name || query2.player_id || query2.start_date && query2.end_date);
150
+ }
151
+ async resolvePlayer(name) {
152
+ return this.mlbApi.resolvePlayer(name);
153
+ }
154
+ async fetch(query2, options) {
155
+ let playerId = query2.player_id;
156
+ if (!playerId && query2.player_name) {
157
+ const resolved = await this.resolvePlayer(query2.player_name);
158
+ if (!resolved) {
159
+ throw new Error(`Player not found: "${query2.player_name}"`);
160
+ }
161
+ playerId = resolved.mlbam_id;
162
+ }
163
+ const params = new URLSearchParams({
164
+ all: "true",
165
+ type: "detail",
166
+ ...query2.stat_type === "pitching" ? { player_type: "pitcher", pitchers_lookup: playerId ?? "" } : { player_type: "batter", batters_lookup: playerId ?? "" },
167
+ ...query2.start_date ? { game_date_gt: query2.start_date } : { game_date_gt: `${query2.season}-01-01` },
168
+ ...query2.end_date ? { game_date_lt: query2.end_date } : { game_date_lt: `${query2.season}-12-31` }
169
+ });
170
+ if (query2.pitch_type?.length) {
171
+ params.set("pitch_type", query2.pitch_type.join(","));
172
+ }
173
+ const url = `${SAVANT_SEARCH_URL}?${params}`;
174
+ log.info(`Fetching Statcast data from Baseball Savant...`);
175
+ log.debug(`Savant URL: ${url}`);
176
+ const csvText = await fetchText(url, { timeout: 6e4 });
177
+ if (!csvText.trim() || csvText.includes("No Results")) {
178
+ return {
179
+ data: [],
180
+ source: this.source,
181
+ cached: false,
182
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
183
+ meta: { rowCount: 0, season: query2.season, query: query2 }
184
+ };
185
+ }
186
+ const rawRows = parse(csvText, {
187
+ columns: true,
188
+ skip_empty_lines: true,
189
+ cast: true,
190
+ cast_date: false
191
+ });
192
+ const filteredRows = rawRows.filter(
193
+ (row) => row.pitch_type && String(row.pitch_type).trim() !== ""
194
+ );
195
+ const data = filteredRows.map((row) => ({
196
+ pitcher_id: String(row.pitcher ?? ""),
197
+ pitcher_name: String(row.player_name ?? ""),
198
+ batter_id: String(row.batter ?? ""),
199
+ batter_name: String(row.batter_name || "") || (row.batter ? `Unknown (#${row.batter})` : "Unknown"),
200
+ game_date: String(row.game_date ?? ""),
201
+ pitch_type: String(row.pitch_type ?? ""),
202
+ release_speed: Number(row.release_speed) || 0,
203
+ release_spin_rate: Number(row.release_spin_rate) || 0,
204
+ pfx_x: Number(row.pfx_x) || 0,
205
+ pfx_z: Number(row.pfx_z) || 0,
206
+ plate_x: Number(row.plate_x) || 0,
207
+ plate_z: Number(row.plate_z) || 0,
208
+ launch_speed: row.launch_speed != null ? Number(row.launch_speed) || null : null,
209
+ launch_angle: row.launch_angle != null ? Number(row.launch_angle) || null : null,
210
+ description: String(row.description ?? ""),
211
+ events: row.events ? String(row.events) : null,
212
+ bb_type: row.bb_type ? String(row.bb_type) : null,
213
+ stand: String(row.stand ?? "R"),
214
+ p_throws: String(row.p_throws ?? "R"),
215
+ estimated_ba: row.estimated_ba_using_speedangle != null ? Number(row.estimated_ba_using_speedangle) || null : null,
216
+ estimated_woba: row.estimated_woba_using_speedangle != null ? Number(row.estimated_woba_using_speedangle) || null : null
217
+ }));
218
+ log.success(`Fetched ${data.length} pitches from Savant`);
219
+ return {
220
+ data,
221
+ source: this.source,
222
+ cached: false,
223
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
224
+ meta: { rowCount: data.length, season: query2.season, query: query2 }
225
+ };
226
+ }
227
+ };
228
+
229
+ // src/adapters/fangraphs.ts
230
+ var FG_LEADERS_URL = "https://www.fangraphs.com/api/leaders/major-league/data";
231
+ var FanGraphsAdapter = class {
232
+ source = "fangraphs";
233
+ description = "FanGraphs \u2014 aggregated stats, leaderboards, WAR, wRC+, FIP";
234
+ supports(query2) {
235
+ return true;
236
+ }
237
+ async resolvePlayer(name) {
238
+ log.debug(`FanGraphs: resolving player "${name}" via leaderboard search`);
239
+ try {
240
+ const season = (/* @__PURE__ */ new Date()).getFullYear();
241
+ const params = new URLSearchParams({
242
+ pos: "all",
243
+ stats: "bat",
244
+ lg: "all",
245
+ qual: "0",
246
+ season: String(season),
247
+ month: "0",
248
+ ind: "0",
249
+ team: "",
250
+ pageitems: "500",
251
+ pagenum: "1",
252
+ type: "8"
253
+ // standard stats
254
+ });
255
+ const data = await fetchText(`${FG_LEADERS_URL}?${params}`);
256
+ const parsed = JSON.parse(data);
257
+ const nameNorm = name.toLowerCase().trim();
258
+ const players = parsed.data ?? [];
259
+ const getName = (p) => String(p.PlayerName ?? p.Name ?? "").toLowerCase().trim();
260
+ let match = players.find((p) => getName(p) === nameNorm);
261
+ if (!match) {
262
+ const tokens = nameNorm.split(/\s+/);
263
+ match = players.find((p) => {
264
+ const words = getName(p).split(/\s+/);
265
+ return tokens.every((t) => words.some((w) => w.startsWith(t)));
266
+ });
267
+ if (match) {
268
+ log.debug(`FanGraphs: fuzzy match for "${name}" \u2192 "${getName(match)}"`);
269
+ }
270
+ }
271
+ if (!match) return null;
272
+ return {
273
+ mlbam_id: String(match.xMLBAMID ?? ""),
274
+ fangraphs_id: String(match.playerid ?? ""),
275
+ name: String(match.PlayerName ?? match.Name),
276
+ team: String(match.Team ?? "")
277
+ };
278
+ } catch (error) {
279
+ log.warn(`FanGraphs player resolution failed: ${error}`);
280
+ return null;
281
+ }
282
+ }
283
+ async fetch(query2, options) {
284
+ const statType = query2.stat_type === "batting" ? "bat" : "pit";
285
+ const params = new URLSearchParams({
286
+ pos: "all",
287
+ stats: statType,
288
+ lg: "all",
289
+ qual: String(query2.min_pa ?? query2.min_ip ?? 0),
290
+ season: String(query2.season),
291
+ month: "0",
292
+ ind: "0",
293
+ team: query2.team ? await this.resolveTeamId(query2.team) : "",
294
+ pageitems: "500",
295
+ pagenum: "1",
296
+ type: "8"
297
+ // standard + advanced stats
298
+ });
299
+ const url = `${FG_LEADERS_URL}?${params}`;
300
+ log.info("Fetching stats from FanGraphs...");
301
+ log.debug(`FanGraphs URL: ${url}`);
302
+ const raw = await fetchText(url);
303
+ const parsed = JSON.parse(raw);
304
+ const rows = parsed.data ?? [];
305
+ let filtered = rows;
306
+ if (query2.player_name) {
307
+ const nameNorm = query2.player_name.toLowerCase().trim();
308
+ const getName = (r) => String(r.PlayerName ?? r.Name ?? "").toLowerCase().trim();
309
+ const exact = rows.filter((r) => getName(r) === nameNorm);
310
+ if (exact.length > 0) {
311
+ filtered = exact;
312
+ } else {
313
+ const tokens = nameNorm.split(/\s+/);
314
+ filtered = rows.filter((r) => {
315
+ const words = getName(r).split(/\s+/);
316
+ return tokens.every((t) => words.some((w) => w.startsWith(t)));
317
+ });
318
+ }
319
+ }
320
+ const stats = filtered.map((row) => ({
321
+ player_id: String(row.xMLBAMID ?? row.playerid ?? ""),
322
+ player_name: String(row.PlayerName ?? row.Name ?? ""),
323
+ team: String(row.Team ?? ""),
324
+ season: query2.season,
325
+ stat_type: query2.stat_type,
326
+ stats: row
327
+ }));
328
+ log.success(`Fetched ${stats.length} player(s) from FanGraphs`);
329
+ return {
330
+ data: stats,
331
+ source: this.source,
332
+ cached: false,
333
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
334
+ meta: { rowCount: stats.length, season: query2.season, query: query2 }
335
+ };
336
+ }
337
+ async resolveTeamId(abbrev) {
338
+ const TEAM_IDS = {
339
+ LAA: "1",
340
+ ARI: "15",
341
+ BAL: "2",
342
+ BOS: "3",
343
+ CHC: "17",
344
+ CHW: "4",
345
+ CIN: "18",
346
+ CLE: "5",
347
+ COL: "19",
348
+ DET: "6",
349
+ HOU: "21",
350
+ KC: "7",
351
+ LAD: "22",
352
+ MIA: "20",
353
+ MIL: "23",
354
+ MIN: "8",
355
+ NYM: "25",
356
+ NYY: "9",
357
+ OAK: "10",
358
+ PHI: "26",
359
+ PIT: "27",
360
+ SD: "29",
361
+ SF: "30",
362
+ SEA: "11",
363
+ STL: "28",
364
+ TB: "12",
365
+ TEX: "13",
366
+ TOR: "14",
367
+ WSH: "24",
368
+ ATL: "16"
369
+ };
370
+ return TEAM_IDS[abbrev.toUpperCase()] ?? "";
371
+ }
372
+ };
373
+
374
+ // src/adapters/baseball-reference.ts
375
+ var BaseballReferenceAdapter = class {
376
+ source = "baseball-reference";
377
+ description = "Baseball Reference \u2014 historical stats, career data (BETA)";
378
+ supports(_query) {
379
+ return false;
380
+ }
381
+ async resolvePlayer(_name) {
382
+ log.warn("Baseball Reference adapter is in beta \u2014 player resolution not yet implemented");
383
+ return null;
384
+ }
385
+ async fetch(query2, _options) {
386
+ log.warn("Baseball Reference adapter is in beta \u2014 returning empty results");
387
+ return {
388
+ data: [],
389
+ source: this.source,
390
+ cached: false,
391
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
392
+ meta: { rowCount: 0, season: query2.season, query: query2 }
393
+ };
394
+ }
395
+ };
396
+
397
+ // src/adapters/index.ts
398
+ var adapters = {
399
+ "mlb-stats-api": new MlbStatsApiAdapter(),
400
+ "savant": new SavantAdapter(),
401
+ "fangraphs": new FanGraphsAdapter(),
402
+ "baseball-reference": new BaseballReferenceAdapter()
403
+ };
404
+ function resolveAdapters(preferred) {
405
+ return preferred.map((source) => adapters[source]).filter(Boolean);
406
+ }
407
+
408
+ // src/formatters/json.ts
409
+ function formatJson(data, meta) {
410
+ const output = { data, meta };
411
+ return {
412
+ raw: data,
413
+ formatted: JSON.stringify(output, null, 2) + "\n",
414
+ meta
415
+ };
416
+ }
417
+
418
+ // src/formatters/table.ts
419
+ import Table from "cli-table3";
420
+ import chalk2 from "chalk";
421
+ function formatTable(data, meta, options = {}) {
422
+ if (data.length === 0) {
423
+ return {
424
+ raw: data,
425
+ formatted: "No data found.\n",
426
+ meta
427
+ };
428
+ }
429
+ const columns = options.columns ?? Object.keys(data[0]);
430
+ const table = new Table({
431
+ head: columns.map((col) => chalk2.bold(col)),
432
+ style: { head: ["cyan"] }
433
+ });
434
+ for (const row of data) {
435
+ table.push(columns.map((col) => formatCell(row[col])));
436
+ }
437
+ const footer = chalk2.gray(
438
+ `
439
+ ${meta.sampleSize} rows \xB7 Source: ${meta.source} \xB7 ${meta.cached ? "cached" : "live"}`
440
+ );
441
+ return {
442
+ raw: data,
443
+ formatted: table.toString() + footer + "\n",
444
+ meta
445
+ };
446
+ }
447
+ function formatCell(value) {
448
+ if (value === null || value === void 0) return chalk2.gray("\u2014");
449
+ if (typeof value === "number") {
450
+ if (Math.abs(value) <= 1 && value !== 0) {
451
+ return (value * 100).toFixed(1) + "%";
452
+ }
453
+ if (Number.isInteger(value) && value > 999) {
454
+ return value.toLocaleString();
455
+ }
456
+ return value.toFixed(1);
457
+ }
458
+ return String(value);
459
+ }
460
+
461
+ // src/formatters/csv.ts
462
+ import { stringify } from "csv-stringify/sync";
463
+ function formatCsv(data, meta) {
464
+ if (data.length === 0) {
465
+ return { raw: data, formatted: "", meta };
466
+ }
467
+ const columns = Object.keys(data[0]);
468
+ const formatted = stringify(data, { header: true, columns });
469
+ return { raw: data, formatted, meta };
470
+ }
471
+
472
+ // src/formatters/markdown.ts
473
+ function formatMarkdown(data, meta) {
474
+ if (data.length === 0) {
475
+ return { raw: data, formatted: "*No data found.*\n", meta };
476
+ }
477
+ const columns = Object.keys(data[0]);
478
+ const header = "| " + columns.join(" | ") + " |";
479
+ const separator = "| " + columns.map(() => "---").join(" | ") + " |";
480
+ const rows = data.map(
481
+ (row) => "| " + columns.map((col) => formatMdCell(row[col])).join(" | ") + " |"
482
+ );
483
+ const footer = `
484
+ *${meta.sampleSize} rows \xB7 Source: ${meta.source} \xB7 Season ${meta.season}*`;
485
+ return {
486
+ raw: data,
487
+ formatted: [header, separator, ...rows, footer].join("\n") + "\n",
488
+ meta
489
+ };
490
+ }
491
+ function formatMdCell(value) {
492
+ if (value === null || value === void 0) return "\u2014";
493
+ if (typeof value === "number") {
494
+ if (Math.abs(value) <= 1 && value !== 0) return (value * 100).toFixed(1) + "%";
495
+ if (Number.isInteger(value)) return String(value);
496
+ return value.toFixed(1);
497
+ }
498
+ return String(value);
499
+ }
500
+
501
+ // src/formatters/index.ts
502
+ function format(data, meta, outputFormat, options) {
503
+ switch (outputFormat) {
504
+ case "json":
505
+ return formatJson(data, meta);
506
+ case "table":
507
+ return formatTable(data, meta, options);
508
+ case "csv":
509
+ return formatCsv(data, meta);
510
+ case "markdown":
511
+ return formatMarkdown(data, meta);
512
+ }
513
+ }
514
+
515
+ // src/config/config.ts
516
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
517
+ import { join } from "path";
518
+ import { homedir } from "os";
519
+
520
+ // src/config/defaults.ts
521
+ import { z } from "zod";
522
+ var ConfigSchema = z.object({
523
+ defaultTeam: z.string().optional(),
524
+ defaultFormat: z.enum(["json", "table", "csv", "markdown"]).default("json"),
525
+ defaultAudience: z.enum(["coach", "gm", "scout", "analyst"]).default("analyst"),
526
+ cache: z.object({
527
+ enabled: z.boolean().default(true),
528
+ maxAgeDays: z.number().default(30),
529
+ directory: z.string().default("")
530
+ }).default({}),
531
+ templates: z.object({
532
+ directory: z.string().default("")
533
+ }).default({}),
534
+ sources: z.object({
535
+ savant: z.object({ enabled: z.boolean().default(true) }).default({}),
536
+ fangraphs: z.object({ enabled: z.boolean().default(true) }).default({}),
537
+ mlbStatsApi: z.object({ enabled: z.boolean().default(true) }).default({}),
538
+ baseballReference: z.object({ enabled: z.boolean().default(false) }).default({})
539
+ }).default({})
540
+ });
541
+ function getDefaultConfig() {
542
+ return ConfigSchema.parse({});
543
+ }
544
+
545
+ // src/config/config.ts
546
+ var BBDATA_DIR = join(homedir(), ".bbdata");
547
+ var CONFIG_PATH = join(BBDATA_DIR, "config.json");
548
+ function ensureDir(dir) {
549
+ if (!existsSync(dir)) {
550
+ mkdirSync(dir, { recursive: true });
551
+ }
552
+ }
553
+ function getConfig() {
554
+ ensureDir(BBDATA_DIR);
555
+ if (!existsSync(CONFIG_PATH)) {
556
+ const defaults = getDefaultConfig();
557
+ writeFileSync(CONFIG_PATH, JSON.stringify(defaults, null, 2), "utf-8");
558
+ return defaults;
559
+ }
560
+ try {
561
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
562
+ return ConfigSchema.parse(raw);
563
+ } catch {
564
+ return getDefaultConfig();
565
+ }
566
+ }
567
+ function setConfig(updates) {
568
+ const current = getConfig();
569
+ const merged = { ...current, ...updates };
570
+ const validated = ConfigSchema.parse(merged);
571
+ ensureDir(BBDATA_DIR);
572
+ writeFileSync(CONFIG_PATH, JSON.stringify(validated, null, 2), "utf-8");
573
+ return validated;
574
+ }
575
+ function getTemplatesDir() {
576
+ const config = getConfig();
577
+ const dir = config.templates.directory || join(BBDATA_DIR, "templates");
578
+ ensureDir(dir);
579
+ return dir;
580
+ }
581
+
582
+ // src/templates/queries/registry.ts
583
+ var templates = /* @__PURE__ */ new Map();
584
+ function registerTemplate(template13) {
585
+ templates.set(template13.id, template13);
586
+ }
587
+ function getTemplate(id) {
588
+ return templates.get(id);
589
+ }
590
+ function getAllTemplates() {
591
+ return Array.from(templates.values());
592
+ }
593
+ function listTemplates() {
594
+ return getAllTemplates().map((t) => ({
595
+ id: t.id,
596
+ name: t.name,
597
+ category: t.category,
598
+ description: t.description
599
+ }));
600
+ }
601
+
602
+ // src/adapters/types.ts
603
+ import { z as z2 } from "zod";
604
+ var PitchDataSchema = z2.object({
605
+ pitcher_id: z2.string(),
606
+ pitcher_name: z2.string(),
607
+ batter_id: z2.string(),
608
+ batter_name: z2.string(),
609
+ game_date: z2.string(),
610
+ pitch_type: z2.string(),
611
+ // FF, SL, CH, CU, SI, FC, KC, FS, etc.
612
+ release_speed: z2.number(),
613
+ // mph
614
+ release_spin_rate: z2.number(),
615
+ // rpm
616
+ pfx_x: z2.number(),
617
+ // horizontal movement (inches)
618
+ pfx_z: z2.number(),
619
+ // vertical movement (inches)
620
+ plate_x: z2.number(),
621
+ // horizontal location
622
+ plate_z: z2.number(),
623
+ // vertical location
624
+ launch_speed: z2.number().nullable(),
625
+ // exit velocity (mph)
626
+ launch_angle: z2.number().nullable(),
627
+ // degrees
628
+ description: z2.string(),
629
+ // called_strike, swinging_strike, ball, foul, hit_into_play, etc.
630
+ events: z2.string().nullable(),
631
+ // single, double, home_run, strikeout, etc.
632
+ bb_type: z2.string().nullable(),
633
+ // fly_ball, ground_ball, line_drive, popup
634
+ stand: z2.enum(["L", "R"]),
635
+ // batter handedness
636
+ p_throws: z2.enum(["L", "R"]),
637
+ // pitcher handedness
638
+ estimated_ba: z2.number().nullable(),
639
+ // xBA
640
+ estimated_woba: z2.number().nullable()
641
+ // xwOBA
642
+ });
643
+ var PlayerStatsSchema = z2.object({
644
+ player_id: z2.string(),
645
+ player_name: z2.string(),
646
+ team: z2.string(),
647
+ season: z2.number(),
648
+ stat_type: z2.enum(["batting", "pitching", "fielding"]),
649
+ stats: z2.record(z2.union([z2.number(), z2.string(), z2.null()]))
650
+ });
651
+ var PITCH_TYPE_MAP = {
652
+ FF: "Four-Seam Fastball",
653
+ SI: "Sinker",
654
+ FC: "Cutter",
655
+ SL: "Slider",
656
+ CU: "Curveball",
657
+ KC: "Knuckle Curve",
658
+ CH: "Changeup",
659
+ FS: "Splitter",
660
+ KN: "Knuckleball",
661
+ EP: "Eephus",
662
+ SC: "Screwball",
663
+ ST: "Sweeper",
664
+ SV: "Slurve"
665
+ };
666
+ function pitchTypeName(code) {
667
+ return PITCH_TYPE_MAP[code.toUpperCase()] ?? code;
668
+ }
669
+
670
+ // src/templates/queries/pitcher-arsenal.ts
671
+ var template = {
672
+ id: "pitcher-arsenal",
673
+ name: "Pitcher Arsenal Profile",
674
+ category: "pitcher",
675
+ description: "Pitch usage rates, velocity, spin, movement, and whiff rates by pitch type",
676
+ preferredSources: ["savant", "fangraphs", "mlb-stats-api"],
677
+ requiredParams: ["player"],
678
+ optionalParams: ["season", "pitchType"],
679
+ examples: [
680
+ 'bbdata query pitcher-arsenal --player "Corbin Burnes" --season 2025',
681
+ 'bbdata query pitcher-arsenal --player "Spencer Strider"'
682
+ ],
683
+ buildQuery(params) {
684
+ return {
685
+ player_name: params.player,
686
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
687
+ stat_type: "pitching",
688
+ pitch_type: params.pitchType ? [params.pitchType] : void 0
689
+ };
690
+ },
691
+ columns() {
692
+ return [
693
+ "Pitch Type",
694
+ "Usage %",
695
+ "Avg Velo",
696
+ "Avg Spin",
697
+ "H Break",
698
+ "V Break",
699
+ "Whiff %",
700
+ "Put Away %",
701
+ "Pitches"
702
+ ];
703
+ },
704
+ transform(data, _params) {
705
+ const pitches = data;
706
+ if (pitches.length === 0) return [];
707
+ const byType = /* @__PURE__ */ new Map();
708
+ for (const pitch of pitches) {
709
+ if (!pitch.pitch_type) continue;
710
+ const group = byType.get(pitch.pitch_type) ?? [];
711
+ group.push(pitch);
712
+ byType.set(pitch.pitch_type, group);
713
+ }
714
+ const total = pitches.length;
715
+ return Array.from(byType.entries()).map(([type, group]) => {
716
+ const count = group.length;
717
+ const swings = group.filter(
718
+ (p) => p.description.includes("swing") || p.description.includes("foul")
719
+ );
720
+ const whiffs = group.filter(
721
+ (p) => p.description.includes("swinging_strike")
722
+ );
723
+ const twoStrikes = group.filter(
724
+ (p) => p.description.includes("strikeout") || p.description.includes("swinging_strike")
725
+ );
726
+ return {
727
+ "Pitch Type": pitchTypeName(type),
728
+ "Usage %": (count / total * 100).toFixed(1) + "%",
729
+ "Avg Velo": (group.reduce((s, p) => s + p.release_speed, 0) / count).toFixed(1) + " mph",
730
+ "Avg Spin": Math.round(group.reduce((s, p) => s + p.release_spin_rate, 0) / count) + " rpm",
731
+ "H Break": (group.reduce((s, p) => s + p.pfx_x, 0) / count).toFixed(1) + " in",
732
+ "V Break": (group.reduce((s, p) => s + p.pfx_z, 0) / count).toFixed(1) + " in",
733
+ "Whiff %": swings.length > 0 ? (whiffs.length / swings.length * 100).toFixed(1) + "%" : "\u2014",
734
+ "Put Away %": twoStrikes.length > 0 ? (whiffs.length / count * 100).toFixed(1) + "%" : "\u2014",
735
+ "Pitches": count
736
+ };
737
+ }).sort((a, b) => b.Pitches - a.Pitches);
738
+ }
739
+ };
740
+ registerTemplate(template);
741
+
742
+ // src/templates/queries/pitcher-velocity-trend.ts
743
+ var template2 = {
744
+ id: "pitcher-velocity-trend",
745
+ name: "Pitcher Velocity Trend",
746
+ category: "pitcher",
747
+ description: "Month-by-month fastball velocity tracking \u2014 flags drops > 0.5 mph",
748
+ preferredSources: ["savant"],
749
+ requiredParams: ["player"],
750
+ optionalParams: ["season"],
751
+ examples: [
752
+ 'bbdata query pitcher-velocity-trend --player "Gerrit Cole" --season 2025'
753
+ ],
754
+ buildQuery(params) {
755
+ return {
756
+ player_name: params.player,
757
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
758
+ stat_type: "pitching"
759
+ };
760
+ },
761
+ columns() {
762
+ return ["Month", "Avg Velo", "Max Velo", "Min Velo", "\u0394 vs Prior", "Pitches", "Flag"];
763
+ },
764
+ transform(data) {
765
+ const pitches = data.filter(
766
+ (p) => ["FF", "SI", "FC"].includes(p.pitch_type) && p.release_speed > 0
767
+ );
768
+ if (pitches.length === 0) return [];
769
+ const byMonth = /* @__PURE__ */ new Map();
770
+ for (const pitch of pitches) {
771
+ const month = pitch.game_date.slice(0, 7);
772
+ const group = byMonth.get(month) ?? [];
773
+ group.push(pitch);
774
+ byMonth.set(month, group);
775
+ }
776
+ const months = Array.from(byMonth.entries()).sort(([a], [b]) => a.localeCompare(b));
777
+ let prevAvg = null;
778
+ return months.map(([month, group]) => {
779
+ const velos = group.map((p) => p.release_speed);
780
+ const avg = velos.reduce((s, v) => s + v, 0) / velos.length;
781
+ const max = Math.max(...velos);
782
+ const min = Math.min(...velos);
783
+ const delta = prevAvg !== null ? avg - prevAvg : 0;
784
+ const flag = prevAvg !== null && delta < -0.5 ? "\u26A0 DROP" : "";
785
+ prevAvg = avg;
786
+ return {
787
+ Month: month,
788
+ "Avg Velo": avg.toFixed(1) + " mph",
789
+ "Max Velo": max.toFixed(1) + " mph",
790
+ "Min Velo": min.toFixed(1) + " mph",
791
+ "\u0394 vs Prior": prevAvg !== null && delta !== 0 ? (delta > 0 ? "+" : "") + delta.toFixed(1) + " mph" : "\u2014",
792
+ Pitches: group.length,
793
+ Flag: flag
794
+ };
795
+ });
796
+ }
797
+ };
798
+ registerTemplate(template2);
799
+
800
+ // src/templates/queries/pitcher-handedness-splits.ts
801
+ var template3 = {
802
+ id: "pitcher-handedness-splits",
803
+ name: "Pitcher Handedness Splits",
804
+ category: "pitcher",
805
+ description: "Performance splits vs LHH and RHH \u2014 BA, SLG, K%, BB%, pitch mix, exit velocity",
806
+ preferredSources: ["savant", "fangraphs"],
807
+ requiredParams: ["player"],
808
+ optionalParams: ["season"],
809
+ examples: [
810
+ 'bbdata query pitcher-handedness-splits --player "Blake Snell" --season 2025'
811
+ ],
812
+ buildQuery(params) {
813
+ return {
814
+ player_name: params.player,
815
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
816
+ stat_type: "pitching"
817
+ };
818
+ },
819
+ columns() {
820
+ return ["vs", "PA", "AVG", "SLG", "K %", "BB %", "Avg EV", "Whiff %"];
821
+ },
822
+ transform(data) {
823
+ const pitches = data;
824
+ if (pitches.length === 0) return [];
825
+ return ["L", "R"].map((hand) => {
826
+ const group = pitches.filter((p) => p.stand === hand);
827
+ if (group.length === 0) {
828
+ return { vs: `vs ${hand}HH`, PA: 0, AVG: "\u2014", SLG: "\u2014", "K %": "\u2014", "BB %": "\u2014", "Avg EV": "\u2014", "Whiff %": "\u2014" };
829
+ }
830
+ const pas = group.filter((p) => p.events !== null);
831
+ const hits = pas.filter(
832
+ (p) => ["single", "double", "triple", "home_run"].includes(p.events ?? "")
833
+ );
834
+ const strikeouts = pas.filter((p) => p.events === "strikeout");
835
+ const walks = pas.filter((p) => ["walk", "hit_by_pitch"].includes(p.events ?? ""));
836
+ const contacted = group.filter((p) => p.launch_speed !== null && p.launch_speed > 0);
837
+ const avgEv = contacted.length > 0 ? contacted.reduce((s, p) => s + (p.launch_speed ?? 0), 0) / contacted.length : null;
838
+ const totalBases = pas.reduce((sum, p) => {
839
+ if (p.events === "single") return sum + 1;
840
+ if (p.events === "double") return sum + 2;
841
+ if (p.events === "triple") return sum + 3;
842
+ if (p.events === "home_run") return sum + 4;
843
+ return sum;
844
+ }, 0);
845
+ const swings = group.filter(
846
+ (p) => p.description.includes("swing") || p.description.includes("foul")
847
+ );
848
+ const whiffs = group.filter((p) => p.description.includes("swinging_strike"));
849
+ return {
850
+ vs: `vs ${hand}HH`,
851
+ PA: pas.length,
852
+ AVG: pas.length > 0 ? (hits.length / pas.length).toFixed(3) : "\u2014",
853
+ SLG: pas.length > 0 ? (totalBases / pas.length).toFixed(3) : "\u2014",
854
+ "K %": pas.length > 0 ? (strikeouts.length / pas.length * 100).toFixed(1) + "%" : "\u2014",
855
+ "BB %": pas.length > 0 ? (walks.length / pas.length * 100).toFixed(1) + "%" : "\u2014",
856
+ "Avg EV": avgEv !== null ? avgEv.toFixed(1) + " mph" : "\u2014",
857
+ "Whiff %": swings.length > 0 ? (whiffs.length / swings.length * 100).toFixed(1) + "%" : "\u2014"
858
+ };
859
+ });
860
+ }
861
+ };
862
+ registerTemplate(template3);
863
+
864
+ // src/templates/queries/hitter-batted-ball.ts
865
+ var template4 = {
866
+ id: "hitter-batted-ball",
867
+ name: "Hitter Batted Ball Profile",
868
+ category: "hitter",
869
+ description: "Exit velocity, launch angle, hard hit rate, barrel rate, batted ball distribution",
870
+ preferredSources: ["savant", "fangraphs"],
871
+ requiredParams: ["player"],
872
+ optionalParams: ["season"],
873
+ examples: [
874
+ 'bbdata query hitter-batted-ball --player "Aaron Judge" --season 2025',
875
+ 'bbdata query hitter-batted-ball --player "Juan Soto"'
876
+ ],
877
+ buildQuery(params) {
878
+ return {
879
+ player_name: params.player,
880
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
881
+ stat_type: "batting"
882
+ };
883
+ },
884
+ columns() {
885
+ return ["Metric", "Value"];
886
+ },
887
+ transform(data) {
888
+ const pitches = data;
889
+ const batted = pitches.filter((p) => p.launch_speed !== null && p.launch_speed > 0);
890
+ if (batted.length === 0) return [];
891
+ const evs = batted.map((p) => p.launch_speed);
892
+ const las = batted.map((p) => p.launch_angle);
893
+ const avgEv = evs.reduce((s, v) => s + v, 0) / evs.length;
894
+ const avgLa = las.reduce((s, v) => s + v, 0) / las.length;
895
+ const hardHit = batted.filter((p) => p.launch_speed >= 95).length;
896
+ const barrels = batted.filter(
897
+ (p) => p.launch_speed >= 98 && p.launch_angle >= 26 && p.launch_angle <= 30
898
+ ).length;
899
+ const linedrives = batted.filter((p) => p.bb_type === "line_drive").length;
900
+ const flyballs = batted.filter((p) => p.bb_type === "fly_ball").length;
901
+ const groundballs = batted.filter((p) => p.bb_type === "ground_ball").length;
902
+ const popups = batted.filter((p) => p.bb_type === "popup").length;
903
+ return [
904
+ { Metric: "Batted Balls", Value: batted.length },
905
+ { Metric: "Avg Exit Velocity", Value: avgEv.toFixed(1) + " mph" },
906
+ { Metric: "Max Exit Velocity", Value: Math.max(...evs).toFixed(1) + " mph" },
907
+ { Metric: "Avg Launch Angle", Value: avgLa.toFixed(1) + "\xB0" },
908
+ { Metric: "Hard Hit Rate (95+ mph)", Value: (hardHit / batted.length * 100).toFixed(1) + "%" },
909
+ { Metric: "Barrel Rate", Value: (barrels / batted.length * 100).toFixed(1) + "%" },
910
+ { Metric: "Line Drive %", Value: (linedrives / batted.length * 100).toFixed(1) + "%" },
911
+ { Metric: "Fly Ball %", Value: (flyballs / batted.length * 100).toFixed(1) + "%" },
912
+ { Metric: "Ground Ball %", Value: (groundballs / batted.length * 100).toFixed(1) + "%" },
913
+ { Metric: "Popup %", Value: (popups / batted.length * 100).toFixed(1) + "%" }
914
+ ];
915
+ }
916
+ };
917
+ registerTemplate(template4);
918
+
919
+ // src/templates/queries/hitter-vs-pitch-type.ts
920
+ var template5 = {
921
+ id: "hitter-vs-pitch-type",
922
+ name: "Hitter vs Pitch Type",
923
+ category: "hitter",
924
+ description: "Swing rate, whiff rate, exit velocity, and outcomes by pitch type faced",
925
+ preferredSources: ["savant"],
926
+ requiredParams: ["player"],
927
+ optionalParams: ["season"],
928
+ examples: [
929
+ 'bbdata query hitter-vs-pitch-type --player "Mookie Betts" --season 2025'
930
+ ],
931
+ buildQuery(params) {
932
+ return {
933
+ player_name: params.player,
934
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
935
+ stat_type: "batting"
936
+ };
937
+ },
938
+ columns() {
939
+ return ["Pitch Type", "Seen", "Swing %", "Whiff %", "Foul %", "In Play", "Avg EV", "SLG"];
940
+ },
941
+ transform(data) {
942
+ const pitches = data;
943
+ if (pitches.length === 0) return [];
944
+ const byType = /* @__PURE__ */ new Map();
945
+ for (const p of pitches) {
946
+ if (!p.pitch_type) continue;
947
+ const group = byType.get(p.pitch_type) ?? [];
948
+ group.push(p);
949
+ byType.set(p.pitch_type, group);
950
+ }
951
+ return Array.from(byType.entries()).map(([type, group]) => {
952
+ const swings = group.filter(
953
+ (p) => p.description.includes("swing") || p.description.includes("foul") || p.description.includes("hit_into_play")
954
+ );
955
+ const whiffs = group.filter((p) => p.description.includes("swinging_strike"));
956
+ const fouls = group.filter((p) => p.description.includes("foul"));
957
+ const inPlay = group.filter((p) => p.description.includes("hit_into_play"));
958
+ const contacted = group.filter((p) => p.launch_speed !== null && p.launch_speed > 0);
959
+ const totalBases = group.reduce((sum, p) => {
960
+ if (p.events === "single") return sum + 1;
961
+ if (p.events === "double") return sum + 2;
962
+ if (p.events === "triple") return sum + 3;
963
+ if (p.events === "home_run") return sum + 4;
964
+ return sum;
965
+ }, 0);
966
+ const abs = group.filter((p) => p.events && !["walk", "hit_by_pitch", "sac_fly", "sac_bunt"].includes(p.events)).length;
967
+ return {
968
+ "Pitch Type": pitchTypeName(type),
969
+ Seen: group.length,
970
+ "Swing %": (swings.length / group.length * 100).toFixed(1) + "%",
971
+ "Whiff %": swings.length > 0 ? (whiffs.length / swings.length * 100).toFixed(1) + "%" : "\u2014",
972
+ "Foul %": swings.length > 0 ? (fouls.length / swings.length * 100).toFixed(1) + "%" : "\u2014",
973
+ "In Play": inPlay.length,
974
+ "Avg EV": contacted.length > 0 ? (contacted.reduce((s, p) => s + p.launch_speed, 0) / contacted.length).toFixed(1) + " mph" : "\u2014",
975
+ SLG: abs > 0 ? (totalBases / abs).toFixed(3) : "\u2014"
976
+ };
977
+ }).sort((a, b) => b.Seen - a.Seen);
978
+ }
979
+ };
980
+ registerTemplate(template5);
981
+
982
+ // src/templates/queries/hitter-hot-cold-zones.ts
983
+ var ZONES = {
984
+ "High-In": { xMin: -0.83, xMax: -0.28, zMin: 2.83, zMax: 3.5 },
985
+ "High-Mid": { xMin: -0.28, xMax: 0.28, zMin: 2.83, zMax: 3.5 },
986
+ "High-Out": { xMin: 0.28, xMax: 0.83, zMin: 2.83, zMax: 3.5 },
987
+ "Mid-In": { xMin: -0.83, xMax: -0.28, zMin: 2.17, zMax: 2.83 },
988
+ "Mid-Mid": { xMin: -0.28, xMax: 0.28, zMin: 2.17, zMax: 2.83 },
989
+ "Mid-Out": { xMin: 0.28, xMax: 0.83, zMin: 2.17, zMax: 2.83 },
990
+ "Low-In": { xMin: -0.83, xMax: -0.28, zMin: 1.5, zMax: 2.17 },
991
+ "Low-Mid": { xMin: -0.28, xMax: 0.28, zMin: 1.5, zMax: 2.17 },
992
+ "Low-Out": { xMin: 0.28, xMax: 0.83, zMin: 1.5, zMax: 2.17 }
993
+ };
994
+ var template6 = {
995
+ id: "hitter-hot-cold-zones",
996
+ name: "Hitter Hot/Cold Zones",
997
+ category: "hitter",
998
+ description: "3x3 strike zone grid with BA, SLG, whiff rate, and pitch count per zone",
999
+ preferredSources: ["savant"],
1000
+ requiredParams: ["player"],
1001
+ optionalParams: ["season"],
1002
+ examples: [
1003
+ 'bbdata query hitter-hot-cold-zones --player "Shohei Ohtani" --season 2025'
1004
+ ],
1005
+ buildQuery(params) {
1006
+ return {
1007
+ player_name: params.player,
1008
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1009
+ stat_type: "batting"
1010
+ };
1011
+ },
1012
+ columns() {
1013
+ return ["Zone", "Pitches", "Swings", "Whiff %", "AVG", "SLG"];
1014
+ },
1015
+ transform(data) {
1016
+ const pitches = data;
1017
+ if (pitches.length === 0) return [];
1018
+ return Object.entries(ZONES).map(([zoneName, bounds]) => {
1019
+ const inZone = pitches.filter(
1020
+ (p) => p.plate_x >= bounds.xMin && p.plate_x < bounds.xMax && p.plate_z >= bounds.zMin && p.plate_z < bounds.zMax
1021
+ );
1022
+ const swings = inZone.filter(
1023
+ (p) => p.description.includes("swing") || p.description.includes("foul") || p.description.includes("hit_into_play")
1024
+ );
1025
+ const whiffs = inZone.filter((p) => p.description.includes("swinging_strike"));
1026
+ const abs = inZone.filter((p) => p.events && !["walk", "hit_by_pitch"].includes(p.events));
1027
+ const hits = abs.filter(
1028
+ (p) => ["single", "double", "triple", "home_run"].includes(p.events ?? "")
1029
+ );
1030
+ const totalBases = abs.reduce((sum, p) => {
1031
+ if (p.events === "single") return sum + 1;
1032
+ if (p.events === "double") return sum + 2;
1033
+ if (p.events === "triple") return sum + 3;
1034
+ if (p.events === "home_run") return sum + 4;
1035
+ return sum;
1036
+ }, 0);
1037
+ return {
1038
+ Zone: zoneName,
1039
+ Pitches: inZone.length,
1040
+ Swings: swings.length,
1041
+ "Whiff %": swings.length > 0 ? (whiffs.length / swings.length * 100).toFixed(1) + "%" : "\u2014",
1042
+ AVG: abs.length > 0 ? (hits.length / abs.length).toFixed(3) : "\u2014",
1043
+ SLG: abs.length > 0 ? (totalBases / abs.length).toFixed(3) : "\u2014"
1044
+ };
1045
+ });
1046
+ }
1047
+ };
1048
+ registerTemplate(template6);
1049
+
1050
+ // src/templates/queries/matchup-pitcher-vs-hitter.ts
1051
+ var template7 = {
1052
+ id: "matchup-pitcher-vs-hitter",
1053
+ name: "Pitcher vs Hitter Matchup",
1054
+ category: "matchup",
1055
+ description: "Career head-to-head history \u2014 PA, H, HR, BB, K, BA, SLG, most common pitches",
1056
+ preferredSources: ["savant"],
1057
+ requiredParams: ["players"],
1058
+ // expects [pitcher, hitter]
1059
+ optionalParams: ["season"],
1060
+ examples: [
1061
+ 'bbdata query matchup-pitcher-vs-hitter --players "Gerrit Cole,Aaron Judge"'
1062
+ ],
1063
+ buildQuery(params) {
1064
+ return {
1065
+ player_name: params.players?.[0],
1066
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1067
+ stat_type: "pitching"
1068
+ };
1069
+ },
1070
+ columns() {
1071
+ return ["Metric", "Value"];
1072
+ },
1073
+ transform(data, params) {
1074
+ const pitches = data;
1075
+ const hitterName = (params.players?.[1] ?? "").toLowerCase();
1076
+ const matchup = pitches.filter(
1077
+ (p) => p.batter_name.toLowerCase().includes(hitterName)
1078
+ );
1079
+ if (matchup.length === 0) {
1080
+ return [{ Metric: "Note", Value: `No matchup data found for ${params.players?.[1] ?? "hitter"}` }];
1081
+ }
1082
+ const pas = matchup.filter((p) => p.events !== null);
1083
+ const hits = pas.filter((p) => ["single", "double", "triple", "home_run"].includes(p.events ?? ""));
1084
+ const hrs = pas.filter((p) => p.events === "home_run");
1085
+ const ks = pas.filter((p) => p.events === "strikeout");
1086
+ const bbs = pas.filter((p) => ["walk", "hit_by_pitch"].includes(p.events ?? ""));
1087
+ const totalBases = pas.reduce((sum, p) => {
1088
+ if (p.events === "single") return sum + 1;
1089
+ if (p.events === "double") return sum + 2;
1090
+ if (p.events === "triple") return sum + 3;
1091
+ if (p.events === "home_run") return sum + 4;
1092
+ return sum;
1093
+ }, 0);
1094
+ const pitchCounts = /* @__PURE__ */ new Map();
1095
+ for (const p of matchup) {
1096
+ pitchCounts.set(p.pitch_type, (pitchCounts.get(p.pitch_type) ?? 0) + 1);
1097
+ }
1098
+ const topPitches = Array.from(pitchCounts.entries()).sort(([, a], [, b]) => b - a).slice(0, 3).map(([type, count]) => `${pitchTypeName(type)} (${count})`).join(", ");
1099
+ return [
1100
+ { Metric: "Total Pitches", Value: matchup.length },
1101
+ { Metric: "Plate Appearances", Value: pas.length },
1102
+ { Metric: "Hits", Value: hits.length },
1103
+ { Metric: "Home Runs", Value: hrs.length },
1104
+ { Metric: "Strikeouts", Value: ks.length },
1105
+ { Metric: "Walks", Value: bbs.length },
1106
+ { Metric: "AVG", Value: pas.length > 0 ? (hits.length / pas.length).toFixed(3) : "\u2014" },
1107
+ { Metric: "SLG", Value: pas.length > 0 ? (totalBases / pas.length).toFixed(3) : "\u2014" },
1108
+ { Metric: "Most Common Pitches", Value: topPitches }
1109
+ ];
1110
+ }
1111
+ };
1112
+ registerTemplate(template7);
1113
+
1114
+ // src/templates/queries/matchup-situational.ts
1115
+ var template8 = {
1116
+ id: "matchup-situational",
1117
+ name: "Situational Splits",
1118
+ category: "matchup",
1119
+ description: "Performance in key situations \u2014 RISP, high leverage, close & late, by inning",
1120
+ preferredSources: ["fangraphs", "mlb-stats-api"],
1121
+ requiredParams: ["player"],
1122
+ optionalParams: ["season"],
1123
+ examples: [
1124
+ 'bbdata query matchup-situational --player "Juan Soto" --season 2025'
1125
+ ],
1126
+ buildQuery(params) {
1127
+ return {
1128
+ player_name: params.player,
1129
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1130
+ stat_type: "batting"
1131
+ };
1132
+ },
1133
+ columns() {
1134
+ return ["Situation", "PA", "AVG", "OBP", "SLG", "K %", "BB %"];
1135
+ },
1136
+ transform(data) {
1137
+ const stats = data;
1138
+ if (stats.length === 0) return [];
1139
+ const player = stats[0];
1140
+ const s = player.stats;
1141
+ return [
1142
+ {
1143
+ Situation: "Overall",
1144
+ PA: s.plateAppearances ?? s.PA ?? "\u2014",
1145
+ AVG: formatStat(s.avg ?? s.AVG),
1146
+ OBP: formatStat(s.obp ?? s.OBP),
1147
+ SLG: formatStat(s.slg ?? s.SLG),
1148
+ "K %": formatPct(s.strikeOuts ?? s.SO, s.plateAppearances ?? s.PA),
1149
+ "BB %": formatPct(s.baseOnBalls ?? s.BB, s.plateAppearances ?? s.PA)
1150
+ }
1151
+ ];
1152
+ }
1153
+ };
1154
+ function formatStat(val) {
1155
+ if (val === null || val === void 0) return "\u2014";
1156
+ const n = Number(val);
1157
+ if (isNaN(n)) return String(val);
1158
+ return n < 1 ? n.toFixed(3) : n.toFixed(1);
1159
+ }
1160
+ function formatPct(num, denom) {
1161
+ const n = Number(num);
1162
+ const d = Number(denom);
1163
+ if (isNaN(n) || isNaN(d) || d === 0) return "\u2014";
1164
+ return (n / d * 100).toFixed(1) + "%";
1165
+ }
1166
+ registerTemplate(template8);
1167
+
1168
+ // src/templates/queries/leaderboard-custom.ts
1169
+ var template9 = {
1170
+ id: "leaderboard-custom",
1171
+ name: "Custom Leaderboard",
1172
+ category: "leaderboard",
1173
+ description: "Top N players by any metric \u2014 with minimum qualification thresholds",
1174
+ preferredSources: ["fangraphs", "mlb-stats-api"],
1175
+ requiredParams: ["stat"],
1176
+ optionalParams: ["season", "team", "top", "minPa", "minIp"],
1177
+ examples: [
1178
+ "bbdata query leaderboard-custom --stat barrel_rate --min-pa 200 --top 20",
1179
+ "bbdata query leaderboard-custom --stat ERA --min-ip 100 --top 10 --format table"
1180
+ ],
1181
+ buildQuery(params) {
1182
+ return {
1183
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1184
+ team: params.team,
1185
+ stat_type: "batting",
1186
+ // Will be overridden based on stat
1187
+ min_pa: params.minPa,
1188
+ min_ip: params.minIp
1189
+ };
1190
+ },
1191
+ columns(params) {
1192
+ return ["Rank", "Player", "Team", params.stat ?? "Stat", "PA/IP"];
1193
+ },
1194
+ transform(data, params) {
1195
+ const stats = data;
1196
+ if (stats.length === 0) return [];
1197
+ const statKey = params.stat ?? "";
1198
+ const top = params.top ?? 20;
1199
+ const withStat = stats.map((player) => {
1200
+ const value = findStat(player.stats, statKey);
1201
+ return { player, value };
1202
+ }).filter((p) => p.value !== null).sort((a, b) => {
1203
+ const ascending = ["era", "fip", "xfip", "siera", "whip", "bb%"].includes(
1204
+ statKey.toLowerCase()
1205
+ );
1206
+ return ascending ? a.value - b.value : b.value - a.value;
1207
+ }).slice(0, top);
1208
+ return withStat.map((entry, idx) => ({
1209
+ Rank: idx + 1,
1210
+ Player: entry.player.player_name,
1211
+ Team: entry.player.team,
1212
+ [statKey]: typeof entry.value === "number" ? entry.value < 1 && entry.value > 0 ? entry.value.toFixed(3) : entry.value.toFixed(1) : String(entry.value),
1213
+ "PA/IP": entry.player.stats.plateAppearances ?? entry.player.stats.PA ?? entry.player.stats.inningsPitched ?? entry.player.stats.IP ?? "\u2014"
1214
+ }));
1215
+ }
1216
+ };
1217
+ function findStat(stats, key) {
1218
+ if (key in stats) {
1219
+ const val = Number(stats[key]);
1220
+ return isNaN(val) ? null : val;
1221
+ }
1222
+ const lower = key.toLowerCase();
1223
+ for (const [k, v] of Object.entries(stats)) {
1224
+ if (k.toLowerCase() === lower || k.toLowerCase().replace(/[_%]/g, "") === lower.replace(/[_%]/g, "")) {
1225
+ const val = Number(v);
1226
+ return isNaN(val) ? null : val;
1227
+ }
1228
+ }
1229
+ return null;
1230
+ }
1231
+ registerTemplate(template9);
1232
+
1233
+ // src/templates/queries/leaderboard-comparison.ts
1234
+ var template10 = {
1235
+ id: "leaderboard-comparison",
1236
+ name: "Player Comparison",
1237
+ category: "leaderboard",
1238
+ description: "Side-by-side comparison of multiple players across key metrics vs league average",
1239
+ preferredSources: ["fangraphs", "mlb-stats-api"],
1240
+ requiredParams: ["players"],
1241
+ optionalParams: ["season"],
1242
+ examples: [
1243
+ 'bbdata query leaderboard-comparison --players "Aaron Judge,Juan Soto,Mookie Betts"'
1244
+ ],
1245
+ buildQuery(params) {
1246
+ return {
1247
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1248
+ stat_type: "batting"
1249
+ };
1250
+ },
1251
+ columns(params) {
1252
+ return ["Metric", ...params.players ?? []];
1253
+ },
1254
+ transform(data, params) {
1255
+ const allStats = data;
1256
+ const playerNames = params.players ?? [];
1257
+ const matched = playerNames.map((name) => {
1258
+ const norm = name.toLowerCase();
1259
+ return allStats.find((s) => s.player_name.toLowerCase().includes(norm));
1260
+ });
1261
+ const metrics = [
1262
+ "AVG",
1263
+ "OBP",
1264
+ "SLG",
1265
+ "OPS",
1266
+ "wRC+",
1267
+ "WAR",
1268
+ "HR",
1269
+ "RBI",
1270
+ "K%",
1271
+ "BB%",
1272
+ "ISO",
1273
+ "BABIP"
1274
+ ];
1275
+ return metrics.map((metric) => {
1276
+ const row = { Metric: metric };
1277
+ for (let i = 0; i < playerNames.length; i++) {
1278
+ const player = matched[i];
1279
+ if (!player) {
1280
+ row[playerNames[i]] = "\u2014";
1281
+ continue;
1282
+ }
1283
+ const val = findStatValue(player.stats, metric);
1284
+ row[playerNames[i]] = val ?? "\u2014";
1285
+ }
1286
+ return row;
1287
+ });
1288
+ }
1289
+ };
1290
+ function findStatValue(stats, key) {
1291
+ const lower = key.toLowerCase().replace(/[+%]/g, "");
1292
+ for (const [k, v] of Object.entries(stats)) {
1293
+ if (k.toLowerCase().replace(/[+%]/g, "") === lower) {
1294
+ if (v === null || v === void 0) return null;
1295
+ const n = Number(v);
1296
+ if (isNaN(n)) return String(v);
1297
+ return n < 1 && n > 0 ? n.toFixed(3) : n % 1 === 0 ? String(n) : n.toFixed(1);
1298
+ }
1299
+ }
1300
+ return null;
1301
+ }
1302
+ registerTemplate(template10);
1303
+
1304
+ // src/templates/queries/trend-rolling-average.ts
1305
+ var template11 = {
1306
+ id: "trend-rolling-average",
1307
+ name: "Season Trend (Rolling Average)",
1308
+ category: "trend",
1309
+ description: "15-game (hitters) or 5-start (pitchers) rolling averages \u2014 identifies sustained trends",
1310
+ preferredSources: ["savant"],
1311
+ requiredParams: ["player"],
1312
+ optionalParams: ["season"],
1313
+ examples: [
1314
+ 'bbdata query trend-rolling-average --player "Freddie Freeman" --season 2025'
1315
+ ],
1316
+ buildQuery(params) {
1317
+ return {
1318
+ player_name: params.player,
1319
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1320
+ stat_type: "batting"
1321
+ };
1322
+ },
1323
+ columns() {
1324
+ return ["Window", "Games", "AVG", "SLG", "K %", "Avg EV", "Hard Hit %"];
1325
+ },
1326
+ transform(data) {
1327
+ const pitches = data;
1328
+ if (pitches.length === 0) return [];
1329
+ const byDate = /* @__PURE__ */ new Map();
1330
+ for (const p of pitches) {
1331
+ const group = byDate.get(p.game_date) ?? [];
1332
+ group.push(p);
1333
+ byDate.set(p.game_date, group);
1334
+ }
1335
+ const dates = Array.from(byDate.keys()).sort();
1336
+ const windowSize = 15;
1337
+ if (dates.length < windowSize) {
1338
+ return [{ Window: "Insufficient data", Games: dates.length, AVG: "\u2014", SLG: "\u2014", "K %": "\u2014", "Avg EV": "\u2014", "Hard Hit %": "\u2014" }];
1339
+ }
1340
+ const results = [];
1341
+ for (let i = 0; i <= dates.length - windowSize; i += Math.max(1, Math.floor(windowSize / 3))) {
1342
+ const windowDates = dates.slice(i, i + windowSize);
1343
+ const windowPitches = windowDates.flatMap((d) => byDate.get(d) ?? []);
1344
+ const pas = windowPitches.filter((p) => p.events !== null);
1345
+ const hits = pas.filter((p) => ["single", "double", "triple", "home_run"].includes(p.events ?? ""));
1346
+ const ks = pas.filter((p) => p.events === "strikeout");
1347
+ const totalBases = pas.reduce((sum, p) => {
1348
+ if (p.events === "single") return sum + 1;
1349
+ if (p.events === "double") return sum + 2;
1350
+ if (p.events === "triple") return sum + 3;
1351
+ if (p.events === "home_run") return sum + 4;
1352
+ return sum;
1353
+ }, 0);
1354
+ const batted = windowPitches.filter((p) => p.launch_speed !== null && p.launch_speed > 0);
1355
+ const avgEv = batted.length > 0 ? batted.reduce((s, p) => s + p.launch_speed, 0) / batted.length : null;
1356
+ const hardHit = batted.filter((p) => p.launch_speed >= 95).length;
1357
+ results.push({
1358
+ Window: `${windowDates[0]} \u2192 ${windowDates[windowDates.length - 1]}`,
1359
+ Games: windowDates.length,
1360
+ AVG: pas.length > 0 ? (hits.length / pas.length).toFixed(3) : "\u2014",
1361
+ SLG: pas.length > 0 ? (totalBases / pas.length).toFixed(3) : "\u2014",
1362
+ "K %": pas.length > 0 ? (ks.length / pas.length * 100).toFixed(1) + "%" : "\u2014",
1363
+ "Avg EV": avgEv !== null ? avgEv.toFixed(1) + " mph" : "\u2014",
1364
+ "Hard Hit %": batted.length > 0 ? (hardHit / batted.length * 100).toFixed(1) + "%" : "\u2014"
1365
+ });
1366
+ }
1367
+ return results;
1368
+ }
1369
+ };
1370
+ registerTemplate(template11);
1371
+
1372
+ // src/templates/queries/trend-year-over-year.ts
1373
+ var template12 = {
1374
+ id: "trend-year-over-year",
1375
+ name: "Year-over-Year Comparison",
1376
+ category: "trend",
1377
+ description: "Compare metric changes year to year \u2014 flags changes greater than 10%",
1378
+ preferredSources: ["fangraphs", "mlb-stats-api"],
1379
+ requiredParams: ["player"],
1380
+ optionalParams: ["seasons"],
1381
+ // e.g. "2023-2025"
1382
+ examples: [
1383
+ 'bbdata query trend-year-over-year --player "Julio Rodriguez" --seasons 2023-2025'
1384
+ ],
1385
+ buildQuery(params) {
1386
+ const season = params.season ?? (/* @__PURE__ */ new Date()).getFullYear();
1387
+ return {
1388
+ player_name: params.player,
1389
+ season,
1390
+ stat_type: "batting"
1391
+ };
1392
+ },
1393
+ columns() {
1394
+ return ["Metric", "Prior", "Current", "Change", "Flag"];
1395
+ },
1396
+ transform(data, params) {
1397
+ const stats = data;
1398
+ if (stats.length === 0) return [];
1399
+ const player = stats[0];
1400
+ const s = player.stats;
1401
+ const metrics = ["AVG", "OBP", "SLG", "HR", "wRC+", "K%", "BB%", "WAR", "ISO", "BABIP"];
1402
+ return metrics.map((metric) => {
1403
+ const current = findStat2(s, metric);
1404
+ return {
1405
+ Metric: metric,
1406
+ Prior: "\u2014",
1407
+ Current: current !== null ? formatVal(current) : "\u2014",
1408
+ Change: "\u2014",
1409
+ Flag: ""
1410
+ };
1411
+ });
1412
+ }
1413
+ };
1414
+ function findStat2(stats, key) {
1415
+ const lower = key.toLowerCase().replace(/[+%]/g, "");
1416
+ for (const [k, v] of Object.entries(stats)) {
1417
+ if (k.toLowerCase().replace(/[+%]/g, "") === lower) {
1418
+ const n = Number(v);
1419
+ return isNaN(n) ? null : n;
1420
+ }
1421
+ }
1422
+ return null;
1423
+ }
1424
+ function formatVal(n) {
1425
+ if (n < 1 && n > 0) return n.toFixed(3);
1426
+ return n % 1 === 0 ? String(n) : n.toFixed(1);
1427
+ }
1428
+ registerTemplate(template12);
1429
+
1430
+ // src/commands/query.ts
1431
+ async function query(options) {
1432
+ const config = getConfig();
1433
+ const outputFormat = options.format ?? config.defaultFormat;
1434
+ const template13 = getTemplate(options.template);
1435
+ if (!template13) {
1436
+ const available = listTemplates().map((t) => ` ${t.id} \u2014 ${t.description}`).join("\n");
1437
+ throw new Error(`Unknown template "${options.template}". Available templates:
1438
+ ${available}`);
1439
+ }
1440
+ const params = {
1441
+ player: options.player,
1442
+ players: options.players?.split(",").map((s) => s.trim()),
1443
+ team: options.team,
1444
+ season: options.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1445
+ stat: options.stat,
1446
+ pitchType: options.pitchType,
1447
+ minPa: options.minPa,
1448
+ minIp: options.minIp,
1449
+ top: options.top,
1450
+ seasons: options.seasons
1451
+ };
1452
+ for (const req of template13.requiredParams) {
1453
+ if (!params[req] && !(req === "players" && params.player)) {
1454
+ throw new Error(`Template "${template13.id}" requires --${req}`);
1455
+ }
1456
+ }
1457
+ const adapterQuery = template13.buildQuery(params);
1458
+ const preferredSources = options.source ? [options.source] : template13.preferredSources;
1459
+ const adapters2 = resolveAdapters(preferredSources);
1460
+ let lastError;
1461
+ let result;
1462
+ const startTime = Date.now();
1463
+ for (const adapter of adapters2) {
1464
+ if (!adapter.supports(adapterQuery)) continue;
1465
+ try {
1466
+ log.info(`Querying ${adapter.source}...`);
1467
+ const adapterResult = await adapter.fetch(adapterQuery, {
1468
+ bypassCache: options.cache === false
1469
+ });
1470
+ const rows = template13.transform(adapterResult.data, params);
1471
+ const columns = template13.columns(params);
1472
+ result = {
1473
+ rows,
1474
+ columns,
1475
+ title: template13.name,
1476
+ description: template13.description,
1477
+ source: adapter.source,
1478
+ cached: adapterResult.cached
1479
+ };
1480
+ break;
1481
+ } catch (error) {
1482
+ lastError = error instanceof Error ? error : new Error(String(error));
1483
+ log.debug(`${adapter.source} failed: ${lastError.message}. Trying next source...`);
1484
+ }
1485
+ }
1486
+ if (!result) {
1487
+ throw lastError ?? new Error(`No adapter could fulfill this query`);
1488
+ }
1489
+ const queryTimeMs = Date.now() - startTime;
1490
+ const output = format(result.rows, {
1491
+ source: result.source,
1492
+ cached: result.cached,
1493
+ queryTimeMs,
1494
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear(),
1495
+ sampleSize: result.rows.length,
1496
+ template: template13.id
1497
+ }, outputFormat, { columns: result.columns });
1498
+ return {
1499
+ data: result.rows,
1500
+ formatted: output.formatted,
1501
+ meta: {
1502
+ template: template13.id,
1503
+ source: result.source,
1504
+ cached: result.cached,
1505
+ rowCount: result.rows.length,
1506
+ season: params.season ?? (/* @__PURE__ */ new Date()).getFullYear()
1507
+ }
1508
+ };
1509
+ }
1510
+
1511
+ // src/commands/report.ts
1512
+ import Handlebars from "handlebars";
1513
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
1514
+ import { join as join2, dirname } from "path";
1515
+ import { fileURLToPath } from "url";
1516
+
1517
+ // src/utils/grading.ts
1518
+ import chalk3 from "chalk";
1519
+ function gradeLabel(grade) {
1520
+ if (grade >= 70) return "Plus-Plus";
1521
+ if (grade >= 60) return "Plus";
1522
+ if (grade >= 55) return "Above Average";
1523
+ if (grade >= 50) return "Average";
1524
+ if (grade >= 45) return "Below Average";
1525
+ if (grade >= 40) return "Fringe";
1526
+ if (grade >= 30) return "Poor";
1527
+ return "Non-existent";
1528
+ }
1529
+ function gradeColor(grade) {
1530
+ if (grade >= 70) return chalk3.green.bold(String(grade));
1531
+ if (grade >= 60) return chalk3.green(String(grade));
1532
+ if (grade >= 55) return chalk3.cyan(String(grade));
1533
+ if (grade >= 50) return chalk3.white(String(grade));
1534
+ if (grade >= 45) return chalk3.yellow(String(grade));
1535
+ if (grade >= 40) return chalk3.red(String(grade));
1536
+ return chalk3.red.dim(String(grade));
1537
+ }
1538
+ function formatGrade(grade) {
1539
+ return `${gradeColor(grade)} (${gradeLabel(grade)})`;
1540
+ }
1541
+
1542
+ // src/templates/reports/registry.ts
1543
+ var templates2 = /* @__PURE__ */ new Map();
1544
+ function registerReportTemplate(template13) {
1545
+ templates2.set(template13.id, template13);
1546
+ }
1547
+ function getReportTemplate(id) {
1548
+ return templates2.get(id);
1549
+ }
1550
+ function getAllReportTemplates() {
1551
+ return Array.from(templates2.values());
1552
+ }
1553
+ function listReportTemplates() {
1554
+ return getAllReportTemplates().map((t) => ({
1555
+ id: t.id,
1556
+ name: t.name,
1557
+ category: t.category,
1558
+ description: t.description
1559
+ }));
1560
+ }
1561
+ registerReportTemplate({
1562
+ id: "pro-pitcher-eval",
1563
+ name: "Pro Pitcher Evaluation",
1564
+ category: "pro-scouting",
1565
+ description: "Full MLB/MiLB pitcher assessment for trade/free agency decisions",
1566
+ audiences: ["gm", "scout", "analyst"],
1567
+ templateFile: "pro-pitcher-eval.hbs",
1568
+ dataRequirements: [
1569
+ { queryTemplate: "pitcher-arsenal", paramMapping: { player: "player" }, required: true },
1570
+ { queryTemplate: "pitcher-velocity-trend", paramMapping: { player: "player" }, required: false },
1571
+ { queryTemplate: "pitcher-handedness-splits", paramMapping: { player: "player" }, required: true }
1572
+ ],
1573
+ requiredSections: ["Header", "Pitch Arsenal", "Performance Profile", "Splits Analysis", "Trend Analysis", "Risk Assessment", "Comparable Player", "Role Projection"],
1574
+ examples: ['bbdata report pro-pitcher-eval --player "Corbin Burnes"']
1575
+ });
1576
+ registerReportTemplate({
1577
+ id: "pro-hitter-eval",
1578
+ name: "Pro Hitter Evaluation",
1579
+ category: "pro-scouting",
1580
+ description: "Full MLB/MiLB hitter assessment for acquisition decisions",
1581
+ audiences: ["gm", "scout", "analyst"],
1582
+ templateFile: "pro-hitter-eval.hbs",
1583
+ dataRequirements: [
1584
+ { queryTemplate: "hitter-batted-ball", paramMapping: { player: "player" }, required: true },
1585
+ { queryTemplate: "hitter-vs-pitch-type", paramMapping: { player: "player" }, required: true },
1586
+ { queryTemplate: "hitter-hot-cold-zones", paramMapping: { player: "player" }, required: false }
1587
+ ],
1588
+ requiredSections: ["Header", "Batted Ball Profile", "Approach & Discipline", "Splits Analysis", "Trend Analysis", "Risk Assessment", "Comparable Player", "Role Projection"],
1589
+ examples: ['bbdata report pro-hitter-eval --player "Juan Soto"']
1590
+ });
1591
+ registerReportTemplate({
1592
+ id: "relief-pitcher-quick",
1593
+ name: "Relief Pitcher Quick Eval",
1594
+ category: "pro-scouting",
1595
+ description: "Fast 1-page evaluation for bullpen additions",
1596
+ audiences: ["gm", "scout"],
1597
+ templateFile: "relief-pitcher-quick.hbs",
1598
+ dataRequirements: [
1599
+ { queryTemplate: "pitcher-arsenal", paramMapping: { player: "player" }, required: true }
1600
+ ],
1601
+ requiredSections: ["Header", "Arsenal", "Key Metrics", "Recommendation"],
1602
+ examples: ['bbdata report relief-pitcher-quick --player "Edwin Diaz"']
1603
+ });
1604
+ registerReportTemplate({
1605
+ id: "college-pitcher-draft",
1606
+ name: "College Pitcher Draft Report",
1607
+ category: "amateur-scouting",
1608
+ description: "Draft evaluation with tools and projection focus",
1609
+ audiences: ["gm", "scout"],
1610
+ templateFile: "college-pitcher-draft.hbs",
1611
+ dataRequirements: [],
1612
+ requiredSections: ["Header", "Physical", "Arsenal Grades", "Performance", "Projection", "Risk", "Recommendation"],
1613
+ examples: ['bbdata report college-pitcher-draft --player "Chase Burns"']
1614
+ });
1615
+ registerReportTemplate({
1616
+ id: "college-hitter-draft",
1617
+ name: "College Hitter Draft Report",
1618
+ category: "amateur-scouting",
1619
+ description: "Draft evaluation with tools and projection focus",
1620
+ audiences: ["gm", "scout"],
1621
+ templateFile: "college-hitter-draft.hbs",
1622
+ dataRequirements: [],
1623
+ requiredSections: ["Header", "Physical", "Tool Grades", "Performance", "Projection", "Risk", "Recommendation"],
1624
+ examples: ['bbdata report college-hitter-draft --player "Charlie Condon"']
1625
+ });
1626
+ registerReportTemplate({
1627
+ id: "hs-prospect",
1628
+ name: "High School Prospect Report",
1629
+ category: "amateur-scouting",
1630
+ description: "Tools-and-projection focused (stats unreliable at HS level)",
1631
+ audiences: ["gm", "scout"],
1632
+ templateFile: "hs-prospect.hbs",
1633
+ dataRequirements: [],
1634
+ requiredSections: ["Header", "Physical", "Tool Grades", "Makeup", "Projection", "Signability", "Recommendation"],
1635
+ examples: ['bbdata report hs-prospect --player "Prospect Name"']
1636
+ });
1637
+ registerReportTemplate({
1638
+ id: "advance-sp",
1639
+ name: "Advance Report: Starting Pitcher",
1640
+ category: "advance",
1641
+ description: "Game prep for opposing starter \u2014 actionable, 1-page, bullet-point format",
1642
+ audiences: ["coach", "analyst"],
1643
+ templateFile: "advance-sp.hbs",
1644
+ dataRequirements: [
1645
+ { queryTemplate: "pitcher-arsenal", paramMapping: { player: "player" }, required: true },
1646
+ { queryTemplate: "pitcher-handedness-splits", paramMapping: { player: "player" }, required: true }
1647
+ ],
1648
+ requiredSections: ["Header", "Recent Form", "Pitch Mix & Sequencing", "Times Through Order", "Platoon Vulnerabilities", "How to Attack"],
1649
+ examples: ['bbdata report advance-sp --player "Gerrit Cole" --audience coach']
1650
+ });
1651
+ registerReportTemplate({
1652
+ id: "advance-lineup",
1653
+ name: "Advance Report: Opposing Lineup",
1654
+ category: "advance",
1655
+ description: "Hitter-by-hitter breakdown for pitchers and catchers",
1656
+ audiences: ["coach", "analyst"],
1657
+ templateFile: "advance-lineup.hbs",
1658
+ dataRequirements: [],
1659
+ requiredSections: ["Header", "Lineup Overview", "Hitter Breakdowns", "Key Matchups"],
1660
+ examples: ["bbdata report advance-lineup --team NYY"]
1661
+ });
1662
+ registerReportTemplate({
1663
+ id: "dev-progress",
1664
+ name: "Development Progress Report",
1665
+ category: "player-dev",
1666
+ description: "Track minor league player growth over time (monthly/quarterly)",
1667
+ audiences: ["scout", "analyst"],
1668
+ templateFile: "dev-progress.hbs",
1669
+ dataRequirements: [],
1670
+ requiredSections: ["Header", "Current Stats", "Trend Analysis", "Mechanical Notes", "Development Goals", "Next Steps"],
1671
+ examples: ['bbdata report dev-progress --player "Jackson Holliday"']
1672
+ });
1673
+ registerReportTemplate({
1674
+ id: "post-promotion",
1675
+ name: "Post-Promotion Evaluation",
1676
+ category: "player-dev",
1677
+ description: "Assess player adjustment after level change",
1678
+ audiences: ["scout", "analyst"],
1679
+ templateFile: "post-promotion.hbs",
1680
+ dataRequirements: [],
1681
+ requiredSections: ["Header", "Pre-Promotion Stats", "Post-Promotion Stats", "Adjustment Analysis", "Recommendation"],
1682
+ examples: ['bbdata report post-promotion --player "Jackson Holliday"']
1683
+ });
1684
+ registerReportTemplate({
1685
+ id: "trade-target-onepager",
1686
+ name: "Trade Target One-Pager",
1687
+ category: "executive",
1688
+ description: "Condensed 2-minute evaluation for GM-level trade decisions",
1689
+ audiences: ["gm"],
1690
+ templateFile: "trade-target-onepager.hbs",
1691
+ dataRequirements: [
1692
+ { queryTemplate: "hitter-batted-ball", paramMapping: { player: "player" }, required: false },
1693
+ { queryTemplate: "pitcher-arsenal", paramMapping: { player: "player" }, required: false }
1694
+ ],
1695
+ requiredSections: ["Header", "Key Stats", "Strengths", "Concerns", "Fit Assessment", "Recommendation"],
1696
+ examples: ['bbdata report trade-target-onepager --player "Vladimir Guerrero Jr." --audience gm']
1697
+ });
1698
+ registerReportTemplate({
1699
+ id: "draft-board-card",
1700
+ name: "Draft Board Summary Card",
1701
+ category: "executive",
1702
+ description: "Glanceable index card for draft room use",
1703
+ audiences: ["gm", "scout"],
1704
+ templateFile: "draft-board-card.hbs",
1705
+ dataRequirements: [],
1706
+ requiredSections: ["Name", "Position", "School", "Tool Grades", "Projection", "Comp", "Round Range"],
1707
+ examples: ['bbdata report draft-board-card --player "Prospect Name"']
1708
+ });
1709
+
1710
+ // src/commands/report.ts
1711
+ Handlebars.registerHelper("grade", (value) => formatGrade(value));
1712
+ Handlebars.registerHelper("gradeLabel", (value) => gradeLabel(value));
1713
+ Handlebars.registerHelper("pitchType", (code) => pitchTypeName(code));
1714
+ Handlebars.registerHelper("formatStat", (value, decimals) => {
1715
+ if (value === null || value === void 0) return "\u2014";
1716
+ return typeof decimals === "number" ? Number(value).toFixed(decimals) : String(value);
1717
+ });
1718
+ Handlebars.registerHelper("compare", (value, leagueAvg) => {
1719
+ if (value === null || value === void 0) return "\u2014";
1720
+ const diff = value - leagueAvg;
1721
+ const pct = leagueAvg !== 0 ? (diff / leagueAvg * 100).toFixed(1) : "0";
1722
+ return diff > 0 ? `+${pct}%` : `${pct}%`;
1723
+ });
1724
+ Handlebars.registerHelper("ifGt", function(a, b, options) {
1725
+ return a > b ? options.fn(this) : options.inverse(this);
1726
+ });
1727
+ var __dirname2 = dirname(fileURLToPath(import.meta.url));
1728
+ var BUNDLED_TEMPLATES_DIR = join2(__dirname2, "..", "templates", "reports");
1729
+ function loadTemplate(templateFile) {
1730
+ const userDir = join2(getTemplatesDir(), "reports");
1731
+ const userPath = join2(userDir, templateFile);
1732
+ if (existsSync2(userPath)) {
1733
+ return readFileSync2(userPath, "utf-8");
1734
+ }
1735
+ const bundledPath = join2(BUNDLED_TEMPLATES_DIR, templateFile);
1736
+ if (existsSync2(bundledPath)) {
1737
+ return readFileSync2(bundledPath, "utf-8");
1738
+ }
1739
+ return generateFallbackTemplate(templateFile);
1740
+ }
1741
+ function generateFallbackTemplate(templateFile) {
1742
+ const templateId = templateFile.replace(".hbs", "");
1743
+ const template13 = getReportTemplate(templateId);
1744
+ if (!template13) return "# Report\n\n{{data}}";
1745
+ const sections = template13.requiredSections.map((s) => `## ${s}
1746
+
1747
+ {{!-- ${s} data goes here --}}
1748
+ *Data pending*
1749
+ `).join("\n");
1750
+ return `# ${template13.name}
1751
+
1752
+ **Player:** {{player}}
1753
+ **Season:** {{season}}
1754
+ **Audience:** {{audience}}
1755
+ **Generated:** {{date}}
1756
+
1757
+ ---
1758
+
1759
+ ${sections}
1760
+
1761
+ ---
1762
+ *Generated by bbdata CLI \xB7 Data sources: {{sources}}*
1763
+ `;
1764
+ }
1765
+ async function report(options) {
1766
+ const config = getConfig();
1767
+ const audience = options.audience ?? config.defaultAudience;
1768
+ const template13 = getReportTemplate(options.template);
1769
+ if (!template13) {
1770
+ const available = listReportTemplates().map((t) => ` ${t.id} \u2014 ${t.description}`).join("\n");
1771
+ throw new Error(`Unknown report template "${options.template}". Available:
1772
+ ${available}`);
1773
+ }
1774
+ const season = options.season ?? (/* @__PURE__ */ new Date()).getFullYear();
1775
+ const player = options.player ?? "Unknown";
1776
+ const dataResults = {};
1777
+ const dataSources = [];
1778
+ for (const req of template13.dataRequirements) {
1779
+ try {
1780
+ const result = await query({
1781
+ template: req.queryTemplate,
1782
+ player: options.player,
1783
+ team: options.team,
1784
+ season,
1785
+ format: "json"
1786
+ });
1787
+ dataResults[req.queryTemplate] = result.data;
1788
+ if (!dataSources.includes(result.meta.source)) {
1789
+ dataSources.push(result.meta.source);
1790
+ }
1791
+ } catch (error) {
1792
+ if (req.required) {
1793
+ log.warn(`Required data "${req.queryTemplate}" failed: ${error}`);
1794
+ }
1795
+ dataResults[req.queryTemplate] = null;
1796
+ }
1797
+ }
1798
+ const hbsSource = loadTemplate(template13.templateFile);
1799
+ const compiled = Handlebars.compile(hbsSource);
1800
+ const content = compiled({
1801
+ player,
1802
+ season,
1803
+ audience,
1804
+ date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
1805
+ sources: dataSources.join(", ") || "none",
1806
+ data: dataResults,
1807
+ ...dataResults
1808
+ });
1809
+ let validation;
1810
+ if (options.validate) {
1811
+ validation = validateReport(content, template13.requiredSections);
1812
+ }
1813
+ const formatted = options.format === "json" ? JSON.stringify({ content, validation, meta: { template: template13.id, player, audience, season, dataSources } }, null, 2) + "\n" : content + "\n";
1814
+ return {
1815
+ content,
1816
+ formatted,
1817
+ validation,
1818
+ meta: {
1819
+ template: template13.id,
1820
+ player,
1821
+ audience,
1822
+ season,
1823
+ dataSources
1824
+ }
1825
+ };
1826
+ }
1827
+ function validateReport(content, requiredSections) {
1828
+ const issues = [];
1829
+ for (const section of requiredSections) {
1830
+ if (!content.includes(section)) {
1831
+ issues.push({ severity: "warning", message: `Missing section: "${section}"` });
1832
+ }
1833
+ }
1834
+ if (content.includes("Data pending") || content.includes("data goes here")) {
1835
+ issues.push({ severity: "error", message: "Report contains placeholder text \u2014 data was not populated" });
1836
+ }
1837
+ const genericPhrases = ["shows promise", "solid player", "good potential", "talented athlete"];
1838
+ for (const phrase of genericPhrases) {
1839
+ if (content.toLowerCase().includes(phrase)) {
1840
+ issues.push({ severity: "warning", message: `Generic language detected: "${phrase}" \u2014 be more specific` });
1841
+ }
1842
+ }
1843
+ if (content.length < 200) {
1844
+ issues.push({ severity: "warning", message: "Report seems too short \u2014 may be missing content" });
1845
+ }
1846
+ return {
1847
+ passed: issues.filter((i) => i.severity === "error").length === 0,
1848
+ issues
1849
+ };
1850
+ }
1851
+ export {
1852
+ getConfig,
1853
+ query,
1854
+ report,
1855
+ setConfig
1856
+ };
1857
+ //# sourceMappingURL=index.js.map