From 43d32e9b9d952c2ef8881dd7eb2adfcd38ca0e91 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 9 Mar 2026 02:00:41 -0500 Subject: [PATCH] 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 --- CLAUDE.md | 3 +- patterns/python.md | 38 +++ plugins/blocklist.json | 2 +- plugins/known_marketplaces.json | 4 +- skills/create-scheduled-task/SKILL.md | 1 + skills/major-domo/CLI_REFERENCE.md | 5 +- skills/major-domo/SKILL.md | 2 +- skills/major-domo/api_client.py | 336 +++++++++++++------------- skills/major-domo/cli.py | 117 ++++----- skills/major-domo/cli_admin.py | 6 +- skills/major-domo/cli_common.py | 22 +- skills/major-domo/cli_injuries.py | 8 +- skills/major-domo/cli_results.py | 23 +- skills/major-domo/cli_schedule.py | 23 +- skills/major-domo/cli_transactions.py | 67 ++--- 15 files changed, 313 insertions(+), 344 deletions(-) create mode 100644 patterns/python.md diff --git a/CLAUDE.md b/CLAUDE.md index 9fc55bc..b95d0e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,8 +32,7 @@ Automatic loads are NOT enough — Read loads required CLAUDE.md context along t > **Fallback:** If MCP is unavailable, use `tea` CLI. Always pass `--repo owner/name`. ## Tech Preferences -- Python with uv for package/environment management - - Utilize dependency injection pattern whenever possible +- Python → see [`~/.claude/patterns/python.md`](patterns/python.md) (uv, DI, FastAPI hexagonal architecture) - Never add lazy imports to middle of file ## SSH diff --git a/patterns/python.md b/patterns/python.md new file mode 100644 index 0000000..58cbb33 --- /dev/null +++ b/patterns/python.md @@ -0,0 +1,38 @@ +# Python Patterns & Preferences + +## Package Management +- Always use `uv` for package and environment management +- Never use pip directly; prefer `uv pip`, `uv sync`, `uv run` + +## Linting & Formatting +- Use **Ruff** for linting and **Black** for formatting +- Include both as dev dependencies in new projects (`uv add --dev ruff black`) + +## Code Style +- Utilize dependency injection pattern whenever possible +- Never add lazy imports to the middle of a file — all imports at the top + +## FastAPI / Backend Services +Use **Ports & Adapters (Hexagonal Architecture)** to cleanly separate concerns: + +``` +project/ + domain/ # Pure business logic, no framework imports + models.py # Domain entities / value objects + services.py # Use cases / business rules + ports.py # Abstract interfaces (ABC) for external dependencies + adapters/ + inbound/ # FastAPI routers, CLI handlers — drive the domain + outbound/ # Database repos, API clients — implement domain ports + config/ # App wiring, dependency injection, settings + main.py # FastAPI app creation, adapter registration +``` + +### Key rules +- **Domain layer has zero framework imports** — no FastAPI, SQLAlchemy, httpx, etc. +- **Ports** are abstract base classes in the domain that define what the domain *needs* (e.g., `UserRepository(ABC)`) +- **Outbound adapters** implement ports (e.g., `PostgresUserRepository(UserRepository)`) +- **Inbound adapters** (routers) depend on domain services, never on outbound adapters directly +- **Dependency injection** wires adapters to ports at startup in `config/` or `main.py` +- **Tests** can swap outbound adapters for in-memory fakes by implementing the same port +- Models passed across boundaries should be domain models or DTOs — never ORM models in router responses diff --git a/plugins/blocklist.json b/plugins/blocklist.json index f3c5282..8addf03 100644 --- a/plugins/blocklist.json +++ b/plugins/blocklist.json @@ -1,5 +1,5 @@ { - "fetchedAt": "2026-03-07T20:00:33.830Z", + "fetchedAt": "2026-03-09T07:00:31.477Z", "plugins": [ { "plugin": "code-review@claude-plugins-official", diff --git a/plugins/known_marketplaces.json b/plugins/known_marketplaces.json index c24f954..55e8623 100644 --- a/plugins/known_marketplaces.json +++ b/plugins/known_marketplaces.json @@ -5,7 +5,7 @@ "url": "https://github.com/anthropics/claude-plugins-official.git" }, "installLocation": "/home/cal/.claude/plugins/marketplaces/claude-plugins-official", - "lastUpdated": "2026-03-05T17:30:45.691Z" + "lastUpdated": "2026-03-08T20:02:31.612Z" }, "claude-code-plugins": { "source": { @@ -13,6 +13,6 @@ "repo": "anthropics/claude-code" }, "installLocation": "/home/cal/.claude/plugins/marketplaces/claude-code-plugins", - "lastUpdated": "2026-03-07T20:45:27.810Z" + "lastUpdated": "2026-03-09T07:00:34.612Z" } } \ No newline at end of file diff --git a/skills/create-scheduled-task/SKILL.md b/skills/create-scheduled-task/SKILL.md index dfe3906..1dbad6b 100644 --- a/skills/create-scheduled-task/SKILL.md +++ b/skills/create-scheduled-task/SKILL.md @@ -225,6 +225,7 @@ ls -lt ~/.local/share/claude-scheduled/logs// | Task | Schedule | Budget | Description | |------|----------|--------|-------------| | `backlog-triage` | Daily 09:00 | $0.75 | Scan Gitea issues across repos, prioritize, suggest focus | +| `sync-config` | Daily 02:00 | $0.25 | Sync ~/.claude and ~/dotfiles to Gitea (bash pre-check, Claude only on changes) | ## How the Runner Works diff --git a/skills/major-domo/CLI_REFERENCE.md b/skills/major-domo/CLI_REFERENCE.md index 91b874f..09bf397 100644 --- a/skills/major-domo/CLI_REFERENCE.md +++ b/skills/major-domo/CLI_REFERENCE.md @@ -43,7 +43,7 @@ cli.py player move --batch "N1:T1,N2:T2" # Batch moves ```bash cli.py team list [--active] # List teams cli.py team get CAR # Team details -cli.py team roster CAR # Roster breakdown (Active/Short IL/Long IL) +cli.py team roster CAR # Roster breakdown (Active/Injured List/Minor League) ``` ### Standings @@ -92,8 +92,7 @@ cli.py admin refresh-batting --season 11 # Specific season ## 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) +- `team roster` shows Active/Injured List/Minor League with WARA values for Active only - For individual player lookups, use `player get "Name"` — bulk queries can timeout - `transactions simulate` validates compliance without making changes - `stats` commands support standard baseball stats sorting (woba, obp, slg, era, whip, fip, etc.) diff --git a/skills/major-domo/SKILL.md b/skills/major-domo/SKILL.md index bd42253..3153657 100644 --- a/skills/major-domo/SKILL.md +++ b/skills/major-domo/SKILL.md @@ -38,7 +38,7 @@ Comprehensive system for managing the **Strat-o-Matic Baseball Association (SBA) ```bash python3 ~/.claude/skills/major-domo/cli.py ``` -See `CLI_REFERENCE.md` for full command list and flag ordering rules. +**IMPORTANT**: Before running ANY CLI command, read `~/.claude/skills/major-domo/CLI_REFERENCE.md` for the full command list, flag ordering rules, and available options. Do not guess at CLI syntax — the reference is authoritative. ### Before Every Commit - Run `git remote -v` to verify repository diff --git a/skills/major-domo/api_client.py b/skills/major-domo/api_client.py index 1095498..5ea66f1 100755 --- a/skills/major-domo/api_client.py +++ b/skills/major-domo/api_client.py @@ -6,7 +6,7 @@ Shared API client for all Major Domo (SBA) operations. Provides methods for interacting with teams, players, standings, stats, transactions, and more. Environment Variables: - API_TOKEN: Bearer token for API authentication (required) + API_TOKEN: Bearer token for API authentication (required for write operations) DATABASE: 'prod' or 'dev' (default: prod) """ @@ -15,6 +15,11 @@ import sys from typing import Optional, Dict, List, Any, Literal import requests +_BASE_URLS = { + "prod": "https://api.sba.manticorum.com/v3", + "dev": "http://10.10.0.42:8000/api/v3", +} + class MajorDomoAPI: """ @@ -36,7 +41,12 @@ class MajorDomoAPI: standings = api.get_standings(season=12, division_abbrev='ALE') """ - def __init__(self, environment: str = 'prod', token: Optional[str] = None, verbose: bool = False): + def __init__( + self, + environment: str = "prod", + token: Optional[str] = None, + verbose: bool = False, + ): """ Initialize API client @@ -46,38 +56,28 @@ class MajorDomoAPI: verbose: Print request/response details """ self.env = environment.lower() + self.base_url = _BASE_URLS.get(self.env, _BASE_URLS["prod"]) - # Set base URL based on environment - if 'prod' in self.env: - self.base_url = 'https://api.sba.manticorum.com/v3' - else: - self.base_url = 'http://10.10.0.42:8000/api/v3' # Docker dev container - - self.token = token or os.getenv('API_TOKEN') + self.token = token or os.getenv("API_TOKEN") self.verbose = verbose - if not self.token: - raise ValueError( - "API_TOKEN environment variable required. " - "Set it with: export API_TOKEN='your-token-here'" - ) - - self.headers = { - 'Authorization': f'Bearer {self.token}', - 'Content-Type': 'application/json' - } + self.headers = {"Content-Type": "application/json"} + if self.token: + self.headers["Authorization"] = f"Bearer {self.token}" def _log(self, message: str): """Print message if verbose mode enabled""" if self.verbose: print(f"[API] {message}") - def _build_url(self, endpoint: str, object_id: Optional[int] = None, **params) -> str: + def _build_url( + self, endpoint: str, object_id: Optional[int] = None, **params + ) -> str: """Build API URL with query parameters""" - url = f'{self.base_url}/{endpoint}' + url = f"{self.base_url}/{endpoint}" if object_id is not None: - url += f'/{object_id}' + url += f"/{object_id}" # Add query parameters if params: @@ -85,15 +85,15 @@ class MajorDomoAPI: for key, value in params.items(): if value is not None: if isinstance(value, bool): - param_parts.append(f'{key}={str(value).lower()}') + param_parts.append(f"{key}={str(value).lower()}") elif isinstance(value, list): for item in value: - param_parts.append(f'{key}={item}') + param_parts.append(f"{key}={item}") else: - param_parts.append(f'{key}={value}') + param_parts.append(f"{key}={value}") if param_parts: - url += '?' + '&'.join(param_parts) + url += "?" + "&".join(param_parts) return url @@ -101,7 +101,13 @@ class MajorDomoAPI: # Low-level HTTP methods # ==================== - def get(self, endpoint: str, object_id: Optional[int] = None, timeout: int = 10, **params) -> Any: + def get( + self, + endpoint: str, + object_id: Optional[int] = None, + timeout: int = 10, + **params, + ) -> Any: """GET request to API""" url = self._build_url(endpoint, object_id=object_id, **params) self._log(f"GET {url}") @@ -109,24 +115,47 @@ class MajorDomoAPI: response.raise_for_status() return response.json() if response.text else None - def post(self, endpoint: str, payload: Optional[Dict] = None, timeout: int = 10, **params) -> Any: + def _require_token(self, method: str): + """Raise if no API token is set (required for write operations).""" + if not self.token: + raise ValueError( + f"{method} requires API_TOKEN. Set it with: export API_TOKEN='your-token-here'" + ) + + def post( + self, endpoint: str, payload: Optional[Dict] = None, timeout: int = 10, **params + ) -> Any: """POST request to API""" + self._require_token("POST") url = self._build_url(endpoint, **params) self._log(f"POST {url}") - response = requests.post(url, headers=self.headers, json=payload, timeout=timeout) + response = requests.post( + url, headers=self.headers, json=payload, timeout=timeout + ) response.raise_for_status() return response.json() if response.text else {} - def patch(self, endpoint: str, object_id: Optional[int] = None, payload: Optional[Dict] = None, timeout: int = 10, **params) -> Any: + def patch( + self, + endpoint: str, + object_id: Optional[int] = None, + payload: Optional[Dict] = None, + timeout: int = 10, + **params, + ) -> Any: """PATCH request to API""" + self._require_token("PATCH") url = self._build_url(endpoint, object_id=object_id, **params) self._log(f"PATCH {url}") - response = requests.patch(url, headers=self.headers, json=payload, timeout=timeout) + response = requests.patch( + url, headers=self.headers, json=payload, timeout=timeout + ) response.raise_for_status() return response.json() if response.text else {} def delete(self, endpoint: str, object_id: int, timeout: int = 10) -> str: """DELETE request to API""" + self._require_token("DELETE") url = self._build_url(endpoint, object_id=object_id) self._log(f"DELETE {url}") response = requests.delete(url, headers=self.headers, timeout=timeout) @@ -147,7 +176,7 @@ class MajorDomoAPI: Returns: Current status dict with season, week, trade_deadline, etc. """ - return self.get('current', season=season) + return self.get("current", season=season) def update_current(self, current_id: int, **updates) -> Dict: """ @@ -160,13 +189,18 @@ class MajorDomoAPI: Returns: Updated current status dict """ - return self.patch('current', object_id=current_id, **updates) + return self.patch("current", object_id=current_id, **updates) # ==================== # Team Operations # ==================== - def get_team(self, team_id: Optional[int] = None, abbrev: Optional[str] = None, season: Optional[int] = None) -> Dict: + def get_team( + self, + team_id: Optional[int] = None, + abbrev: Optional[str] = None, + season: Optional[int] = None, + ) -> Dict: """ Get a team by ID or abbreviation @@ -179,12 +213,15 @@ class MajorDomoAPI: Team dict """ if team_id: - return self.get('teams', object_id=team_id) + return self.get("teams", object_id=team_id) elif abbrev: - result = self.get('teams', team_abbrev=[abbrev.upper()], season=season) - teams = result.get('teams', []) + result = self.get("teams", team_abbrev=[abbrev.upper()], season=season) + teams = result.get("teams", []) if not teams: - raise ValueError(f"Team '{abbrev}' not found" + (f" in season {season}" if season else "")) + raise ValueError( + f"Team '{abbrev}' not found" + + (f" in season {season}" if season else "") + ) return teams[0] else: raise ValueError("Must provide team_id or abbrev") @@ -196,7 +233,7 @@ class MajorDomoAPI: manager_id: Optional[List[int]] = None, team_abbrev: Optional[List[str]] = None, active_only: bool = False, - short_output: bool = False + short_output: bool = False, ) -> List[Dict]: """ List teams @@ -213,21 +250,21 @@ class MajorDomoAPI: List of team dicts """ result = self.get( - 'teams', + "teams", season=season, owner_id=owner_id, manager_id=manager_id, team_abbrev=team_abbrev, active_only=active_only, - short_output=short_output + short_output=short_output, ) - return result.get('teams', []) + return result.get("teams", []) def get_team_roster( self, team_id: int, - which: Literal['current', 'next'] = 'current', - sort: Optional[str] = None + which: Literal["current", "next"] = "current", + sort: Optional[str] = None, ) -> Dict: """ Get team roster breakdown @@ -238,9 +275,9 @@ class MajorDomoAPI: sort: Sort method (e.g., 'wara-desc') Returns: - Roster dict with active/shortil/longil player lists + Roster dict with active (Active), shortil (Injured List), longil (Minor League) player lists """ - return self.get(f'teams/{team_id}/roster/{which}', sort=sort) + return self.get(f"teams/{team_id}/roster/{which}", sort=sort) def update_team(self, team_id: int, **updates) -> Dict: """ @@ -253,7 +290,7 @@ class MajorDomoAPI: Returns: Updated team dict """ - return self.patch('teams', object_id=team_id, **updates) + return self.patch("teams", object_id=team_id, **updates) # ==================== # Player Operations @@ -264,7 +301,7 @@ class MajorDomoAPI: player_id: Optional[int] = None, name: Optional[str] = None, season: Optional[int] = None, - short_output: bool = False + short_output: bool = False, ) -> Optional[Dict]: """ Get a player by ID or name @@ -279,10 +316,12 @@ class MajorDomoAPI: Player dict or None if not found """ if player_id: - return self.get('players', object_id=player_id, short_output=short_output) + return self.get("players", object_id=player_id, short_output=short_output) elif name and season: - result = self.get('players', season=season, name=name, short_output=short_output) - players = result.get('players', []) + result = self.get( + "players", season=season, name=name, short_output=short_output + ) + players = result.get("players", []) return players[0] if players else None else: raise ValueError("Must provide player_id or (name and season)") @@ -295,7 +334,7 @@ class MajorDomoAPI: strat_code: Optional[List[str]] = None, is_injured: Optional[bool] = None, sort: Optional[str] = None, - short_output: bool = False + short_output: bool = False, ) -> List[Dict]: """ List players with filters @@ -313,23 +352,23 @@ class MajorDomoAPI: List of player dicts """ result = self.get( - 'players', + "players", season=season, team_id=team_id, pos=pos, strat_code=strat_code, is_injured=is_injured, sort=sort, - short_output=short_output + short_output=short_output, ) - return result.get('players', []) + return result.get("players", []) def search_players( self, query: str, season: Optional[int] = None, limit: int = 10, - short_output: bool = False + short_output: bool = False, ) -> List[Dict]: """ Fuzzy search players by name @@ -344,13 +383,13 @@ class MajorDomoAPI: List of player dicts """ result = self.get( - 'players/search', + "players/search", q=query, season=season, limit=limit, - short_output=short_output + short_output=short_output, ) - return result.get('players', []) + return result.get("players", []) def update_player(self, player_id: int, **updates) -> Dict: """ @@ -363,7 +402,7 @@ class MajorDomoAPI: Returns: Updated player dict """ - return self.patch('players', object_id=player_id, **updates) + return self.patch("players", object_id=player_id, **updates) # ==================== # Standings Operations @@ -375,7 +414,7 @@ class MajorDomoAPI: team_id: Optional[List[int]] = None, league_abbrev: Optional[str] = None, division_abbrev: Optional[str] = None, - short_output: bool = False + short_output: bool = False, ) -> List[Dict]: """ Get league standings @@ -391,14 +430,14 @@ class MajorDomoAPI: List of standings dicts (sorted by win percentage) """ result = self.get( - 'standings', + "standings", season=season, team_id=team_id, league_abbrev=league_abbrev, division_abbrev=division_abbrev, - short_output=short_output + short_output=short_output, ) - return result.get('standings', []) + return result.get("standings", []) def get_team_standings(self, team_id: int) -> Dict: """ @@ -410,7 +449,7 @@ class MajorDomoAPI: Returns: Standings dict """ - return self.get(f'standings/team/{team_id}') + return self.get(f"standings/team/{team_id}") def recalculate_standings(self, season: int) -> str: """ @@ -422,7 +461,7 @@ class MajorDomoAPI: Returns: Success message """ - return self.post(f'standings/s{season}/recalculate') + return self.post(f"standings/s{season}/recalculate") # ==================== # Transaction Operations @@ -439,7 +478,7 @@ class MajorDomoAPI: player_name: Optional[List[str]] = None, player_id: Optional[List[int]] = None, move_id: Optional[str] = None, - short_output: bool = False + short_output: bool = False, ) -> List[Dict]: """ Get transactions with filters @@ -460,7 +499,7 @@ class MajorDomoAPI: List of transaction dicts """ result = self.get( - 'transactions', + "transactions", season=season, team_abbrev=team_abbrev, week_start=week_start, @@ -470,123 +509,90 @@ class MajorDomoAPI: player_name=player_name, player_id=player_id, move_id=move_id, - short_output=short_output + short_output=short_output, ) - return result.get('transactions', []) + return result.get("transactions", []) # ==================== # Statistics Operations (Advanced Views) # ==================== - def get_season_batting_stats( + def _get_season_stats( self, + stat_type: Literal["batting", "pitching"], season: Optional[int] = None, team_id: Optional[int] = None, player_id: Optional[int] = None, sbaplayer_id: Optional[int] = None, - min_pa: Optional[int] = None, - sort_by: str = 'woba', - sort_order: Literal['asc', 'desc'] = 'desc', + min_threshold: Optional[int] = None, + sort_by: Optional[str] = None, + sort_order: Optional[Literal["asc", "desc"]] = None, limit: int = 200, - offset: int = 0 + offset: int = 0, ) -> List[Dict]: - """ - Get season batting statistics - - Args: - season: Season number (defaults to current) - team_id: Filter by team - player_id: Filter by player - sbaplayer_id: Filter by SBA player reference - min_pa: Minimum plate appearances - sort_by: Sort field (woba, avg, obp, slg, ops, hr, rbi, etc.) - sort_order: 'asc' or 'desc' - limit: Maximum results - offset: Offset for pagination - - Returns: - List of batting stat dicts - """ + """Get season statistics for batting or pitching.""" + threshold_key = "min_pa" if stat_type == "batting" else "min_outs" result = self.get( - 'views/season-stats/batting', + f"views/season-stats/{stat_type}", season=season, team_id=team_id, player_id=player_id, sbaplayer_id=sbaplayer_id, - min_pa=min_pa, sort_by=sort_by, sort_order=sort_order, limit=limit, - offset=offset + offset=offset, + **({threshold_key: min_threshold} if min_threshold is not None else {}), + ) + return result.get("stats", []) + + def get_season_batting_stats( + self, + *, + min_pa: Optional[int] = None, + sort_by: str = "woba", + sort_order: Literal["asc", "desc"] = "desc", + **kwargs, + ) -> List[Dict]: + """Get season batting statistics. See _get_season_stats for full kwargs.""" + return self._get_season_stats( + "batting", + min_threshold=min_pa, + sort_by=sort_by, + sort_order=sort_order, + **kwargs, ) - return result.get('stats', []) def get_season_pitching_stats( self, - season: Optional[int] = None, - team_id: Optional[int] = None, - player_id: Optional[int] = None, - sbaplayer_id: Optional[int] = None, + *, min_outs: Optional[int] = None, - sort_by: str = 'era', - sort_order: Literal['asc', 'desc'] = 'asc', - limit: int = 200, - offset: int = 0 + sort_by: str = "era", + sort_order: Literal["asc", "desc"] = "asc", + **kwargs, ) -> List[Dict]: - """ - Get season pitching statistics - - Args: - season: Season number (defaults to current) - team_id: Filter by team - player_id: Filter by player - sbaplayer_id: Filter by SBA player reference - min_outs: Minimum outs pitched - sort_by: Sort field (era, whip, k, bb, w, l, sv, etc.) - sort_order: 'asc' or 'desc' - limit: Maximum results - offset: Offset for pagination - - Returns: - List of pitching stat dicts - """ - result = self.get( - 'views/season-stats/pitching', - season=season, - team_id=team_id, - player_id=player_id, - sbaplayer_id=sbaplayer_id, - min_outs=min_outs, + """Get season pitching statistics. See _get_season_stats for full kwargs.""" + return self._get_season_stats( + "pitching", + min_threshold=min_outs, sort_by=sort_by, sort_order=sort_order, - limit=limit, - offset=offset + **kwargs, ) - return result.get('stats', []) + + def refresh_stats( + self, stat_type: Literal["batting", "pitching"], season: int + ) -> Dict: + """Refresh batting or pitching statistics for a season (private endpoint).""" + return self.post(f"views/season-stats/{stat_type}/refresh", season=season) def refresh_batting_stats(self, season: int) -> Dict: - """ - Refresh batting statistics for a season (private endpoint) - - Args: - season: Season number - - Returns: - Result dict with players_updated count - """ - return self.post(f'views/season-stats/batting/refresh', season=season) + """Refresh batting statistics for a season.""" + return self.refresh_stats("batting", season) def refresh_pitching_stats(self, season: int) -> Dict: - """ - Refresh pitching statistics for a season (private endpoint) - - Args: - season: Season number - - Returns: - Result dict with players_updated count - """ - return self.post(f'views/season-stats/pitching/refresh', season=season) + """Refresh pitching statistics for a season.""" + return self.refresh_stats("pitching", season) # ==================== # Schedule & Results Operations @@ -597,7 +603,7 @@ class MajorDomoAPI: season: int, team_id: Optional[List[int]] = None, week: Optional[int] = None, - short_output: bool = False + short_output: bool = False, ) -> List[Dict]: """ Get game schedules @@ -612,20 +618,20 @@ class MajorDomoAPI: List of schedule dicts """ result = self.get( - 'schedules', + "schedules", season=season, team_id=team_id, week=week, - short_output=short_output + short_output=short_output, ) - return result.get('schedules', []) + return result.get("schedules", []) def get_results( self, season: int, team_id: Optional[List[int]] = None, week: Optional[int] = None, - short_output: bool = False + short_output: bool = False, ) -> List[Dict]: """ Get game results @@ -640,13 +646,13 @@ class MajorDomoAPI: List of result dicts """ result = self.get( - 'results', + "results", season=season, team_id=team_id, week=week, - short_output=short_output + short_output=short_output, ) - return result.get('results', []) + return result.get("results", []) # ==================== # Utility Methods @@ -675,9 +681,11 @@ def main(): """CLI interface for testing""" import argparse - parser = argparse.ArgumentParser(description='Major Domo API Client') - parser.add_argument('--env', choices=['prod', 'dev'], default='prod', help='Environment') - parser.add_argument('--verbose', action='store_true', help='Verbose output') + parser = argparse.ArgumentParser(description="Major Domo API Client") + parser.add_argument( + "--env", choices=["prod", "dev"], default="prod", help="Environment" + ) + parser.add_argument("--verbose", action="store_true", help="Verbose output") args = parser.parse_args() try: @@ -695,7 +703,7 @@ def main(): print(f" Trade Deadline: Week {current['trade_deadline']}") # List teams - teams = api.list_teams(season=current['season'], active_only=True) + teams = api.list_teams(season=current["season"], active_only=True) print(f"\nActive Teams in Season {current['season']}: {len(teams)}") for team in teams[:5]: print(f" {team['abbrev']}: {team['lname']}") @@ -709,5 +717,5 @@ def main(): sys.exit(1) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/skills/major-domo/cli.py b/skills/major-domo/cli.py index 236a1b8..7562e16 100755 --- a/skills/major-domo/cli.py +++ b/skills/major-domo/cli.py @@ -19,7 +19,7 @@ Usage: majordomo stats batting --sort woba --min-pa 100 Environment: - API_TOKEN: Required. Bearer token for API authentication. + API_TOKEN: Bearer token for API authentication (required for write operations only). """ import os @@ -40,6 +40,7 @@ from cli_common import ( output_table, handle_error, get_season, + safe_nested, ) # ============================================================================ @@ -92,14 +93,6 @@ def main( try: state.api = MajorDomoAPI(environment=env, verbose=verbose) state.json_output = json_output - # Cache current season - current = state.api.get_current() - state.current_season = current["season"] - except ValueError as e: - console.print(f"[red]Configuration Error:[/red] {e}") - console.print("\nSet API_TOKEN environment variable:") - console.print(" export API_TOKEN='your-token-here'") - raise typer.Exit(1) except Exception as e: handle_error(e) @@ -176,8 +169,7 @@ 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_abbrev = safe_nested(player, "team", "abbrev") # Collect positions positions = [ @@ -227,8 +219,7 @@ def player_search( rows = [] for p in players: - team = p.get("team", {}) - team_abbrev = team.get("abbrev", "N/A") if isinstance(team, dict) else "N/A" + team_abbrev = safe_nested(p, "team", "abbrev") rows.append( [ p["id"], @@ -318,12 +309,7 @@ def player_move( 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_abbrev = safe_nested(player, "team", "abbrev") # Find target team try: @@ -414,14 +400,8 @@ 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 "" - ) + manager_name = safe_nested(t, "manager1", "name", default="") + div_abbrev = safe_nested(t, "division", "division_abbrev", default="") rows.append( [ t["abbrev"], @@ -456,16 +436,8 @@ 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_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" @@ -511,42 +483,38 @@ def team_roster( f"\n[bold cyan]{team.get('lname', abbrev)} Roster ({which.title()})[/bold cyan]\n" ) - # Active roster - active = roster.get("active", {}).get("players", []) - if active: - active_rows = [ - [p["name"], p.get("pos_1", ""), f"{p.get('wara', 0):.2f}"] - for p in active - ] - output_table( - f"Active ({len(active)})", ["Name", "Pos", "WARA"], active_rows - ) + # 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", "")], + ), + ] - # Short IL - short_il = roster.get("shortil", {}).get("players", []) - if short_il: - console.print() - il_rows = [ - [p["name"], p.get("pos_1", ""), p.get("il_return", "")] - for p in short_il - ] - output_table( - f"Short IL ({len(short_il)})", ["Name", "Pos", "Return"], il_rows - ) - - # Long IL - long_il = roster.get("longil", {}).get("players", []) - if long_il: - console.print() - lil_rows = [ - [p["name"], p.get("pos_1", ""), p.get("il_return", "")] for p in long_il - ] - output_table( - f"Long IL ({len(long_il)})", ["Name", "Pos", "Return"], lil_rows - ) - - # Summary - total = len(active) + len(short_il) + len(long_il) + 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: @@ -592,9 +560,8 @@ 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" + 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 diff --git a/skills/major-domo/cli_admin.py b/skills/major-domo/cli_admin.py index ee5df0f..8b87ade 100644 --- a/skills/major-domo/cli_admin.py +++ b/skills/major-domo/cli_admin.py @@ -17,6 +17,7 @@ from cli_common import ( output_table, handle_error, get_season, + safe_nested, ) from cli_stats import _format_rate_stat, _outs_to_ip @@ -32,9 +33,8 @@ def _show_standings(season: int): rows = [] for s in standings_data: - team = s.get("team", {}) - abbrev = team.get("abbrev", "N/A") if isinstance(team, dict) else "N/A" - name = team.get("lname", "N/A") if isinstance(team, dict) else "N/A" + abbrev = safe_nested(s, "team", "abbrev") + name = safe_nested(s, "team", "lname") wins = s.get("wins", 0) losses = s.get("losses", 0) total = wins + losses diff --git a/skills/major-domo/cli_common.py b/skills/major-domo/cli_common.py index 2de932a..aaa711f 100644 --- a/skills/major-domo/cli_common.py +++ b/skills/major-domo/cli_common.py @@ -7,7 +7,7 @@ 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 typing import Any, List, Optional from rich.console import Console from rich.table import Table @@ -67,6 +67,20 @@ def handle_error(e: Exception, context: str = ""): 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 +def safe_nested(obj: Any, *keys: str, default: str = "N/A") -> str: + """Safely extract a value from nested dicts. Returns default if any key is missing or obj isn't a dict.""" + for key in keys: + if not isinstance(obj, dict): + return default + obj = obj.get(key) + return obj if obj is not None else default + + +def get_season(season: Optional[int] = None) -> int: + """Get season, defaulting to current if not specified. Lazy-fetches current season on first call.""" + if season is not None: + return season + if state.current_season is None: + current = state.api.get_current() + state.current_season = current["season"] + return state.current_season diff --git a/skills/major-domo/cli_injuries.py b/skills/major-domo/cli_injuries.py index 63993a1..4981620 100644 --- a/skills/major-domo/cli_injuries.py +++ b/skills/major-domo/cli_injuries.py @@ -16,6 +16,7 @@ from cli_common import ( output_table, handle_error, get_season, + safe_nested, ) injuries_app = typer.Typer(help="Injury operations") @@ -90,12 +91,7 @@ def injuries_list( 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" - ) + team_abbrev_display = safe_nested(player, "team", "abbrev") total_games = injury.get("total_games", 0) start_week = injury.get("start_week", 0) diff --git a/skills/major-domo/cli_results.py b/skills/major-domo/cli_results.py index 719c52e..fd655a5 100644 --- a/skills/major-domo/cli_results.py +++ b/skills/major-domo/cli_results.py @@ -15,6 +15,7 @@ from cli_common import ( output_table, handle_error, get_season, + safe_nested, ) results_app = typer.Typer( @@ -96,18 +97,11 @@ def results_callback( # Build table rows rows = [] for r in results_list: - away_team = r.get("away_team", {}) - home_team = r.get("home_team", {}) + away_abbrev = safe_nested(r, "away_team", "abbrev") + home_abbrev = safe_nested(r, "home_team", "abbrev") 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}" @@ -200,18 +194,11 @@ def results_list( # Build table rows rows = [] for r in results_list: - away_team = r.get("away_team", {}) - home_team = r.get("home_team", {}) + away_abbrev = safe_nested(r, "away_team", "abbrev") + home_abbrev = safe_nested(r, "home_team", "abbrev") 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}" diff --git a/skills/major-domo/cli_schedule.py b/skills/major-domo/cli_schedule.py index 08bbaef..e3a30c6 100644 --- a/skills/major-domo/cli_schedule.py +++ b/skills/major-domo/cli_schedule.py @@ -15,6 +15,7 @@ from cli_common import ( output_table, handle_error, get_season, + safe_nested, ) schedule_app = typer.Typer( @@ -96,15 +97,8 @@ def schedule_callback( # 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" - ) + away_abbrev = safe_nested(s, "away_team", "abbrev") + home_abbrev = safe_nested(s, "home_team", "abbrev") rows.append( [ @@ -194,15 +188,8 @@ def schedule_list( # 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" - ) + away_abbrev = safe_nested(s, "away_team", "abbrev") + home_abbrev = safe_nested(s, "home_team", "abbrev") rows.append( [ diff --git a/skills/major-domo/cli_transactions.py b/skills/major-domo/cli_transactions.py index 48d22cb..f049af8 100644 --- a/skills/major-domo/cli_transactions.py +++ b/skills/major-domo/cli_transactions.py @@ -16,6 +16,7 @@ from cli_common import ( output_table, handle_error, get_season, + safe_nested, ) transactions_app = typer.Typer( @@ -77,8 +78,12 @@ def transactions_callback( week_start = week week_end = week - # Build filter parameters - team_list = [team] if team else None + # 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 @@ -111,25 +116,9 @@ def transactions_callback( # 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" - ) + player_name = safe_nested(t, "player", "name") + old_abbrev = safe_nested(t, "oldteam", "abbrev") + new_abbrev = safe_nested(t, "newteam", "abbrev") rows.append( [ @@ -200,8 +189,12 @@ def transactions_list( week_start = week week_end = week - # Build filter parameters - team_list = [team] if team else None + # 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 @@ -234,25 +227,9 @@ def transactions_list( # 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" - ) + player_name = safe_nested(t, "player", "name") + old_abbrev = safe_nested(t, "oldteam", "abbrev") + new_abbrev = safe_nested(t, "newteam", "abbrev") rows.append( [ @@ -363,11 +340,7 @@ def transactions_simulate( 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 "?" - ) + p_team = safe_nested(player, "team", "abbrev", default="?") # Determine if this move adds to or removes from ML target_is_ml = target == team_upper