- Update blocklist.json, installed_plugins.json, known_marketplaces.json - Rotate sessions: remove 3 old, add 4 new - Update skills/major-domo/cli.py
597 lines
19 KiB
Python
Executable File
597 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.
|
|
Modular architecture: each command group is a separate cli_*.py file.
|
|
|
|
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
|
|
majordomo transactions --team CLS
|
|
majordomo injuries --active
|
|
majordomo stats batting --sort woba --min-pa 100
|
|
|
|
Environment:
|
|
API_TOKEN: Bearer token for API authentication (required for write operations only).
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from typing import Annotated, Optional
|
|
|
|
import typer
|
|
from rich.panel import Panel
|
|
|
|
# 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,
|
|
safe_nested,
|
|
)
|
|
|
|
# ============================================================================
|
|
# 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")
|
|
|
|
# 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
|
|
from cli_admin import admin_app
|
|
|
|
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")
|
|
app.add_typer(admin_app, name="admin")
|
|
|
|
|
|
# ============================================================================
|
|
# 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
|
|
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_abbrev = safe_nested(player, "team", "abbrev")
|
|
|
|
# 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_abbrev = safe_nested(p, "team", "abbrev")
|
|
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,
|
|
demotion_week: Annotated[
|
|
Optional[int],
|
|
typer.Option("--demotion-week", "-d", help="Demotion week 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_abbrev = safe_nested(player, "team", "abbrev")
|
|
|
|
# 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
|
|
update_kwargs = {"team_id": target_team["id"]}
|
|
if demotion_week is not None:
|
|
update_kwargs["demotion_week"] = demotion_week
|
|
state.api.update_player(player["id"], **update_kwargs)
|
|
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_name = safe_nested(t, "manager1", "name", default="")
|
|
div_abbrev = safe_nested(t, "division", "division_abbrev", default="")
|
|
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_name = safe_nested(team, "manager1", "name")
|
|
div_name = safe_nested(team, "division", "division_name")
|
|
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"
|
|
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]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)}",
|
|
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"
|
|
)
|
|
|
|
# Roster sections: (API key, display label, columns, row builder)
|
|
sections = [
|
|
(
|
|
"active",
|
|
"Active",
|
|
["Name", "Pos", "WARA"],
|
|
lambda p: [p["name"], p.get("pos_1", ""), f"{p.get('wara', 0):.2f}"],
|
|
),
|
|
(
|
|
"shortil",
|
|
"Injured List",
|
|
["Name", "Pos", "Return"],
|
|
lambda p: [p["name"], p.get("pos_1", ""), p.get("il_return", "")],
|
|
),
|
|
(
|
|
"longil",
|
|
"Minor League",
|
|
["Name", "Pos", "Return"],
|
|
lambda p: [p["name"], p.get("pos_1", ""), p.get("il_return", "")],
|
|
),
|
|
]
|
|
|
|
total = 0
|
|
for i, (key, label, columns, row_fn) in enumerate(sections):
|
|
players = roster.get(key, {}).get("players", [])
|
|
if players:
|
|
if i > 0:
|
|
console.print()
|
|
output_table(
|
|
f"{label} ({len(players)})", columns, [row_fn(p) for p in players]
|
|
)
|
|
total += len(players)
|
|
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_abbrev = safe_nested(s, "team", "abbrev")
|
|
team_name = safe_nested(s, "team", "lname")
|
|
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()
|