Version control Claude Code configuration including: - Global instructions (CLAUDE.md) - User settings (settings.json) - Custom agents (architect, designer, engineer, etc.) - Custom skills (create-skill templates and workflows) Excludes session data, secrets, cache, and temporary files per .gitignore. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
520 lines
19 KiB
Python
Executable File
520 lines
19 KiB
Python
Executable File
#!/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()
|