#!/usr/bin/env python3 """ Paper Dynasty CLI - Baseball Card Game Management A command-line interface for the Paper Dynasty API, primarily for use with Claude Code. Usage: pd status pd team list pd team get SKB pd team cards SKB pd pack today pd pack distribute --num 10 pd gauntlet list --event-id 8 --active pd gauntlet cleanup Gauntlet-SKB --event-id 8 --yes 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 PaperDynastyAPI # ============================================================================ # App Setup # ============================================================================ app = typer.Typer( name="pd", help="Paper Dynasty Baseball Card Game CLI", no_args_is_help=True, ) team_app = typer.Typer(help="Team operations") pack_app = typer.Typer(help="Pack operations") gauntlet_app = typer.Typer(help="Gauntlet operations") player_app = typer.Typer(help="Player operations") app.add_typer(team_app, name="team") app.add_typer(pack_app, name="pack") app.add_typer(gauntlet_app, name="gauntlet") app.add_typer(player_app, name="player") console = Console() class State: """Global state for API client and settings""" api: Optional[PaperDynastyAPI] = None json_output: bool = False 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) # ============================================================================ # 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, ): """Paper Dynasty Baseball Card Game CLI""" state.api = PaperDynastyAPI(environment=env, verbose=verbose) state.json_output = json_output # ============================================================================ # Status & Health Commands # ============================================================================ @app.command() def status(): """Show packs opened today summary""" try: result = state.api.get_packs_opened_today() if state.json_output: output_json(result) return console.print( f"\n[bold cyan]Packs Opened Today ({result['date']})[/bold cyan]\n" ) console.print(f"[bold]Total:[/bold] {result['total']} packs\n") if result["teams"]: rows = [] for t in result["teams"]: rows.append([t["abbrev"], t["name"], t["packs"]]) output_table("By Team", ["Abbrev", "Team", "Packs"], rows) else: console.print("[dim]No packs opened today[/dim]") if result.get("note"): console.print(f"\n[yellow]Note:[/yellow] {result['note']}") except Exception as e: handle_error(e) @app.command() def health(): """Check API health status""" try: # Try to list teams as a health check teams = state.api.list_teams() console.print(f"[green]API is healthy[/green] ({state.api.base_url})") console.print(f"[dim]Found {len(teams)} teams[/dim]") 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="Filter by season") ] = None, ): """List all teams""" try: teams = state.api.list_teams(season=season) if state.json_output: output_json(teams) return if not teams: console.print("[yellow]No teams found[/yellow]") return # Filter out gauntlet teams for cleaner display regular_teams = [t for t in teams if "Gauntlet" not in t.get("abbrev", "")] rows = [] for t in regular_teams: rows.append( [ t["abbrev"], t.get("sname", ""), t.get("season", ""), t.get("wallet", 0), t.get("ranking", "N/A"), "AI" if t.get("is_ai") else "Human", ] ) title = "Teams" if season: title += f" - Season {season}" output_table( title, ["Abbrev", "Name", "Season", "Wallet", "Rank", "Type"], rows ) except Exception as e: handle_error(e) @team_app.command("get") def team_get( abbrev: Annotated[str, typer.Argument(help="Team abbreviation")], ): """Get team details""" try: team = state.api.get_team(abbrev=abbrev.upper()) if state.json_output: output_json(team) return 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]Season:[/bold] {team.get('season', 'N/A')}\n" f"[bold]Wallet:[/bold] ${team.get('wallet', 0)}\n" f"[bold]Ranking:[/bold] {team.get('ranking', 'N/A')}\n" f"[bold]Type:[/bold] {'AI' if team.get('is_ai') else 'Human'}", title=f"Team: {team.get('lname', abbrev)}", border_style="green", ) console.print(panel) except ValueError as e: console.print(f"[red]Error:[/red] {e}") raise typer.Exit(1) except Exception as e: handle_error(e, f"Team '{abbrev}' may not exist.") @team_app.command("cards") def team_cards( abbrev: Annotated[str, typer.Argument(help="Team abbreviation")], limit: Annotated[int, typer.Option("--limit", "-n", help="Max cards to show")] = 50, ): """List team's cards""" try: team = state.api.get_team(abbrev=abbrev.upper()) cards = state.api.list_cards(team_id=team["id"]) if state.json_output: output_json(cards) return if not cards: console.print(f"[yellow]Team {abbrev} has no cards[/yellow]") return rows = [] for c in cards[:limit]: player = c.get("player", {}) rows.append( [ c["id"], player.get("p_name", "Unknown"), player.get("rarity", ""), c.get("value", 0), ] ) output_table( f"Cards for {team.get('lname', abbrev)} ({len(cards)} total)", ["Card ID", "Player", "Rarity", "Value"], rows, ) if len(cards) > limit: console.print( f"\n[dim]Showing {limit} of {len(cards)} cards. Use --limit to see more.[/dim]" ) except ValueError as e: console.print(f"[red]Error:[/red] {e}") raise typer.Exit(1) except Exception as e: handle_error(e) # ============================================================================ # Pack Commands # ============================================================================ @pack_app.command("list") def pack_list( team: Annotated[ Optional[str], typer.Option("--team", "-t", help="Filter by team abbrev") ] = None, opened: Annotated[ Optional[bool], typer.Option("--opened/--unopened", help="Filter by opened status"), ] = None, limit: Annotated[int, typer.Option("--limit", "-n", help="Max packs to show")] = 50, ): """List packs""" try: team_id = None team_name = None if team: team_obj = state.api.get_team(abbrev=team.upper()) team_id = team_obj["id"] team_name = team_obj.get("sname", team) packs = state.api.list_packs( team_id=team_id, opened=opened, new_to_old=True, limit=limit ) if state.json_output: output_json(packs) return if not packs: console.print("[yellow]No packs found[/yellow]") return rows = [] for p in packs: pack_team = p.get("team", {}) pack_type = p.get("pack_type", {}) is_opened = "Yes" if p.get("open_time") else "No" rows.append( [ p["id"], pack_team.get("abbrev", "N/A"), pack_type.get("name", "Unknown"), is_opened, ] ) title = "Packs" if team_name: title += f" - {team_name}" if opened is True: title += " (Opened)" elif opened is False: title += " (Unopened)" output_table(title, ["Pack ID", "Team", "Type", "Opened"], rows) except ValueError as e: console.print(f"[red]Error:[/red] {e}") raise typer.Exit(1) except Exception as e: handle_error(e) @pack_app.command("today") def pack_today(): """Show packs opened today analytics""" # Reuse status command status() @pack_app.command("distribute") def pack_distribute( num: Annotated[ int, typer.Option("--num", "-n", help="Number of packs per team") ] = 5, exclude: Annotated[ Optional[List[str]], typer.Option("--exclude", "-x", help="Team abbrevs to exclude"), ] = None, pack_type: Annotated[ int, typer.Option( "--pack-type", help="1=Standard, 2=Starter, 3=Premium, 4=Check-In, 5=MVP, 6=All Star, 7=Mario, 8=Team Choice, 9=Promo Choice", ), ] = 1, cardset: Annotated[ Optional[int], typer.Option( "--cardset", "-c", help="Cardset ID (required for Promo Choice packs)" ), ] = None, dry_run: Annotated[ bool, typer.Option("--dry-run", help="Show what would be done") ] = False, ): """Distribute packs to all human teams""" try: if dry_run: # Get qualifying teams to show preview current = state.api.get("current") season = current["season"] all_teams = state.api.list_teams(season=season) exclude_upper = [e.upper() for e in (exclude or [])] qualifying = [ t for t in all_teams if not t["is_ai"] and "gauntlet" not in t["abbrev"].lower() and t["abbrev"].upper() not in exclude_upper ] console.print( f"\n[bold cyan]Pack Distribution Preview (DRY RUN)[/bold cyan]\n" ) console.print(f"[bold]Packs per team:[/bold] {num}") console.print(f"[bold]Pack type:[/bold] {pack_type}") if cardset is not None: console.print(f"[bold]Cardset ID:[/bold] {cardset}") console.print(f"[bold]Teams:[/bold] {len(qualifying)}") console.print(f"[bold]Total packs:[/bold] {num * len(qualifying)}") if exclude: console.print(f"[bold]Excluded:[/bold] {', '.join(exclude)}") console.print("\n[bold]Qualifying teams:[/bold]") for t in qualifying: console.print(f" - {t['abbrev']}: {t['sname']}") return result = state.api.distribute_packs( num_packs=num, exclude_team_abbrev=exclude, pack_type_id=pack_type, cardset_id=cardset, ) if state.json_output: output_json(result) return console.print(f"\n[green]Distribution complete![/green]") console.print(f"[bold]Total packs:[/bold] {result['total_packs']}") console.print(f"[bold]Teams:[/bold] {result['teams_count']}") if exclude: console.print(f"[bold]Excluded:[/bold] {', '.join(exclude)}") except Exception as e: handle_error(e) # ============================================================================ # Gauntlet Commands # ============================================================================ @gauntlet_app.command("list") def gauntlet_list( event_id: Annotated[ Optional[int], typer.Option("--event-id", "-e", help="Filter by event ID") ] = None, active: Annotated[ bool, typer.Option("--active", "-a", help="Only active runs") ] = False, ): """List gauntlet runs""" try: runs = state.api.list_gauntlet_runs(event_id=event_id, active_only=active) if state.json_output: output_json(runs) return if not runs: console.print("[yellow]No gauntlet runs found[/yellow]") return rows = [] for r in runs: team = r.get("team", {}) is_active = "Active" if r.get("ended") is None else "Ended" gauntlet = r.get("gauntlet", {}) rows.append( [ r["id"], team.get("abbrev", "N/A"), r.get("wins", 0), r.get("losses", 0), gauntlet.get("id", "N/A"), is_active, ] ) title = "Gauntlet Runs" if event_id: title += f" - Event {event_id}" if active: title += " (Active Only)" output_table(title, ["Run ID", "Team", "W", "L", "Event", "Status"], rows) except Exception as e: handle_error(e) @gauntlet_app.command("teams") def gauntlet_teams( event_id: Annotated[ Optional[int], typer.Option("--event-id", "-e", help="Filter by event ID") ] = None, active: Annotated[ bool, typer.Option("--active", "-a", help="Only teams with active runs") ] = False, ): """List gauntlet teams""" try: teams = state.api.find_gauntlet_teams(event_id=event_id, active_only=active) if state.json_output: output_json(teams) return if not teams: console.print("[yellow]No gauntlet teams found[/yellow]") return rows = [] for t in teams: run = t.get("active_run") or t.get("run", {}) wins = run.get("wins", "-") if run else "-" losses = run.get("losses", "-") if run else "-" rows.append([t["id"], t["abbrev"], t.get("sname", ""), wins, losses]) title = "Gauntlet Teams" if active: title += " (Active)" output_table(title, ["Team ID", "Abbrev", "Name", "W", "L"], rows) except Exception as e: handle_error(e) @gauntlet_app.command("cleanup") def gauntlet_cleanup( team_abbrev: Annotated[ str, typer.Argument(help="Team abbreviation (e.g., Gauntlet-SKB)") ], event_id: Annotated[ int, typer.Option("--event-id", "-e", help="Event ID (required)") ], yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False, ): """Clean up a gauntlet team (wipe cards, delete packs, end run)""" try: # Find the team team = state.api.get_team(abbrev=team_abbrev) team_id = team["id"] # Get cards and packs count cards = state.api.list_cards(team_id=team_id) packs = state.api.list_packs(team_id=team_id, opened=False) # Find active run runs = state.api.list_gauntlet_runs( event_id=event_id, team_id=team_id, active_only=True ) active_run = runs[0] if runs else None console.print(f"\n[bold cyan]Gauntlet Cleanup: {team_abbrev}[/bold cyan]\n") console.print(f"[bold]Team ID:[/bold] {team_id}") console.print(f"[bold]Cards to wipe:[/bold] {len(cards)}") console.print(f"[bold]Packs to delete:[/bold] {len(packs)}") console.print( f"[bold]Active run:[/bold] {'Yes (ID: ' + str(active_run['id']) + ')' if active_run else 'No'}" ) if not yes: console.print("\n[yellow]This is a destructive operation![/yellow]") console.print("Use --yes flag to confirm.") raise typer.Exit(0) # Perform cleanup results = [] # 1. Wipe cards if cards: state.api.wipe_team_cards(team_id) results.append(f"Wiped {len(cards)} cards") # 2. Delete packs for pack in packs: state.api.delete_pack(pack["id"]) if packs: results.append(f"Deleted {len(packs)} packs") # 3. End gauntlet run if active_run: state.api.end_gauntlet_run(active_run["id"]) results.append(f"Ended run {active_run['id']}") console.print(f"\n[green]Cleanup complete![/green]") for r in results: console.print(f" - {r}") except ValueError as e: console.print(f"[red]Error:[/red] {e}") raise typer.Exit(1) except Exception as e: handle_error(e) # ============================================================================ # Player Commands # ============================================================================ @player_app.command("get") def player_get( player_id: Annotated[int, typer.Argument(help="Player ID")], ): """Get player by ID""" try: player = state.api.get_player(player_id=player_id) if state.json_output: output_json(player) return # Get positions positions = [] for i in range(1, 9): pos = player.get(f"pos_{i}") if pos: positions.append(pos) cardset = player.get("cardset", {}) rarity = player.get("rarity", {}) rarity_name = rarity.get("name", "N/A") if isinstance(rarity, dict) else rarity panel = Panel( f"[bold]ID:[/bold] {player['player_id']}\n" f"[bold]Name:[/bold] {player.get('p_name', 'Unknown')}\n" f"[bold]Rarity:[/bold] {rarity_name}\n" f"[bold]Cost:[/bold] {player.get('cost', 0)}\n" f"[bold]Positions:[/bold] {', '.join(positions) if positions else 'N/A'}\n" f"[bold]Cardset:[/bold] {cardset.get('name', 'N/A')} (ID: {cardset.get('id', 'N/A')})\n" f"[bold]Hand:[/bold] {player.get('hand', 'N/A')}", title=f"Player: {player.get('p_name', 'Unknown')}", border_style="blue", ) console.print(panel) except Exception as e: handle_error(e, f"Player ID {player_id} may not exist.") @player_app.command("list") def player_list( rarity: Annotated[ Optional[str], typer.Option("--rarity", "-r", help="Filter by rarity") ] = None, cardset: Annotated[ Optional[int], typer.Option("--cardset", "-c", help="Filter by cardset ID") ] = None, limit: Annotated[ int, typer.Option("--limit", "-n", help="Max players to show") ] = 50, ): """List players""" try: players = state.api.list_players(cardset_id=cardset, rarity=rarity) if state.json_output: output_json(players) return if not players: console.print("[yellow]No players found[/yellow]") return rows = [] for p in players[:limit]: cs = p.get("cardset", {}) rarity = p.get("rarity", {}) rarity_name = rarity.get("name", "") if isinstance(rarity, dict) else rarity rows.append( [ p["player_id"], p.get("p_name", "Unknown"), rarity_name, p.get("cost", 0), cs.get("name", "N/A"), ] ) title = "Players" if rarity: title += f" - {rarity}" if cardset: title += f" - Cardset {cardset}" output_table(title, ["ID", "Name", "Rarity", "Cost", "Cardset"], rows) if len(players) > limit: console.print( f"\n[dim]Showing {limit} of {len(players)} players. Use --limit to see more.[/dim]" ) except Exception as e: handle_error(e) # ============================================================================ # Entry Point # ============================================================================ if __name__ == "__main__": app()