claude-configs/skills/major-domo/cli_transactions.py
Cal Corum 43d32e9b9d Update major-domo skill CLI refactor and plugin/config updates
- Refactor major-domo skill: api_client.py, cli.py, and CLI modules (admin, common, injuries, results, schedule, transactions) with significant simplification (-275 lines net)
- Update CLI_REFERENCE.md and SKILL.md docs for major-domo
- Update create-scheduled-task SKILL.md
- Update plugins blocklist.json and known_marketplaces.json
- Add patterns/ directory to repo
- Update CLAUDE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 02:00:41 -05:00

441 lines
14 KiB
Python

#!/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,
safe_nested,
)
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 — include affiliate rosters (IL, MiL)
if team:
base = team.upper().removesuffix("MIL").removesuffix("IL")
team_list = [base, f"{base}IL", f"{base}MiL"]
else:
team_list = 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_name = safe_nested(t, "player", "name")
old_abbrev = safe_nested(t, "oldteam", "abbrev")
new_abbrev = safe_nested(t, "newteam", "abbrev")
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 — include affiliate rosters (IL, MiL)
if team:
base = team.upper().removesuffix("MIL").removesuffix("IL")
team_list = [base, f"{base}IL", f"{base}MiL"]
else:
team_list = 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_name = safe_nested(t, "player", "name")
old_abbrev = safe_nested(t, "oldteam", "abbrev")
new_abbrev = safe_nested(t, "newteam", "abbrev")
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 = safe_nested(player, "team", "abbrev", default="?")
# 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)