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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-16 11:24:18 -06:00
parent a4ae9774ad
commit 6201b4c9af
8 changed files with 1794 additions and 135 deletions

View File

@ -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

View File

@ -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])

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)