#!/usr/bin/env python3 """ Major Domo CLI - SBA League Management A command-line interface for the Major Domo API, primarily for use with Claude Code. Usage: majordomo status majordomo player get "Mike Trout" majordomo player search "trout" majordomo player move "Mike Trout" CAR majordomo player move --batch "Name1:Team1,Name2:Team2" majordomo team list majordomo team roster CAR majordomo standings --division ALE Environment: API_TOKEN: Required. Bearer token for API authentication. """ import json import os import sys from typing import Annotated, List, 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 sys.path.insert(0, os.path.dirname(__file__)) from api_client import MajorDomoAPI # ============================================================================ # App Setup # ============================================================================ app = typer.Typer( name="majordomo", help="Major Domo SBA League Management CLI", no_args_is_help=True, ) player_app = typer.Typer(help="Player operations") team_app = typer.Typer(help="Team operations") app.add_typer(player_app, name="player") app.add_typer(team_app, name="team") 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() # ============================================================================ # 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 # ============================================================================ # Main Callback (Global Options) # ============================================================================ @app.callback() def main( 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, ): """Major Domo SBA League Management CLI""" try: state.api = MajorDomoAPI(environment=env, verbose=verbose) state.json_output = json_output # Cache current season current = state.api.get_current() state.current_season = current['season'] except ValueError as e: console.print(f"[red]Configuration Error:[/red] {e}") console.print("\nSet API_TOKEN environment variable:") console.print(" export API_TOKEN='your-token-here'") raise typer.Exit(1) except Exception as e: handle_error(e) # ============================================================================ # Status & Health Commands # ============================================================================ @app.command() def status(): """Show current season/week status""" try: current = state.api.get_current() if state.json_output: output_json(current) return panel = Panel( f"[bold]Season:[/bold] {current['season']}\n" f"[bold]Week:[/bold] {current['week']}\n" f"[bold]Frozen:[/bold] {'Yes' if current.get('freeze') else 'No'}\n" f"[bold]Trade Deadline:[/bold] Week {current.get('trade_deadline', 'N/A')}\n" f"[bold]Playoffs Begin:[/bold] Week {current.get('playoffs_begin', 'N/A')}", title="SBA League Status", border_style="green", ) console.print(panel) except Exception as e: handle_error(e) @app.command() def health(): """Check API health status""" try: healthy = state.api.health_check() if healthy: console.print(f"[green]API is healthy[/green] ({state.api.base_url})") else: console.print("[red]API is not responding[/red]") raise typer.Exit(1) except Exception as e: handle_error(e) # ============================================================================ # 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, ): """Get player information by name""" try: season = get_season(season) 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\"") raise typer.Exit(1) if state.json_output: output_json(player) return # Extract nested team info 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}')] panel = Panel( f"[bold]ID:[/bold] {player['id']}\n" f"[bold]Name:[/bold] {player['name']}\n" f"[bold]Team:[/bold] {team_abbrev}\n" f"[bold]Position(s):[/bold] {', '.join(positions) if positions else 'N/A'}\n" f"[bold]WARA:[/bold] {player.get('wara', 0):.2f}\n" f"[bold]Strat Code:[/bold] {player.get('strat_code', 'N/A')}\n" f"[bold]IL Return:[/bold] {player.get('il_return') or 'Healthy'}", title=f"Player: {player['name']}", border_style="blue", ) console.print(panel) except typer.Exit: raise except Exception as e: handle_error(e, f"Player '{name}' may not exist.") @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, limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 10, ): """Search players by name (fuzzy match)""" try: season = get_season(season) players = state.api.search_players(query=query, season=season, limit=limit) if state.json_output: output_json(players) return if not players: 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}" ]) output_table( f"Search Results: '{query}' (Season {season})", ["ID", "Name", "Team", "Pos", "WARA"], rows ) except Exception as e: handle_error(e) @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, ): """Move player(s) to a new team""" try: season = get_season(season) # Parse moves if batch: moves = [] for move_str in batch.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 batch format:[/red] '{move_str}'") console.print("Expected format: 'Player Name:TEAM'") raise typer.Exit(1) moves.append((parts[0].strip(), parts[1].strip().upper())) elif name and team: moves = [(name, team.upper())] else: 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\"") raise typer.Exit(1) results = [] for player_name, team_abbrev in moves: # Find player 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) if search_results: player = search_results[0] else: 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' # 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")) continue if dry_run: 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}")) if state.json_output: json_results = [ {"player": r[0], "team": r[1], "status": r[2].replace("[", "").replace("]", "").split("/")[0], "message": r[3]} for r in results ] output_json(json_results) return title = "Player Moves" if dry_run: title += " (DRY RUN)" output_table(title, ["Player", "Target", "Status", "Message"], results) except typer.Exit: raise except Exception as e: handle_error(e) # ============================================================================ # 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, ): """List all teams""" try: season = get_season(season) teams = state.api.list_teams(season=season, active_only=active) if state.json_output: output_json(teams) return if not teams: console.print(f"[yellow]No teams found in season {season}[/yellow]") return 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]) output_table( f"Teams - Season {season}" + (" (Active Only)" if active else ""), ["Abbrev", "Name", "Division", "Manager"], rows ) except Exception as e: handle_error(e) @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, ): """Get team information""" try: season = get_season(season) team = state.api.get_team(abbrev=abbrev.upper(), season=season) if state.json_output: 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' panel = Panel( f"[bold]ID:[/bold] {team['id']}\n" f"[bold]Abbreviation:[/bold] {team['abbrev']}\n" f"[bold]Short Name:[/bold] {team.get('sname', 'N/A')}\n" 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]Stadium:[/bold] {team.get('stadium', 'N/A')}\n" f"[bold]Season:[/bold] {team.get('season', 'N/A')}", title=f"Team: {team.get('lname', abbrev)}", border_style="green", ) console.print(panel) except Exception as e: handle_error(e, f"Team '{abbrev}' may not exist.") @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, ): """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) if state.json_output: output_json(roster) return console.print(f"\n[bold cyan]{team.get('lname', abbrev)} Roster ({which.title()})[/bold cyan]\n") # Active roster 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) # Short IL 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) # Long IL 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) # Summary total = len(active) + len(short_il) + len(long_il) console.print(f"\n[dim]Total: {total} players[/dim]") except Exception as e: handle_error(e, f"Team '{abbrev}' may not exist.") # ============================================================================ # 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, ): """Show league standings""" try: season = get_season(season) standings_data = state.api.get_standings( season=season, division_abbrev=division.upper() if division else None, league_abbrev=league.upper() if league else None ) if state.json_output: output_json(standings_data) return if not standings_data: console.print(f"[yellow]No standings found for season {season}[/yellow]") return 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) total = wins + losses pct = f".{int(wins/total*1000):03d}" if total > 0 else ".000" 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]) title = f"Standings - Season {season}" if division: title += f" ({division.upper()})" elif league: title += f" ({league.upper()})" output_table(title, ["Team", "Name", "W", "L", "PCT", "RD"], rows) except Exception as e: handle_error(e) # ============================================================================ # Entry Point # ============================================================================ if __name__ == "__main__": app()