From 6201b4c9afbc94c6896c55abf37ae7d8da9047d4 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 16 Feb 2026 11:24:18 -0600 Subject: [PATCH] Major Domo CLI: modular refactor + 6 new command modules Refactored monolithic cli.py into modular architecture: - cli_common.py: shared state, console, output helpers - cli_transactions.py: list + simulate (compliance checker) - cli_injuries.py: injury listing with team/active filters - cli_stats.py: batting/pitching leaderboards - cli_results.py: game results - cli_schedule.py: game schedules Also: team get now shows salary_cap, SKILL.md fully updated with CLI docs, flag ordering warning, and compliance workflow. Co-Authored-By: Claude Opus 4.6 --- skills/major-domo/SKILL.md | 142 +++++++- skills/major-domo/cli.py | 353 ++++++++++++------- skills/major-domo/cli_common.py | 72 ++++ skills/major-domo/cli_injuries.py | 143 ++++++++ skills/major-domo/cli_results.py | 243 ++++++++++++++ skills/major-domo/cli_schedule.py | 231 +++++++++++++ skills/major-domo/cli_stats.py | 278 +++++++++++++++ skills/major-domo/cli_transactions.py | 467 ++++++++++++++++++++++++++ 8 files changed, 1794 insertions(+), 135 deletions(-) create mode 100644 skills/major-domo/cli_common.py create mode 100644 skills/major-domo/cli_injuries.py create mode 100644 skills/major-domo/cli_results.py create mode 100644 skills/major-domo/cli_schedule.py create mode 100644 skills/major-domo/cli_stats.py create mode 100644 skills/major-domo/cli_transactions.py diff --git a/skills/major-domo/SKILL.md b/skills/major-domo/SKILL.md index 54fbb02..4063e90 100644 --- a/skills/major-domo/SKILL.md +++ b/skills/major-domo/SKILL.md @@ -491,10 +491,13 @@ This skill includes structured workflows for common tasks: **Team Analysis**: ``` "Compare team rosters" -→ Get rosters for multiple teams and analyze +→ cli.py team roster ABBREV (for each team) "Show team's best players by WARA" -→ api.list_players(season=12, team_id=X, sort='cost-desc') +→ cli.py team roster ABBREV (sorted by WARA in output) + +"Check if transactions are compliant" +→ See "Transaction Compliance Check Workflow" in CLI section "Analyze team transaction history" → api.get_transactions(season=12, team_abbrev=X) @@ -631,9 +634,14 @@ npm run type-check # Type checking only ``` major-domo/ ├── SKILL.md (this file) -├── CLAUDE.md (detailed context - future) ├── api_client.py (shared API client) -├── cli.py (CLI application - alias: majordomo) +├── cli.py (main CLI - mounts sub-apps, core commands) +├── cli_common.py (shared state, console, output helpers) +├── cli_transactions.py (transactions list + simulate) +├── cli_injuries.py (injury listing) +├── cli_stats.py (batting/pitching leaderboards) +├── cli_results.py (game results) +├── cli_schedule.py (game schedules) ├── workflows/ │ ├── bot-deployment.md │ ├── weekly-stats-update.md @@ -715,30 +723,146 @@ majordomo team roster CAR # Team roster breakdown # Standings majordomo standings [--division ALE] # League standings + +# Transactions +majordomo transactions --team CLS --week 9 # Team transactions for week +majordomo transactions list --week-start 1 --week-end 4 # Week range +majordomo transactions --player "Mike Trout" # Player-specific transactions +majordomo transactions simulate CLS "Stott:CLSMiL,Walker:CLS,Castellanos:FA,Martin:CLS" # Check compliance + +# Injuries +majordomo injuries list --team CLS --active # Active injuries for team +majordomo injuries list --sort return-asc # Sort by return date + +# Statistics (Season Leaders) +majordomo stats batting --sort woba --min-pa 100 --limit 25 # Batting leaders +majordomo stats pitching --sort era --min-outs 100 --limit 25 # Pitching leaders +majordomo stats batting --team CLS # Team batting stats + +# Game Results +majordomo results --team CLS --week 9 # Team results for week +majordomo results list --week-start 1 --week-end 4 # Results for week range + +# Game Schedule +majordomo schedule --team CLS --week 10 # Team schedule for week +majordomo schedule list --week-start 10 --week-end 12 # Schedule for week range ``` ### Options +**⚠️ IMPORTANT**: `--env`, `--json`, `--verbose` are **top-level flags** that go BEFORE the subcommand. +`--season` is a **subcommand flag** that goes AFTER the subcommand. + ```bash +# Top-level flags (BEFORE subcommand) --env prod|dev # Environment (default: prod) ---json # Output as JSON +--json # Output as JSON (shows all fields including salary_cap) --verbose / -v # Show API request details + +# Subcommand flags (AFTER subcommand) --season / -s N # Specify season (defaults to current) ``` +**Correct:** +```bash +python3 ~/.claude/skills/major-domo/cli.py --json team get CLS +python3 ~/.claude/skills/major-domo/cli.py --env dev team roster CAN +``` + +**Wrong** (will error): +```bash +python3 ~/.claude/skills/major-domo/cli.py team get CLS --json # ❌ +python3 ~/.claude/skills/major-domo/cli.py team roster CAN --env prod # ❌ +``` + +### Key Notes + +- `team get` shows salary_cap in formatted output; use `--json` for all fields +- `team roster` shows Active/Short IL/Long IL with WARA values for Active only +- "Long IL" = MiL (minor leagues) +- For individual player lookups, use `player get "Name"` — avoid bulk API queries (`/players?team_id=X`) which can timeout on large rosters +- **Prefer CLI over direct API client** for all standard operations +- `transactions simulate` validates compliance without making changes — check roster count, WARA limits, salary cap +- `stats` commands support standard baseball stats sorting (woba, obp, slg, era, whip, fip, etc.) +- `injuries list` shows return_date, weeks_out, and injury_type for all injured players + ### Examples ```bash -# What we commonly do for roster moves: -majordomo player move --batch "Gerrit Cole:CAN,Robert Suarez:CANMiL" +# Roster moves +python3 ~/.claude/skills/major-domo/cli.py player move --batch "Gerrit Cole:CAN,Robert Suarez:CANMiL" # Check standings -majordomo standings --division ALE +python3 ~/.claude/skills/major-domo/cli.py standings --division ALE # Get team roster -majordomo team roster CAN +python3 ~/.claude/skills/major-domo/cli.py team roster CAN + +# Get team salary cap (need --json) +python3 ~/.claude/skills/major-domo/cli.py --json team get CAN + +# Check transaction compliance (dry run) +python3 ~/.claude/skills/major-domo/cli.py transactions simulate CLS "Stott:CLSMiL,Walker:CLS,Castellanos:FA,Martin:CLS" + +# View recent transactions +python3 ~/.claude/skills/major-domo/cli.py transactions --team CLS --week 9 +python3 ~/.claude/skills/major-domo/cli.py transactions list --week-start 1 --week-end 4 + +# Check injuries +python3 ~/.claude/skills/major-domo/cli.py injuries list --active +python3 ~/.claude/skills/major-domo/cli.py injuries list --team CLS + +# Season batting leaders +python3 ~/.claude/skills/major-domo/cli.py stats batting --sort woba --min-pa 100 --limit 25 +python3 ~/.claude/skills/major-domo/cli.py stats batting --team CLS + +# Season pitching leaders +python3 ~/.claude/skills/major-domo/cli.py stats pitching --sort era --min-outs 100 --limit 25 +python3 ~/.claude/skills/major-domo/cli.py stats pitching --team CAN --sort k + +# Game results and schedules +python3 ~/.claude/skills/major-domo/cli.py results --team CLS --week 9 +python3 ~/.claude/skills/major-domo/cli.py schedule --team CLS --week 10 +python3 ~/.claude/skills/major-domo/cli.py schedule list --week-start 10 --week-end 12 + +# Practical workflows +# 1. Check if proposed trades are compliant before executing +python3 ~/.claude/skills/major-domo/cli.py transactions simulate CLS "Player1:CLS,Player2:CLSMiL" + +# 2. Review team stats and identify weak positions +python3 ~/.claude/skills/major-domo/cli.py team roster CLS +python3 ~/.claude/skills/major-domo/cli.py stats batting --team CLS + +# 3. Monitor weekly transactions across the league +python3 ~/.claude/skills/major-domo/cli.py transactions list --week-start 8 --week-end 9 + +# 4. Check injury status before setting lineups +python3 ~/.claude/skills/major-domo/cli.py injuries list --team CLS --active ``` +### Transaction Compliance Check Workflow + +**Recommended approach** - Use the simulate command for automatic compliance validation: + +```bash +# Simulate proposed transactions (dry run - no changes made) +python3 ~/.claude/skills/major-domo/cli.py transactions simulate CLS "Stott:CLSMiL,Walker:CLS,Castellanos:FA,Martin:CLS" +``` + +The simulate command automatically validates: +- Active roster count = 26 +- Total sWAR ≤ salary cap +- All players exist and can be moved to target destinations +- Returns detailed compliance report with before/after state + +**Manual verification workflow** (if needed): + +1. **Get current ML roster**: `cli.py team roster ABBREV` → note player count and WARA values +2. **Get salary cap**: `cli.py --json team get ABBREV` → read `salary_cap` field +3. **Look up incoming players**: `cli.py player get "Name"` for each player being added to ML +4. **Calculate**: Current ML sWAR ± transaction changes ≤ salary_cap, and roster count = 26 +5. **Process moves**: `cli.py player move --batch "Name1:Team1,Name2:Team2"` + --- ## Quick Reference Commands diff --git a/skills/major-domo/cli.py b/skills/major-domo/cli.py index 82370a9..fecb569 100755 --- a/skills/major-domo/cli.py +++ b/skills/major-domo/cli.py @@ -3,6 +3,7 @@ Major Domo CLI - SBA League Management A command-line interface for the Major Domo API, primarily for use with Claude Code. +Modular architecture: each command group is a separate cli_*.py file. Usage: majordomo status @@ -13,24 +14,33 @@ Usage: majordomo team list majordomo team roster CAR majordomo standings --division ALE + majordomo transactions --team CLS + majordomo injuries --active + majordomo stats batting --sort woba --min-pa 100 Environment: API_TOKEN: Required. Bearer token for API authentication. """ -import json import os import sys -from typing import Annotated, List, Optional +from typing import Annotated, Optional import typer -from rich.console import Console from rich.panel import Panel -from rich.table import Table -# Import the existing API client from same directory +# Ensure skill directory is on path for imports sys.path.insert(0, os.path.dirname(__file__)) + from api_client import MajorDomoAPI +from cli_common import ( + console, + state, + output_json, + output_table, + handle_error, + get_season, +) # ============================================================================ # App Setup @@ -47,66 +57,34 @@ team_app = typer.Typer(help="Team operations") app.add_typer(player_app, name="player") app.add_typer(team_app, name="team") -console = Console() +# Import and mount sub-app modules +from cli_transactions import transactions_app +from cli_injuries import injuries_app +from cli_stats import stats_app +from cli_results import results_app +from cli_schedule import schedule_app - -class State: - """Global state for API client and settings""" - api: Optional[MajorDomoAPI] = None - json_output: bool = False - current_season: Optional[int] = None - - -state = State() - - -# ============================================================================ -# Output Helpers -# ============================================================================ - -def output_json(data): - """Output data as formatted JSON""" - console.print_json(json.dumps(data, indent=2, default=str)) - - -def output_table(title: str, columns: List[str], rows: List[List], show_lines: bool = False): - """Output data as a rich table""" - table = Table(title=title, show_header=True, header_style="bold cyan", show_lines=show_lines) - for col in columns: - table.add_column(col) - for row in rows: - table.add_row(*[str(cell) if cell is not None else "" for cell in row]) - console.print(table) - - -def handle_error(e: Exception, context: str = ""): - """Graceful error handling with helpful messages""" - error_str = str(e) - if "401" in error_str: - console.print("[red]Error:[/red] Unauthorized. Check your API_TOKEN.") - elif "404" in error_str: - console.print(f"[red]Error:[/red] Not found. {context}") - elif "Connection" in error_str or "ConnectionError" in error_str: - console.print("[red]Error:[/red] Cannot connect to API. Check network and --env setting.") - else: - console.print(f"[red]Error:[/red] {e}") - raise typer.Exit(1) - - -def get_season(season: Optional[int]) -> int: - """Get season, defaulting to current if not specified""" - return season if season is not None else state.current_season +app.add_typer(transactions_app, name="transactions") +app.add_typer(injuries_app, name="injuries") +app.add_typer(stats_app, name="stats") +app.add_typer(results_app, name="results") +app.add_typer(schedule_app, name="schedule") # ============================================================================ # Main Callback (Global Options) # ============================================================================ + @app.callback() def main( - env: Annotated[str, typer.Option("--env", help="Environment: prod or dev")] = "prod", + env: Annotated[ + str, typer.Option("--env", help="Environment: prod or dev") + ] = "prod", json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False, - verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Verbose output")] = False, + verbose: Annotated[ + bool, typer.Option("--verbose", "-v", help="Verbose output") + ] = False, ): """Major Domo SBA League Management CLI""" try: @@ -114,7 +92,7 @@ def main( state.json_output = json_output # Cache current season current = state.api.get_current() - state.current_season = current['season'] + state.current_season = current["season"] except ValueError as e: console.print(f"[red]Configuration Error:[/red] {e}") console.print("\nSet API_TOKEN environment variable:") @@ -128,6 +106,7 @@ def main( # Status & Health Commands # ============================================================================ + @app.command() def status(): """Show current season/week status""" @@ -170,10 +149,13 @@ def health(): # Player Commands # ============================================================================ + @player_app.command("get") def player_get( name: Annotated[str, typer.Argument(help="Player name")], - season: Annotated[Optional[int], typer.Option("--season", "-s", help="Season number")] = None, + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, ): """Get player information by name""" try: @@ -181,8 +163,10 @@ def player_get( player = state.api.get_player(name=name, season=season) if not player: - console.print(f"[yellow]Player '{name}' not found in season {season}[/yellow]") - console.print("Try: majordomo player search \"partial name\"") + console.print( + f"[yellow]Player '{name}' not found in season {season}[/yellow]" + ) + console.print('Try: majordomo player search "partial name"') raise typer.Exit(1) if state.json_output: @@ -190,11 +174,13 @@ def player_get( return # Extract nested team info - team = player.get('team', {}) - team_abbrev = team.get('abbrev', 'N/A') if isinstance(team, dict) else 'N/A' + team = player.get("team", {}) + team_abbrev = team.get("abbrev", "N/A") if isinstance(team, dict) else "N/A" # Collect positions - positions = [player.get(f'pos_{i}') for i in range(1, 9) if player.get(f'pos_{i}')] + positions = [ + player.get(f"pos_{i}") for i in range(1, 9) if player.get(f"pos_{i}") + ] panel = Panel( f"[bold]ID:[/bold] {player['id']}\n" @@ -217,7 +203,9 @@ def player_get( @player_app.command("search") def player_search( query: Annotated[str, typer.Argument(help="Search query (partial name)")], - season: Annotated[Optional[int], typer.Option("--season", "-s", help="Season number")] = None, + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 10, ): """Search players by name (fuzzy match)""" @@ -230,25 +218,29 @@ def player_search( return if not players: - console.print(f"[yellow]No players found matching '{query}' in season {season}[/yellow]") + console.print( + f"[yellow]No players found matching '{query}' in season {season}[/yellow]" + ) return rows = [] for p in players: - team = p.get('team', {}) - team_abbrev = team.get('abbrev', 'N/A') if isinstance(team, dict) else 'N/A' - rows.append([ - p['id'], - p['name'], - team_abbrev, - p.get('pos_1', ''), - f"{p.get('wara', 0):.2f}" - ]) + team = p.get("team", {}) + team_abbrev = team.get("abbrev", "N/A") if isinstance(team, dict) else "N/A" + rows.append( + [ + p["id"], + p["name"], + team_abbrev, + p.get("pos_1", ""), + f"{p.get('wara', 0):.2f}", + ] + ) output_table( f"Search Results: '{query}' (Season {season})", ["ID", "Name", "Team", "Pos", "WARA"], - rows + rows, ) except Exception as e: handle_error(e) @@ -257,10 +249,19 @@ def player_search( @player_app.command("move") def player_move( name: Annotated[Optional[str], typer.Argument(help="Player name")] = None, - team: Annotated[Optional[str], typer.Argument(help="Target team abbreviation")] = None, - batch: Annotated[Optional[str], typer.Option("--batch", "-b", help="Batch moves: 'Name1:Team1,Name2:Team2'")] = None, - season: Annotated[Optional[int], typer.Option("--season", "-s", help="Season number")] = None, - dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would be done")] = False, + team: Annotated[ + Optional[str], typer.Argument(help="Target team abbreviation") + ] = None, + batch: Annotated[ + Optional[str], + typer.Option("--batch", "-b", help="Batch moves: 'Name1:Team1,Name2:Team2'"), + ] = None, + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, + dry_run: Annotated[ + bool, typer.Option("--dry-run", help="Show what would be done") + ] = False, ): """Move player(s) to a new team""" try: @@ -282,10 +283,14 @@ def player_move( elif name and team: moves = [(name, team.upper())] else: - console.print("[red]Error:[/red] Provide player name and team, or use --batch") + console.print( + "[red]Error:[/red] Provide player name and team, or use --batch" + ) console.print("\nUsage:") - console.print(" majordomo player move \"Mike Trout\" CAR") - console.print(" majordomo player move --batch \"Mike Trout:CAR,Aaron Judge:NYM\"") + console.print(' majordomo player move "Mike Trout" CAR') + console.print( + ' majordomo player move --batch "Mike Trout:CAR,Aaron Judge:NYM"' + ) raise typer.Exit(1) results = [] @@ -294,35 +299,74 @@ def player_move( player = state.api.get_player(name=player_name, season=season) if not player: # Try search - search_results = state.api.search_players(query=player_name, season=season, limit=1) + search_results = state.api.search_players( + query=player_name, season=season, limit=1 + ) if search_results: player = search_results[0] else: - results.append((player_name, team_abbrev, "[red]ERROR[/red]", "Player not found")) + results.append( + ( + player_name, + team_abbrev, + "[red]ERROR[/red]", + "Player not found", + ) + ) continue # Get current team - current_team = player.get('team', {}) - current_abbrev = current_team.get('abbrev', 'N/A') if isinstance(current_team, dict) else 'N/A' + current_team = player.get("team", {}) + current_abbrev = ( + current_team.get("abbrev", "N/A") + if isinstance(current_team, dict) + else "N/A" + ) # Find target team try: target_team = state.api.get_team(abbrev=team_abbrev, season=season) except Exception: - results.append((player_name, team_abbrev, "[red]ERROR[/red]", f"Team '{team_abbrev}' not found")) + results.append( + ( + player_name, + team_abbrev, + "[red]ERROR[/red]", + f"Team '{team_abbrev}' not found", + ) + ) continue if dry_run: - results.append((player['name'], team_abbrev, "[blue]DRY-RUN[/blue]", f"Would move from {current_abbrev}")) + results.append( + ( + player["name"], + team_abbrev, + "[blue]DRY-RUN[/blue]", + f"Would move from {current_abbrev}", + ) + ) continue # Perform update - state.api.update_player(player['id'], team_id=target_team['id']) - results.append((player['name'], team_abbrev, "[green]SUCCESS[/green]", f"Moved from {current_abbrev}")) + state.api.update_player(player["id"], team_id=target_team["id"]) + results.append( + ( + player["name"], + team_abbrev, + "[green]SUCCESS[/green]", + f"Moved from {current_abbrev}", + ) + ) if state.json_output: json_results = [ - {"player": r[0], "team": r[1], "status": r[2].replace("[", "").replace("]", "").split("/")[0], "message": r[3]} + { + "player": r[0], + "team": r[1], + "status": r[2].replace("[", "").replace("]", "").split("/")[0], + "message": r[3], + } for r in results ] output_json(json_results) @@ -343,10 +387,15 @@ def player_move( # Team Commands # ============================================================================ + @team_app.command("list") def team_list( - season: Annotated[Optional[int], typer.Option("--season", "-s", help="Season number")] = None, - active: Annotated[bool, typer.Option("--active", "-a", help="Exclude IL/MiL teams")] = False, + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, + active: Annotated[ + bool, typer.Option("--active", "-a", help="Exclude IL/MiL teams") + ] = False, ): """List all teams""" try: @@ -363,16 +412,27 @@ def team_list( rows = [] for t in teams: - manager = t.get('manager1', {}) - manager_name = manager.get('name', '') if isinstance(manager, dict) else '' - division = t.get('division', {}) - div_abbrev = division.get('division_abbrev', '') if isinstance(division, dict) else '' - rows.append([t['abbrev'], t.get('lname', t.get('sname', '')), div_abbrev, manager_name]) + manager = t.get("manager1", {}) + manager_name = manager.get("name", "") if isinstance(manager, dict) else "" + division = t.get("division", {}) + div_abbrev = ( + division.get("division_abbrev", "") + if isinstance(division, dict) + else "" + ) + rows.append( + [ + t["abbrev"], + t.get("lname", t.get("sname", "")), + div_abbrev, + manager_name, + ] + ) output_table( f"Teams - Season {season}" + (" (Active Only)" if active else ""), ["Abbrev", "Name", "Division", "Manager"], - rows + rows, ) except Exception as e: handle_error(e) @@ -381,7 +441,9 @@ def team_list( @team_app.command("get") def team_get( abbrev: Annotated[str, typer.Argument(help="Team abbreviation")], - season: Annotated[Optional[int], typer.Option("--season", "-s", help="Season number")] = None, + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, ): """Get team information""" try: @@ -392,10 +454,18 @@ def team_get( output_json(team) return - manager = team.get('manager1', {}) - manager_name = manager.get('name', 'N/A') if isinstance(manager, dict) else 'N/A' - division = team.get('division', {}) - div_name = division.get('division_name', 'N/A') if isinstance(division, dict) else 'N/A' + manager = team.get("manager1", {}) + manager_name = ( + manager.get("name", "N/A") if isinstance(manager, dict) else "N/A" + ) + division = team.get("division", {}) + div_name = ( + division.get("division_name", "N/A") + if isinstance(division, dict) + else "N/A" + ) + salary_cap = team.get("salary_cap") + cap_str = f"{salary_cap:.1f}" if salary_cap is not None else "N/A" panel = Panel( f"[bold]ID:[/bold] {team['id']}\n" @@ -404,6 +474,7 @@ def team_get( f"[bold]Full Name:[/bold] {team.get('lname', 'N/A')}\n" f"[bold]Division:[/bold] {div_name}\n" f"[bold]Manager:[/bold] {manager_name}\n" + f"[bold]Salary Cap:[/bold] {cap_str}\n" f"[bold]Stadium:[/bold] {team.get('stadium', 'N/A')}\n" f"[bold]Season:[/bold] {team.get('season', 'N/A')}", title=f"Team: {team.get('lname', abbrev)}", @@ -417,40 +488,60 @@ def team_get( @team_app.command("roster") def team_roster( abbrev: Annotated[str, typer.Argument(help="Team abbreviation")], - which: Annotated[str, typer.Option("--which", "-w", help="'current' or 'next'")] = "current", - season: Annotated[Optional[int], typer.Option("--season", "-s", help="Season number")] = None, + which: Annotated[ + str, typer.Option("--which", "-w", help="'current' or 'next'") + ] = "current", + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, ): """Show team roster breakdown""" try: season = get_season(season) team = state.api.get_team(abbrev=abbrev.upper(), season=season) - roster = state.api.get_team_roster(team_id=team['id'], which=which) + roster = state.api.get_team_roster(team_id=team["id"], which=which) if state.json_output: output_json(roster) return - console.print(f"\n[bold cyan]{team.get('lname', abbrev)} Roster ({which.title()})[/bold cyan]\n") + console.print( + f"\n[bold cyan]{team.get('lname', abbrev)} Roster ({which.title()})[/bold cyan]\n" + ) # Active roster - active = roster.get('active', {}).get('players', []) + active = roster.get("active", {}).get("players", []) if active: - active_rows = [[p['name'], p.get('pos_1', ''), f"{p.get('wara', 0):.2f}"] for p in active] - output_table(f"Active ({len(active)})", ["Name", "Pos", "WARA"], active_rows) + active_rows = [ + [p["name"], p.get("pos_1", ""), f"{p.get('wara', 0):.2f}"] + for p in active + ] + output_table( + f"Active ({len(active)})", ["Name", "Pos", "WARA"], active_rows + ) # Short IL - short_il = roster.get('shortil', {}).get('players', []) + short_il = roster.get("shortil", {}).get("players", []) if short_il: console.print() - il_rows = [[p['name'], p.get('pos_1', ''), p.get('il_return', '')] for p in short_il] - output_table(f"Short IL ({len(short_il)})", ["Name", "Pos", "Return"], il_rows) + il_rows = [ + [p["name"], p.get("pos_1", ""), p.get("il_return", "")] + for p in short_il + ] + output_table( + f"Short IL ({len(short_il)})", ["Name", "Pos", "Return"], il_rows + ) # Long IL - long_il = roster.get('longil', {}).get('players', []) + long_il = roster.get("longil", {}).get("players", []) if long_il: console.print() - lil_rows = [[p['name'], p.get('pos_1', ''), p.get('il_return', '')] for p in long_il] - output_table(f"Long IL ({len(long_il)})", ["Name", "Pos", "Return"], lil_rows) + lil_rows = [ + [p["name"], p.get("pos_1", ""), p.get("il_return", "")] for p in long_il + ] + output_table( + f"Long IL ({len(long_il)})", ["Name", "Pos", "Return"], lil_rows + ) # Summary total = len(active) + len(short_il) + len(long_il) @@ -464,11 +555,21 @@ def team_roster( # Standings Command # ============================================================================ + @app.command() def standings( - season: Annotated[Optional[int], typer.Option("--season", "-s", help="Season number")] = None, - division: Annotated[Optional[str], typer.Option("--division", "-d", help="Division (ALE, ALW, ALC, NLE, NLW, NLC)")] = None, - league: Annotated[Optional[str], typer.Option("--league", "-l", help="League (AL or NL)")] = None, + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, + division: Annotated[ + Optional[str], + typer.Option( + "--division", "-d", help="Division (ALE, ALW, ALC, NLE, NLW, NLC)" + ), + ] = None, + league: Annotated[ + Optional[str], typer.Option("--league", "-l", help="League (AL or NL)") + ] = None, ): """Show league standings""" try: @@ -476,7 +577,7 @@ def standings( standings_data = state.api.get_standings( season=season, division_abbrev=division.upper() if division else None, - league_abbrev=league.upper() if league else None + league_abbrev=league.upper() if league else None, ) if state.json_output: @@ -489,14 +590,14 @@ def standings( rows = [] for s in standings_data: - team = s.get('team', {}) - team_abbrev = team.get('abbrev', 'N/A') if isinstance(team, dict) else 'N/A' - team_name = team.get('lname', 'N/A') if isinstance(team, dict) else 'N/A' - wins = s.get('wins', 0) - losses = s.get('losses', 0) + team = s.get("team", {}) + team_abbrev = team.get("abbrev", "N/A") if isinstance(team, dict) else "N/A" + team_name = team.get("lname", "N/A") if isinstance(team, dict) else "N/A" + wins = s.get("wins", 0) + losses = s.get("losses", 0) total = wins + losses pct = f".{int(wins/total*1000):03d}" if total > 0 else ".000" - rd = s.get('run_diff', 0) + rd = s.get("run_diff", 0) rd_str = f"+{rd}" if rd > 0 else str(rd) rows.append([team_abbrev, team_name, wins, losses, pct, rd_str]) diff --git a/skills/major-domo/cli_common.py b/skills/major-domo/cli_common.py new file mode 100644 index 0000000..2de932a --- /dev/null +++ b/skills/major-domo/cli_common.py @@ -0,0 +1,72 @@ +""" +Shared utilities for Major Domo CLI modules. + +All CLI sub-modules import from here for consistent output and state management. +""" + +import json +import os +import sys +from typing import List, Optional + +from rich.console import Console +from rich.table import Table + +# Import the API client from same directory +sys.path.insert(0, os.path.dirname(__file__)) +from api_client import MajorDomoAPI + +console = Console() + + +class State: + """Global state for API client and settings""" + + api: Optional[MajorDomoAPI] = None + json_output: bool = False + current_season: Optional[int] = None + + +state = State() + + +def output_json(data): + """Output data as formatted JSON""" + console.print_json(json.dumps(data, indent=2, default=str)) + + +def output_table( + title: str, columns: List[str], rows: List[List], show_lines: bool = False +): + """Output data as a rich table""" + table = Table( + title=title, show_header=True, header_style="bold cyan", show_lines=show_lines + ) + for col in columns: + table.add_column(col) + for row in rows: + table.add_row(*[str(cell) if cell is not None else "" for cell in row]) + console.print(table) + + +def handle_error(e: Exception, context: str = ""): + """Graceful error handling with helpful messages""" + import typer + + error_str = str(e) + if "401" in error_str: + console.print("[red]Error:[/red] Unauthorized. Check your API_TOKEN.") + elif "404" in error_str: + console.print(f"[red]Error:[/red] Not found. {context}") + elif "Connection" in error_str or "ConnectionError" in error_str: + console.print( + "[red]Error:[/red] Cannot connect to API. Check network and --env setting." + ) + else: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + +def get_season(season: Optional[int]) -> int: + """Get season, defaulting to current if not specified""" + return season if season is not None else state.current_season diff --git a/skills/major-domo/cli_injuries.py b/skills/major-domo/cli_injuries.py new file mode 100644 index 0000000..63993a1 --- /dev/null +++ b/skills/major-domo/cli_injuries.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Major Domo CLI - Injury Operations + +This module provides injury listing and filtering commands. +""" + +from typing import Annotated, Optional + +import typer + +from cli_common import ( + console, + state, + output_json, + output_table, + handle_error, + get_season, +) + +injuries_app = typer.Typer(help="Injury operations") + + +@injuries_app.command("list") +def injuries_list( + team: Annotated[ + Optional[str], + typer.Option("--team", "-t", help="Filter by team abbreviation"), + ] = None, + active: Annotated[ + bool, typer.Option("--active", "-a", help="Only show active injuries") + ] = False, + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, + sort: Annotated[ + str, + typer.Option( + "--sort", help="Sort order (start-asc, start-desc, return-asc, return-desc)" + ), + ] = "return-asc", +): + """List injuries with optional filtering""" + try: + season = get_season(season) + + # Resolve team abbreviation to team ID if provided + team_id = None + team_abbrev = None + if team: + team_abbrev = team.upper() + try: + team_obj = state.api.get_team(abbrev=team_abbrev, season=season) + team_id = team_obj["id"] + except Exception: + console.print( + f"[red]Error:[/red] Team '{team_abbrev}' not found in season {season}" + ) + raise typer.Exit(1) + + # Call API with resolved parameters + result = state.api.get( + "injuries", + season=season, + team_id=team_id, + is_active=active if active else None, + sort=sort, + ) + + injuries = result.get("injuries", []) + + if state.json_output: + output_json(injuries) + return + + if not injuries: + filter_parts = [] + if team_abbrev: + filter_parts.append(f"team {team_abbrev}") + if active: + filter_parts.append("active") + filter_str = " (".join(filter_parts) + ")" if filter_parts else "" + console.print( + f"[yellow]No injuries found in season {season}{filter_str}[/yellow]" + ) + return + + # Format table rows + rows = [] + for injury in injuries: + player = injury.get("player", {}) + player_name = player.get("name", "N/A") + player_team = player.get("team", {}) + team_abbrev_display = ( + player_team.get("abbrev", "N/A") + if isinstance(player_team, dict) + else "N/A" + ) + + total_games = injury.get("total_games", 0) + start_week = injury.get("start_week", 0) + start_game = injury.get("start_game", 0) + end_week = injury.get("end_week", 0) + end_game = injury.get("end_game", 0) + is_active = injury.get("is_active", False) + + # Format start/end as wXXgY + start_str = f"w{start_week:02d}g{start_game}" if start_week else "N/A" + end_str = f"w{end_week:02d}g{end_game}" if end_week else "N/A" + active_str = "Yes" if is_active else "No" + + rows.append( + [ + player_name, + team_abbrev_display, + total_games, + start_str, + end_str, + active_str, + ] + ) + + # Build title + title = f"Injuries - Season {season}" + if team_abbrev: + title += f" ({team_abbrev})" + if active: + title += " (Active Only)" + + output_table(title, ["Player", "Team", "Games", "Start", "End", "Active"], rows) + + except typer.Exit: + raise + except Exception as e: + handle_error(e) + + +# Make list the default command when running 'majordomo injuries' +@injuries_app.callback(invoke_without_command=True) +def injuries_default(ctx: typer.Context): + """Default to list command if no subcommand specified""" + if ctx.invoked_subcommand is None: + ctx.invoke(injuries_list) diff --git a/skills/major-domo/cli_results.py b/skills/major-domo/cli_results.py new file mode 100644 index 0000000..719c52e --- /dev/null +++ b/skills/major-domo/cli_results.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +""" +Major Domo CLI - Game Results Operations + +Commands for listing and filtering game results by team, week, and season. +""" + +from typing import Annotated, Optional + +import typer +from cli_common import ( + state, + console, + output_json, + output_table, + handle_error, + get_season, +) + +results_app = typer.Typer( + help="Game results operations", + invoke_without_command=True, + no_args_is_help=False, +) + + +@results_app.callback() +def results_callback( + ctx: typer.Context, + team: Annotated[ + Optional[str], typer.Option("--team", "-t", help="Filter by team abbreviation") + ] = None, + week: Annotated[ + Optional[int], typer.Option("--week", "-w", help="Filter by specific week") + ] = None, + week_start: Annotated[ + Optional[int], typer.Option("--week-start", help="Start week for range") + ] = None, + week_end: Annotated[ + Optional[int], typer.Option("--week-end", help="End week for range") + ] = None, + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, + limit: Annotated[ + int, typer.Option("--limit", "-n", help="Max results to display") + ] = 50, +): + """ + List game results with optional filters. + + By default, shows recent game results for the current season. + Use filters to narrow down results. + + Examples: + majordomo results --team CAR + majordomo results --week 5 + majordomo results --week-start 1 --week-end 4 + """ + # Only invoke if no subcommand was called + if ctx.invoked_subcommand is not None: + return + + try: + season = get_season(season) + + # Handle week vs week_start/week_end + if week is not None: + week_start = week + week_end = week + + # Use low-level API to access all parameters + result = state.api.get( + "results", + season=season, + team_abbrev=team, + week_start=week_start, + week_end=week_end, + short_output=False, + ) + results_list = result.get("results", []) + + # Limit results + results_list = results_list[:limit] + + if state.json_output: + output_json(results_list) + return + + if not results_list: + console.print( + f"[yellow]No results found for the given filters (Season {season})[/yellow]" + ) + return + + # Build table rows + rows = [] + for r in results_list: + away_team = r.get("away_team", {}) + home_team = r.get("home_team", {}) + away_score = r.get("away_score", 0) + home_score = r.get("home_score", 0) + + away_abbrev = ( + away_team.get("abbrev", "N/A") if isinstance(away_team, dict) else "N/A" + ) + home_abbrev = ( + home_team.get("abbrev", "N/A") if isinstance(home_team, dict) else "N/A" + ) + + # Format score as "away_score-home_score" + score_str = f"{away_score}-{home_score}" + + rows.append( + [ + r.get("week", ""), + r.get("game_num", ""), + away_abbrev, + "@", + home_abbrev, + score_str, + ] + ) + + # Build title with filters + title_parts = [f"Game Results (Season {season})"] + if team: + title_parts.append(f"Team: {team}") + if week: + title_parts.append(f"Week {week}") + elif week_start and week_end: + title_parts.append(f"Weeks {week_start}-{week_end}") + + title = " | ".join(title_parts) + + output_table(title, ["Week", "Gm", "Away", "@", "Home", "Score"], rows) + + except Exception as e: + handle_error(e) + + +@results_app.command("list") +def results_list( + team: Annotated[ + Optional[str], typer.Option("--team", "-t", help="Filter by team abbreviation") + ] = None, + week: Annotated[ + Optional[int], typer.Option("--week", "-w", help="Filter by specific week") + ] = None, + week_start: Annotated[ + Optional[int], typer.Option("--week-start", help="Start week for range") + ] = None, + week_end: Annotated[ + Optional[int], typer.Option("--week-end", help="End week for range") + ] = None, + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, + limit: Annotated[ + int, typer.Option("--limit", "-n", help="Max results to display") + ] = 50, +): + """ + List game results with optional filters (explicit command). + + This is the same as calling 'majordomo results' without a subcommand. + """ + try: + season = get_season(season) + + # Handle week vs week_start/week_end + if week is not None: + week_start = week + week_end = week + + # Use low-level API to access all parameters + result = state.api.get( + "results", + season=season, + team_abbrev=team, + week_start=week_start, + week_end=week_end, + short_output=False, + ) + results_list = result.get("results", []) + + # Limit results + results_list = results_list[:limit] + + if state.json_output: + output_json(results_list) + return + + if not results_list: + console.print( + f"[yellow]No results found for the given filters (Season {season})[/yellow]" + ) + return + + # Build table rows + rows = [] + for r in results_list: + away_team = r.get("away_team", {}) + home_team = r.get("home_team", {}) + away_score = r.get("away_score", 0) + home_score = r.get("home_score", 0) + + away_abbrev = ( + away_team.get("abbrev", "N/A") if isinstance(away_team, dict) else "N/A" + ) + home_abbrev = ( + home_team.get("abbrev", "N/A") if isinstance(home_team, dict) else "N/A" + ) + + # Format score as "away_score-home_score" + score_str = f"{away_score}-{home_score}" + + rows.append( + [ + r.get("week", ""), + r.get("game_num", ""), + away_abbrev, + "@", + home_abbrev, + score_str, + ] + ) + + # Build title with filters + title_parts = [f"Game Results (Season {season})"] + if team: + title_parts.append(f"Team: {team}") + if week: + title_parts.append(f"Week {week}") + elif week_start and week_end: + title_parts.append(f"Weeks {week_start}-{week_end}") + + title = " | ".join(title_parts) + + output_table(title, ["Week", "Gm", "Away", "@", "Home", "Score"], rows) + + except Exception as e: + handle_error(e) diff --git a/skills/major-domo/cli_schedule.py b/skills/major-domo/cli_schedule.py new file mode 100644 index 0000000..08bbaef --- /dev/null +++ b/skills/major-domo/cli_schedule.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +""" +Major Domo CLI - Schedule Operations + +Commands for listing and filtering game schedules. +""" + +from typing import Annotated, Optional + +import typer +from cli_common import ( + state, + console, + output_json, + output_table, + handle_error, + get_season, +) + +schedule_app = typer.Typer( + help="Schedule operations", + invoke_without_command=True, + no_args_is_help=False, +) + + +@schedule_app.callback() +def schedule_callback( + ctx: typer.Context, + team: Annotated[ + Optional[str], typer.Option("--team", "-t", help="Filter by team abbreviation") + ] = None, + week: Annotated[ + Optional[int], typer.Option("--week", "-w", help="Filter by specific week") + ] = None, + week_start: Annotated[ + Optional[int], typer.Option("--week-start", help="Start week for range") + ] = None, + week_end: Annotated[ + Optional[int], typer.Option("--week-end", help="End week for range") + ] = None, + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, + limit: Annotated[ + int, typer.Option("--limit", "-n", help="Max results to display") + ] = 50, +): + """ + List game schedules with optional filters. + + By default, shows recent schedules for the current season. + Use filters to narrow down results. + + Examples: + majordomo schedule --team CAR + majordomo schedule --week 5 + majordomo schedule --week-start 1 --week-end 4 + """ + # Only invoke if no subcommand was called + if ctx.invoked_subcommand is not None: + return + + try: + season = get_season(season) + + # Handle week vs week_start/week_end + if week is not None: + week_start = week + week_end = week + + # Get schedules from API using low-level get method + result = state.api.get( + "schedules", + season=season, + team_abbrev=team, + week_start=week_start, + week_end=week_end, + short_output=False, + ) + schedules = result.get("schedules", []) + + # Limit results + schedules = schedules[:limit] + + if state.json_output: + output_json(schedules) + return + + if not schedules: + console.print( + f"[yellow]No schedules found for the given filters (Season {season})[/yellow]" + ) + return + + # Build table rows + rows = [] + for s in schedules: + away_team = s.get("away_team", {}) + home_team = s.get("home_team", {}) + + away_abbrev = ( + away_team.get("abbrev", "N/A") if isinstance(away_team, dict) else "N/A" + ) + home_abbrev = ( + home_team.get("abbrev", "N/A") if isinstance(home_team, dict) else "N/A" + ) + + rows.append( + [ + s.get("week", ""), + s.get("game_num", ""), + away_abbrev, + "@", + home_abbrev, + ] + ) + + # Build title with filters + title_parts = [f"Schedule (Season {season})"] + if team: + title_parts.append(f"Team: {team}") + if week: + title_parts.append(f"Week {week}") + elif week_start and week_end: + title_parts.append(f"Weeks {week_start}-{week_end}") + + title = " | ".join(title_parts) + + output_table(title, ["Week", "Gm", "Away", "@", "Home"], rows) + + except Exception as e: + handle_error(e) + + +@schedule_app.command("list") +def schedule_list( + team: Annotated[ + Optional[str], typer.Option("--team", "-t", help="Filter by team abbreviation") + ] = None, + week: Annotated[ + Optional[int], typer.Option("--week", "-w", help="Filter by specific week") + ] = None, + week_start: Annotated[ + Optional[int], typer.Option("--week-start", help="Start week for range") + ] = None, + week_end: Annotated[ + Optional[int], typer.Option("--week-end", help="End week for range") + ] = None, + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, + limit: Annotated[ + int, typer.Option("--limit", "-n", help="Max results to display") + ] = 50, +): + """ + List game schedules with optional filters (explicit command). + + This is the same as calling 'majordomo schedule' without a subcommand. + """ + try: + season = get_season(season) + + # Handle week vs week_start/week_end + if week is not None: + week_start = week + week_end = week + + # Get schedules from API using low-level get method + result = state.api.get( + "schedules", + season=season, + team_abbrev=team, + week_start=week_start, + week_end=week_end, + short_output=False, + ) + schedules = result.get("schedules", []) + + # Limit results + schedules = schedules[:limit] + + if state.json_output: + output_json(schedules) + return + + if not schedules: + console.print( + f"[yellow]No schedules found for the given filters (Season {season})[/yellow]" + ) + return + + # Build table rows + rows = [] + for s in schedules: + away_team = s.get("away_team", {}) + home_team = s.get("home_team", {}) + + away_abbrev = ( + away_team.get("abbrev", "N/A") if isinstance(away_team, dict) else "N/A" + ) + home_abbrev = ( + home_team.get("abbrev", "N/A") if isinstance(home_team, dict) else "N/A" + ) + + rows.append( + [ + s.get("week", ""), + s.get("game_num", ""), + away_abbrev, + "@", + home_abbrev, + ] + ) + + # Build title with filters + title_parts = [f"Schedule (Season {season})"] + if team: + title_parts.append(f"Team: {team}") + if week: + title_parts.append(f"Week {week}") + elif week_start and week_end: + title_parts.append(f"Weeks {week_start}-{week_end}") + + title = " | ".join(title_parts) + + output_table(title, ["Week", "Gm", "Away", "@", "Home"], rows) + + except Exception as e: + handle_error(e) diff --git a/skills/major-domo/cli_stats.py b/skills/major-domo/cli_stats.py new file mode 100644 index 0000000..afd3d4d --- /dev/null +++ b/skills/major-domo/cli_stats.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +""" +Major Domo CLI - Statistics Operations + +This module provides batting and pitching statistics leaderboard commands. +""" + +from typing import Annotated, Optional + +import typer + +from cli_common import ( + console, + state, + output_json, + output_table, + handle_error, + get_season, +) + +stats_app = typer.Typer(help="Season statistics") + + +def _format_rate_stat(value: Optional[float], decimals: int = 3) -> str: + """Format a rate stat like AVG/OBP/SLG with consistent decimal places""" + if value is None: + return "---" + return f"{value:.{decimals}f}" + + +def _outs_to_ip(outs: int) -> str: + """Convert outs pitched to IP display format (e.g., 450 outs = '150.0')""" + innings = outs // 3 + partial = outs % 3 + return f"{innings}.{partial}" + + +@stats_app.command("batting") +def stats_batting( + sort: Annotated[ + str, + typer.Option( + "--sort", + "-S", + help="Sort field (woba, avg, obp, slg, ops, homerun, rbi, run, etc.)", + ), + ] = "woba", + min_pa: Annotated[ + Optional[int], + typer.Option("--min-pa", help="Minimum plate appearances (default: None)"), + ] = None, + team: Annotated[ + Optional[str], + typer.Option("--team", "-t", help="Filter by team abbreviation"), + ] = None, + limit: Annotated[ + int, typer.Option("--limit", "-n", help="Maximum results to display") + ] = 25, + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, + order: Annotated[ + str, + typer.Option("--order", help="Sort order (asc or desc)"), + ] = "desc", +): + """Batting statistics leaderboard""" + try: + season = get_season(season) + + # Resolve team abbreviation to team ID if provided + team_id = None + team_abbrev = None + if team: + team_abbrev = team.upper() + try: + team_obj = state.api.get_team(abbrev=team_abbrev, season=season) + team_id = team_obj["id"] + except Exception: + console.print( + f"[red]Error:[/red] Team '{team_abbrev}' not found in season {season}" + ) + raise typer.Exit(1) + + # Fetch batting stats + stats = state.api.get_season_batting_stats( + season=season, + team_id=team_id, + min_pa=min_pa, + sort_by=sort, + sort_order=order, + limit=limit, + ) + + if state.json_output: + output_json(stats) + return + + if not stats: + filter_parts = [] + if team_abbrev: + filter_parts.append(f"team {team_abbrev}") + if min_pa: + filter_parts.append(f"min {min_pa} PA") + filter_str = " (" + ", ".join(filter_parts) + ")" if filter_parts else "" + console.print( + f"[yellow]No batting stats found for season {season}{filter_str}[/yellow]" + ) + return + + # Format table rows + rows = [] + for rank, stat in enumerate(stats, start=1): + rows.append( + [ + rank, + stat.get("name", "N/A"), + stat.get("player_team_abbrev", "N/A"), + stat.get("pa", 0), + _format_rate_stat(stat.get("avg")), + _format_rate_stat(stat.get("obp")), + _format_rate_stat(stat.get("slg")), + _format_rate_stat(stat.get("ops")), + _format_rate_stat(stat.get("woba")), + stat.get("homerun", 0), + stat.get("rbi", 0), + ] + ) + + # Build title + title = f"Batting Leaders - Season {season}" + if team_abbrev: + title += f" ({team_abbrev})" + if min_pa: + title += f" (Min {min_pa} PA)" + title += f" - Sorted by {sort.upper()} ({order})" + + output_table( + title, + [ + "#", + "Name", + "Team", + "PA", + "AVG", + "OBP", + "SLG", + "OPS", + "wOBA", + "HR", + "RBI", + ], + rows, + ) + + except typer.Exit: + raise + except Exception as e: + handle_error(e) + + +@stats_app.command("pitching") +def stats_pitching( + sort: Annotated[ + str, + typer.Option( + "--sort", "-S", help="Sort field (era, whip, so, win, saves, etc.)" + ), + ] = "era", + min_outs: Annotated[ + Optional[int], + typer.Option("--min-outs", help="Minimum outs pitched (default: None)"), + ] = None, + team: Annotated[ + Optional[str], + typer.Option("--team", "-t", help="Filter by team abbreviation"), + ] = None, + limit: Annotated[ + int, typer.Option("--limit", "-n", help="Maximum results to display") + ] = 25, + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, + order: Annotated[ + str, + typer.Option("--order", help="Sort order (asc or desc)"), + ] = "asc", +): + """Pitching statistics leaderboard""" + try: + season = get_season(season) + + # Resolve team abbreviation to team ID if provided + team_id = None + team_abbrev = None + if team: + team_abbrev = team.upper() + try: + team_obj = state.api.get_team(abbrev=team_abbrev, season=season) + team_id = team_obj["id"] + except Exception: + console.print( + f"[red]Error:[/red] Team '{team_abbrev}' not found in season {season}" + ) + raise typer.Exit(1) + + # Fetch pitching stats + stats = state.api.get_season_pitching_stats( + season=season, + team_id=team_id, + min_outs=min_outs, + sort_by=sort, + sort_order=order, + limit=limit, + ) + + if state.json_output: + output_json(stats) + return + + if not stats: + filter_parts = [] + if team_abbrev: + filter_parts.append(f"team {team_abbrev}") + if min_outs: + filter_parts.append(f"min {min_outs} outs") + filter_str = " (" + ", ".join(filter_parts) + ")" if filter_parts else "" + console.print( + f"[yellow]No pitching stats found for season {season}{filter_str}[/yellow]" + ) + return + + # Format table rows + rows = [] + for rank, stat in enumerate(stats, start=1): + outs = stat.get("outs", 0) + rows.append( + [ + rank, + stat.get("name", "N/A"), + stat.get("player_team_abbrev", "N/A"), + _outs_to_ip(outs), + _format_rate_stat(stat.get("era"), decimals=2), + _format_rate_stat(stat.get("whip"), decimals=2), + stat.get("win", 0), + stat.get("loss", 0), + stat.get("saves", 0), + stat.get("so", 0), + stat.get("bb", 0), + ] + ) + + # Build title + title = f"Pitching Leaders - Season {season}" + if team_abbrev: + title += f" ({team_abbrev})" + if min_outs: + title += f" (Min {min_outs} outs)" + title += f" - Sorted by {sort.upper()} ({order})" + + output_table( + title, + ["#", "Name", "Team", "IP", "ERA", "WHIP", "W", "L", "SV", "SO", "BB"], + rows, + ) + + except typer.Exit: + raise + except Exception as e: + handle_error(e) + + +# Make batting the default command when running 'majordomo stats' +@stats_app.callback(invoke_without_command=True) +def stats_default(ctx: typer.Context): + """Default to batting command if no subcommand specified""" + if ctx.invoked_subcommand is None: + ctx.invoke(stats_batting) diff --git a/skills/major-domo/cli_transactions.py b/skills/major-domo/cli_transactions.py new file mode 100644 index 0000000..48d22cb --- /dev/null +++ b/skills/major-domo/cli_transactions.py @@ -0,0 +1,467 @@ +#!/usr/bin/env python3 +""" +Major Domo CLI - Transaction Operations + +Commands for listing and filtering player transactions (trades, waivers, etc.). +""" + +from typing import Annotated, List, Optional + +import typer +from rich.panel import Panel +from cli_common import ( + state, + console, + output_json, + output_table, + handle_error, + get_season, +) + +transactions_app = typer.Typer( + help="Transaction operations", + invoke_without_command=True, + no_args_is_help=False, +) + + +@transactions_app.callback() +def transactions_callback( + ctx: typer.Context, + team: Annotated[ + Optional[str], typer.Option("--team", "-t", help="Filter by team abbreviation") + ] = None, + week: Annotated[ + Optional[int], typer.Option("--week", "-w", help="Filter by specific week") + ] = None, + week_start: Annotated[ + Optional[int], typer.Option("--week-start", help="Start week for range") + ] = None, + week_end: Annotated[ + Optional[int], typer.Option("--week-end", help="End week for range") + ] = None, + player: Annotated[ + Optional[str], typer.Option("--player", "-p", help="Filter by player name") + ] = None, + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, + limit: Annotated[ + int, typer.Option("--limit", "-n", help="Max results to display") + ] = 50, + include_cancelled: Annotated[ + bool, typer.Option("--include-cancelled", help="Include cancelled transactions") + ] = False, +): + """ + List transactions with optional filters. + + By default, shows recent non-cancelled transactions for the current season. + Use filters to narrow down results. + + Examples: + majordomo transactions --team CAR + majordomo transactions --week 5 + majordomo transactions --week-start 1 --week-end 4 + majordomo transactions --player "Mike Trout" + """ + # Only invoke if no subcommand was called + if ctx.invoked_subcommand is not None: + return + + try: + season = get_season(season) + + # Handle week vs week_start/week_end + if week is not None: + week_start = week + week_end = week + + # Build filter parameters + team_list = [team] if team else None + player_list = [player] if player else None + + # Get transactions from API + transactions = state.api.get_transactions( + season=season, + team_abbrev=team_list, + week_start=week_start, + week_end=week_end, + player_name=player_list, + cancelled=True if include_cancelled else False, + ) + + # Filter out cancelled transactions unless explicitly requested + if not include_cancelled: + transactions = [t for t in transactions if not t.get("cancelled", False)] + + # Limit results + transactions = transactions[:limit] + + if state.json_output: + output_json(transactions) + return + + if not transactions: + console.print( + f"[yellow]No transactions found for the given filters (Season {season})[/yellow]" + ) + return + + # Build table rows + rows = [] + for t in transactions: + player_dict = t.get("player", {}) + oldteam_dict = t.get("oldteam", {}) + newteam_dict = t.get("newteam", {}) + + player_name = ( + player_dict.get("name", "N/A") + if isinstance(player_dict, dict) + else "N/A" + ) + old_abbrev = ( + oldteam_dict.get("abbrev", "N/A") + if isinstance(oldteam_dict, dict) + else "N/A" + ) + new_abbrev = ( + newteam_dict.get("abbrev", "N/A") + if isinstance(newteam_dict, dict) + else "N/A" + ) + + rows.append( + [ + t.get("week", ""), + player_name, + old_abbrev, + new_abbrev, + t.get("moveid", ""), + ] + ) + + # Build title with filters + title_parts = [f"Transactions (Season {season})"] + if team: + title_parts.append(f"Team: {team}") + if week: + title_parts.append(f"Week {week}") + elif week_start and week_end: + title_parts.append(f"Weeks {week_start}-{week_end}") + if player: + title_parts.append(f"Player: {player}") + + title = " | ".join(title_parts) + + output_table(title, ["Week", "Player", "From", "To", "Move ID"], rows) + + except Exception as e: + handle_error(e) + + +@transactions_app.command("list") +def transactions_list( + team: Annotated[ + Optional[str], typer.Option("--team", "-t", help="Filter by team abbreviation") + ] = None, + week: Annotated[ + Optional[int], typer.Option("--week", "-w", help="Filter by specific week") + ] = None, + week_start: Annotated[ + Optional[int], typer.Option("--week-start", help="Start week for range") + ] = None, + week_end: Annotated[ + Optional[int], typer.Option("--week-end", help="End week for range") + ] = None, + player: Annotated[ + Optional[str], typer.Option("--player", "-p", help="Filter by player name") + ] = None, + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, + limit: Annotated[ + int, typer.Option("--limit", "-n", help="Max results to display") + ] = 50, + include_cancelled: Annotated[ + bool, typer.Option("--include-cancelled", help="Include cancelled transactions") + ] = False, +): + """ + List transactions with optional filters (explicit command). + + This is the same as calling 'majordomo transactions' without a subcommand. + """ + try: + season = get_season(season) + + # Handle week vs week_start/week_end + if week is not None: + week_start = week + week_end = week + + # Build filter parameters + team_list = [team] if team else None + player_list = [player] if player else None + + # Get transactions from API + transactions = state.api.get_transactions( + season=season, + team_abbrev=team_list, + week_start=week_start, + week_end=week_end, + player_name=player_list, + cancelled=True if include_cancelled else False, + ) + + # Filter out cancelled transactions unless explicitly requested + if not include_cancelled: + transactions = [t for t in transactions if not t.get("cancelled", False)] + + # Limit results + transactions = transactions[:limit] + + if state.json_output: + output_json(transactions) + return + + if not transactions: + console.print( + f"[yellow]No transactions found for the given filters (Season {season})[/yellow]" + ) + return + + # Build table rows + rows = [] + for t in transactions: + player_dict = t.get("player", {}) + oldteam_dict = t.get("oldteam", {}) + newteam_dict = t.get("newteam", {}) + + player_name = ( + player_dict.get("name", "N/A") + if isinstance(player_dict, dict) + else "N/A" + ) + old_abbrev = ( + oldteam_dict.get("abbrev", "N/A") + if isinstance(oldteam_dict, dict) + else "N/A" + ) + new_abbrev = ( + newteam_dict.get("abbrev", "N/A") + if isinstance(newteam_dict, dict) + else "N/A" + ) + + rows.append( + [ + t.get("week", ""), + player_name, + old_abbrev, + new_abbrev, + t.get("moveid", ""), + ] + ) + + # Build title with filters + title_parts = [f"Transactions (Season {season})"] + if team: + title_parts.append(f"Team: {team}") + if week: + title_parts.append(f"Week {week}") + elif week_start and week_end: + title_parts.append(f"Weeks {week_start}-{week_end}") + if player: + title_parts.append(f"Player: {player}") + + title = " | ".join(title_parts) + + output_table(title, ["Week", "Player", "From", "To", "Move ID"], rows) + + except Exception as e: + handle_error(e) + + +@transactions_app.command("simulate") +def transactions_simulate( + team: Annotated[str, typer.Argument(help="Team abbreviation (e.g., CLS)")], + moves: Annotated[ + str, + typer.Argument( + help="Moves as 'Player:Target,Player:Target' (e.g., 'Stott:CLSMiL,Walker:CLS')" + ), + ], + season: Annotated[ + Optional[int], typer.Option("--season", "-s", help="Season number") + ] = None, +): + """ + Simulate transactions and check compliance without making changes. + + Validates that after all proposed moves: + - Active (ML) roster has exactly 26 players + - Total ML sWAR does not exceed team salary cap + + Examples: + transactions simulate CLS "Stott:CLSMiL,Walker:CLS,Castellanos:FA,Martin:CLS" + transactions simulate DEN "Player1:DENMiL,Player2:DEN" + """ + try: + season = get_season(season) + team_upper = team.upper() + + # Parse moves + parsed_moves: List[tuple] = [] + for move_str in moves.split(","): + move_str = move_str.strip() + if not move_str: + continue + parts = move_str.split(":") + if len(parts) != 2 or not parts[0].strip() or not parts[1].strip(): + console.print(f"[red]Invalid move format:[/red] '{move_str}'") + console.print("Expected format: 'Player Name:TARGET'") + raise typer.Exit(1) + parsed_moves.append((parts[0].strip(), parts[1].strip().upper())) + + if not parsed_moves: + console.print("[red]No moves provided[/red]") + raise typer.Exit(1) + + # Get team info (includes salary_cap) + team_data = state.api.get_team(abbrev=team_upper, season=season) + salary_cap = team_data.get("salary_cap") + + # Get current roster + roster = state.api.get_team_roster(team_id=team_data["id"], which="current") + active_players = roster.get("active", {}).get("players", []) + + # Build current ML state: {player_name: wara} + ml_roster = {} + for p in active_players: + ml_roster[p["name"]] = float(p.get("wara", 0) or 0) + + current_count = len(ml_roster) + current_swar = sum(ml_roster.values()) + + # Simulate each move + move_details = [] + errors = [] + + for player_name, target in parsed_moves: + # Look up the player + player = state.api.get_player(name=player_name, season=season) + if not player: + search = state.api.search_players( + query=player_name, season=season, limit=1 + ) + if search: + player = search[0] + else: + errors.append(f"Player '{player_name}' not found") + continue + + p_name = player["name"] + p_wara = float(player.get("wara", 0) or 0) + p_team = ( + player.get("team", {}).get("abbrev", "?") + if isinstance(player.get("team"), dict) + else "?" + ) + + # Determine if this move adds to or removes from ML + target_is_ml = target == team_upper + currently_on_ml = p_name in ml_roster + + impact = "" + + if currently_on_ml and not target_is_ml: + # Removing from ML (demote, release, trade away) + del ml_roster[p_name] + impact = f"[red]-{p_wara:.2f}[/red]" + elif not currently_on_ml and target_is_ml: + # Adding to ML (promote, sign, trade in) + ml_roster[p_name] = p_wara + impact = f"[green]+{p_wara:.2f}[/green]" + elif currently_on_ml and target_is_ml: + impact = "[dim]no change (already ML)[/dim]" + else: + impact = "[dim]no ML impact[/dim]" + + move_details.append( + (p_name, f"{p_team} → {target}", f"{p_wara:.2f}", impact) + ) + + # Calculate post-transaction state + post_count = len(ml_roster) + post_swar = sum(ml_roster.values()) + + # Output + if state.json_output: + output_json( + { + "team": team_upper, + "salary_cap": salary_cap, + "before": { + "ml_count": current_count, + "ml_swar": round(current_swar, 2), + }, + "after": { + "ml_count": post_count, + "ml_swar": round(post_swar, 2), + }, + "moves": [ + {"player": m[0], "move": m[1], "wara": m[2]} + for m in move_details + ], + "errors": errors, + "roster_ok": post_count == 26, + "cap_ok": post_swar <= salary_cap if salary_cap else True, + } + ) + return + + # Print errors if any + for err in errors: + console.print(f"[red]Error:[/red] {err}") + + # Moves table + output_table( + f"Simulated Moves for {team_upper}", + ["Player", "Move", "WARA", "ML Impact"], + move_details, + ) + + console.print() + + # Compliance summary + roster_ok = post_count == 26 + cap_ok = post_swar <= salary_cap if salary_cap else True + cap_space = salary_cap - post_swar if salary_cap else 0 + + roster_status = "[green]PASS[/green]" if roster_ok else "[red]FAIL[/red]" + cap_status = "[green]PASS[/green]" if cap_ok else "[red]FAIL[/red]" + + panel = Panel( + f"[bold]ML Roster:[/bold] {current_count} → {post_count} {roster_status}\n" + f"[bold]ML sWAR:[/bold] {current_swar:.2f} → {post_swar:.2f}\n" + f"[bold]Salary Cap:[/bold] {salary_cap:.1f}\n" + f"[bold]Cap Space:[/bold] {cap_space:.2f} {cap_status}", + title="Compliance Check", + border_style="green" if (roster_ok and cap_ok) else "red", + ) + console.print(panel) + + if not roster_ok: + console.print( + f"\n[red]Roster must be exactly 26. Currently {post_count}.[/red]" + ) + if not cap_ok: + console.print( + f"\n[red]Over salary cap by {post_swar - salary_cap:.2f} sWAR.[/red]" + ) + + except typer.Exit: + raise + except Exception as e: + handle_error(e)