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>
This commit is contained in:
Cal Corum 2026-03-09 02:00:41 -05:00
parent 655dc64033
commit 43d32e9b9d
15 changed files with 313 additions and 344 deletions

View File

@ -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`. > **Fallback:** If MCP is unavailable, use `tea` CLI. Always pass `--repo owner/name`.
## Tech Preferences ## Tech Preferences
- Python with uv for package/environment management - Python → see [`~/.claude/patterns/python.md`](patterns/python.md) (uv, DI, FastAPI hexagonal architecture)
- Utilize dependency injection pattern whenever possible
- Never add lazy imports to middle of file - Never add lazy imports to middle of file
## SSH ## SSH

38
patterns/python.md Normal file
View File

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

View File

@ -1,5 +1,5 @@
{ {
"fetchedAt": "2026-03-07T20:00:33.830Z", "fetchedAt": "2026-03-09T07:00:31.477Z",
"plugins": [ "plugins": [
{ {
"plugin": "code-review@claude-plugins-official", "plugin": "code-review@claude-plugins-official",

View File

@ -5,7 +5,7 @@
"url": "https://github.com/anthropics/claude-plugins-official.git" "url": "https://github.com/anthropics/claude-plugins-official.git"
}, },
"installLocation": "/home/cal/.claude/plugins/marketplaces/claude-plugins-official", "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": { "claude-code-plugins": {
"source": { "source": {
@ -13,6 +13,6 @@
"repo": "anthropics/claude-code" "repo": "anthropics/claude-code"
}, },
"installLocation": "/home/cal/.claude/plugins/marketplaces/claude-code-plugins", "installLocation": "/home/cal/.claude/plugins/marketplaces/claude-code-plugins",
"lastUpdated": "2026-03-07T20:45:27.810Z" "lastUpdated": "2026-03-09T07:00:34.612Z"
} }
} }

View File

@ -225,6 +225,7 @@ ls -lt ~/.local/share/claude-scheduled/logs/<task-name>/
| Task | Schedule | Budget | Description | | Task | Schedule | Budget | Description |
|------|----------|--------|-------------| |------|----------|--------|-------------|
| `backlog-triage` | Daily 09:00 | $0.75 | Scan Gitea issues across repos, prioritize, suggest focus | | `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 ## How the Runner Works

View File

@ -43,7 +43,7 @@ cli.py player move --batch "N1:T1,N2:T2" # Batch moves
```bash ```bash
cli.py team list [--active] # List teams cli.py team list [--active] # List teams
cli.py team get CAR # Team details 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 ### Standings
@ -92,8 +92,7 @@ cli.py admin refresh-batting --season 11 # Specific season
## Key Notes ## Key Notes
- `team get` shows salary_cap in formatted output; use `--json` for all fields - `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 - `team roster` shows Active/Injured List/Minor League with WARA values for Active only
- "Long IL" = MiL (minor leagues)
- For individual player lookups, use `player get "Name"` — bulk queries can timeout - For individual player lookups, use `player get "Name"` — bulk queries can timeout
- `transactions simulate` validates compliance without making changes - `transactions simulate` validates compliance without making changes
- `stats` commands support standard baseball stats sorting (woba, obp, slg, era, whip, fip, etc.) - `stats` commands support standard baseball stats sorting (woba, obp, slg, era, whip, fip, etc.)

View File

@ -38,7 +38,7 @@ Comprehensive system for managing the **Strat-o-Matic Baseball Association (SBA)
```bash ```bash
python3 ~/.claude/skills/major-domo/cli.py <command> python3 ~/.claude/skills/major-domo/cli.py <command>
``` ```
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 ### Before Every Commit
- Run `git remote -v` to verify repository - Run `git remote -v` to verify repository

View File

@ -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. Provides methods for interacting with teams, players, standings, stats, transactions, and more.
Environment Variables: 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) DATABASE: 'prod' or 'dev' (default: prod)
""" """
@ -15,6 +15,11 @@ import sys
from typing import Optional, Dict, List, Any, Literal from typing import Optional, Dict, List, Any, Literal
import requests import requests
_BASE_URLS = {
"prod": "https://api.sba.manticorum.com/v3",
"dev": "http://10.10.0.42:8000/api/v3",
}
class MajorDomoAPI: class MajorDomoAPI:
""" """
@ -36,7 +41,12 @@ class MajorDomoAPI:
standings = api.get_standings(season=12, division_abbrev='ALE') 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 Initialize API client
@ -46,38 +56,28 @@ class MajorDomoAPI:
verbose: Print request/response details verbose: Print request/response details
""" """
self.env = environment.lower() self.env = environment.lower()
self.base_url = _BASE_URLS.get(self.env, _BASE_URLS["prod"])
# Set base URL based on environment self.token = token or os.getenv("API_TOKEN")
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.verbose = verbose self.verbose = verbose
if not self.token: self.headers = {"Content-Type": "application/json"}
raise ValueError( if self.token:
"API_TOKEN environment variable required. " self.headers["Authorization"] = f"Bearer {self.token}"
"Set it with: export API_TOKEN='your-token-here'"
)
self.headers = {
'Authorization': f'Bearer {self.token}',
'Content-Type': 'application/json'
}
def _log(self, message: str): def _log(self, message: str):
"""Print message if verbose mode enabled""" """Print message if verbose mode enabled"""
if self.verbose: if self.verbose:
print(f"[API] {message}") 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""" """Build API URL with query parameters"""
url = f'{self.base_url}/{endpoint}' url = f"{self.base_url}/{endpoint}"
if object_id is not None: if object_id is not None:
url += f'/{object_id}' url += f"/{object_id}"
# Add query parameters # Add query parameters
if params: if params:
@ -85,15 +85,15 @@ class MajorDomoAPI:
for key, value in params.items(): for key, value in params.items():
if value is not None: if value is not None:
if isinstance(value, bool): if isinstance(value, bool):
param_parts.append(f'{key}={str(value).lower()}') param_parts.append(f"{key}={str(value).lower()}")
elif isinstance(value, list): elif isinstance(value, list):
for item in value: for item in value:
param_parts.append(f'{key}={item}') param_parts.append(f"{key}={item}")
else: else:
param_parts.append(f'{key}={value}') param_parts.append(f"{key}={value}")
if param_parts: if param_parts:
url += '?' + '&'.join(param_parts) url += "?" + "&".join(param_parts)
return url return url
@ -101,7 +101,13 @@ class MajorDomoAPI:
# Low-level HTTP methods # 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""" """GET request to API"""
url = self._build_url(endpoint, object_id=object_id, **params) url = self._build_url(endpoint, object_id=object_id, **params)
self._log(f"GET {url}") self._log(f"GET {url}")
@ -109,24 +115,47 @@ class MajorDomoAPI:
response.raise_for_status() response.raise_for_status()
return response.json() if response.text else None 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""" """POST request to API"""
self._require_token("POST")
url = self._build_url(endpoint, **params) url = self._build_url(endpoint, **params)
self._log(f"POST {url}") 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() response.raise_for_status()
return response.json() if response.text else {} 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""" """PATCH request to API"""
self._require_token("PATCH")
url = self._build_url(endpoint, object_id=object_id, **params) url = self._build_url(endpoint, object_id=object_id, **params)
self._log(f"PATCH {url}") 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() response.raise_for_status()
return response.json() if response.text else {} return response.json() if response.text else {}
def delete(self, endpoint: str, object_id: int, timeout: int = 10) -> str: def delete(self, endpoint: str, object_id: int, timeout: int = 10) -> str:
"""DELETE request to API""" """DELETE request to API"""
self._require_token("DELETE")
url = self._build_url(endpoint, object_id=object_id) url = self._build_url(endpoint, object_id=object_id)
self._log(f"DELETE {url}") self._log(f"DELETE {url}")
response = requests.delete(url, headers=self.headers, timeout=timeout) response = requests.delete(url, headers=self.headers, timeout=timeout)
@ -147,7 +176,7 @@ class MajorDomoAPI:
Returns: Returns:
Current status dict with season, week, trade_deadline, etc. 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: def update_current(self, current_id: int, **updates) -> Dict:
""" """
@ -160,13 +189,18 @@ class MajorDomoAPI:
Returns: Returns:
Updated current status dict Updated current status dict
""" """
return self.patch('current', object_id=current_id, **updates) return self.patch("current", object_id=current_id, **updates)
# ==================== # ====================
# Team Operations # 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 Get a team by ID or abbreviation
@ -179,12 +213,15 @@ class MajorDomoAPI:
Team dict Team dict
""" """
if team_id: if team_id:
return self.get('teams', object_id=team_id) return self.get("teams", object_id=team_id)
elif abbrev: elif abbrev:
result = self.get('teams', team_abbrev=[abbrev.upper()], season=season) result = self.get("teams", team_abbrev=[abbrev.upper()], season=season)
teams = result.get('teams', []) teams = result.get("teams", [])
if not 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] return teams[0]
else: else:
raise ValueError("Must provide team_id or abbrev") raise ValueError("Must provide team_id or abbrev")
@ -196,7 +233,7 @@ class MajorDomoAPI:
manager_id: Optional[List[int]] = None, manager_id: Optional[List[int]] = None,
team_abbrev: Optional[List[str]] = None, team_abbrev: Optional[List[str]] = None,
active_only: bool = False, active_only: bool = False,
short_output: bool = False short_output: bool = False,
) -> List[Dict]: ) -> List[Dict]:
""" """
List teams List teams
@ -213,21 +250,21 @@ class MajorDomoAPI:
List of team dicts List of team dicts
""" """
result = self.get( result = self.get(
'teams', "teams",
season=season, season=season,
owner_id=owner_id, owner_id=owner_id,
manager_id=manager_id, manager_id=manager_id,
team_abbrev=team_abbrev, team_abbrev=team_abbrev,
active_only=active_only, active_only=active_only,
short_output=short_output short_output=short_output,
) )
return result.get('teams', []) return result.get("teams", [])
def get_team_roster( def get_team_roster(
self, self,
team_id: int, team_id: int,
which: Literal['current', 'next'] = 'current', which: Literal["current", "next"] = "current",
sort: Optional[str] = None sort: Optional[str] = None,
) -> Dict: ) -> Dict:
""" """
Get team roster breakdown Get team roster breakdown
@ -238,9 +275,9 @@ class MajorDomoAPI:
sort: Sort method (e.g., 'wara-desc') sort: Sort method (e.g., 'wara-desc')
Returns: 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: def update_team(self, team_id: int, **updates) -> Dict:
""" """
@ -253,7 +290,7 @@ class MajorDomoAPI:
Returns: Returns:
Updated team dict Updated team dict
""" """
return self.patch('teams', object_id=team_id, **updates) return self.patch("teams", object_id=team_id, **updates)
# ==================== # ====================
# Player Operations # Player Operations
@ -264,7 +301,7 @@ class MajorDomoAPI:
player_id: Optional[int] = None, player_id: Optional[int] = None,
name: Optional[str] = None, name: Optional[str] = None,
season: Optional[int] = None, season: Optional[int] = None,
short_output: bool = False short_output: bool = False,
) -> Optional[Dict]: ) -> Optional[Dict]:
""" """
Get a player by ID or name Get a player by ID or name
@ -279,10 +316,12 @@ class MajorDomoAPI:
Player dict or None if not found Player dict or None if not found
""" """
if player_id: 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: elif name and season:
result = self.get('players', season=season, name=name, short_output=short_output) result = self.get(
players = result.get('players', []) "players", season=season, name=name, short_output=short_output
)
players = result.get("players", [])
return players[0] if players else None return players[0] if players else None
else: else:
raise ValueError("Must provide player_id or (name and season)") raise ValueError("Must provide player_id or (name and season)")
@ -295,7 +334,7 @@ class MajorDomoAPI:
strat_code: Optional[List[str]] = None, strat_code: Optional[List[str]] = None,
is_injured: Optional[bool] = None, is_injured: Optional[bool] = None,
sort: Optional[str] = None, sort: Optional[str] = None,
short_output: bool = False short_output: bool = False,
) -> List[Dict]: ) -> List[Dict]:
""" """
List players with filters List players with filters
@ -313,23 +352,23 @@ class MajorDomoAPI:
List of player dicts List of player dicts
""" """
result = self.get( result = self.get(
'players', "players",
season=season, season=season,
team_id=team_id, team_id=team_id,
pos=pos, pos=pos,
strat_code=strat_code, strat_code=strat_code,
is_injured=is_injured, is_injured=is_injured,
sort=sort, sort=sort,
short_output=short_output short_output=short_output,
) )
return result.get('players', []) return result.get("players", [])
def search_players( def search_players(
self, self,
query: str, query: str,
season: Optional[int] = None, season: Optional[int] = None,
limit: int = 10, limit: int = 10,
short_output: bool = False short_output: bool = False,
) -> List[Dict]: ) -> List[Dict]:
""" """
Fuzzy search players by name Fuzzy search players by name
@ -344,13 +383,13 @@ class MajorDomoAPI:
List of player dicts List of player dicts
""" """
result = self.get( result = self.get(
'players/search', "players/search",
q=query, q=query,
season=season, season=season,
limit=limit, 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: def update_player(self, player_id: int, **updates) -> Dict:
""" """
@ -363,7 +402,7 @@ class MajorDomoAPI:
Returns: Returns:
Updated player dict Updated player dict
""" """
return self.patch('players', object_id=player_id, **updates) return self.patch("players", object_id=player_id, **updates)
# ==================== # ====================
# Standings Operations # Standings Operations
@ -375,7 +414,7 @@ class MajorDomoAPI:
team_id: Optional[List[int]] = None, team_id: Optional[List[int]] = None,
league_abbrev: Optional[str] = None, league_abbrev: Optional[str] = None,
division_abbrev: Optional[str] = None, division_abbrev: Optional[str] = None,
short_output: bool = False short_output: bool = False,
) -> List[Dict]: ) -> List[Dict]:
""" """
Get league standings Get league standings
@ -391,14 +430,14 @@ class MajorDomoAPI:
List of standings dicts (sorted by win percentage) List of standings dicts (sorted by win percentage)
""" """
result = self.get( result = self.get(
'standings', "standings",
season=season, season=season,
team_id=team_id, team_id=team_id,
league_abbrev=league_abbrev, league_abbrev=league_abbrev,
division_abbrev=division_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: def get_team_standings(self, team_id: int) -> Dict:
""" """
@ -410,7 +449,7 @@ class MajorDomoAPI:
Returns: Returns:
Standings dict 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: def recalculate_standings(self, season: int) -> str:
""" """
@ -422,7 +461,7 @@ class MajorDomoAPI:
Returns: Returns:
Success message Success message
""" """
return self.post(f'standings/s{season}/recalculate') return self.post(f"standings/s{season}/recalculate")
# ==================== # ====================
# Transaction Operations # Transaction Operations
@ -439,7 +478,7 @@ class MajorDomoAPI:
player_name: Optional[List[str]] = None, player_name: Optional[List[str]] = None,
player_id: Optional[List[int]] = None, player_id: Optional[List[int]] = None,
move_id: Optional[str] = None, move_id: Optional[str] = None,
short_output: bool = False short_output: bool = False,
) -> List[Dict]: ) -> List[Dict]:
""" """
Get transactions with filters Get transactions with filters
@ -460,7 +499,7 @@ class MajorDomoAPI:
List of transaction dicts List of transaction dicts
""" """
result = self.get( result = self.get(
'transactions', "transactions",
season=season, season=season,
team_abbrev=team_abbrev, team_abbrev=team_abbrev,
week_start=week_start, week_start=week_start,
@ -470,123 +509,90 @@ class MajorDomoAPI:
player_name=player_name, player_name=player_name,
player_id=player_id, player_id=player_id,
move_id=move_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) # Statistics Operations (Advanced Views)
# ==================== # ====================
def get_season_batting_stats( def _get_season_stats(
self, self,
stat_type: Literal["batting", "pitching"],
season: Optional[int] = None, season: Optional[int] = None,
team_id: Optional[int] = None, team_id: Optional[int] = None,
player_id: Optional[int] = None, player_id: Optional[int] = None,
sbaplayer_id: Optional[int] = None, sbaplayer_id: Optional[int] = None,
min_pa: Optional[int] = None, min_threshold: Optional[int] = None,
sort_by: str = 'woba', sort_by: Optional[str] = None,
sort_order: Literal['asc', 'desc'] = 'desc', sort_order: Optional[Literal["asc", "desc"]] = None,
limit: int = 200, limit: int = 200,
offset: int = 0 offset: int = 0,
) -> List[Dict]: ) -> List[Dict]:
""" """Get season statistics for batting or pitching."""
Get season batting statistics threshold_key = "min_pa" if stat_type == "batting" else "min_outs"
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
"""
result = self.get( result = self.get(
'views/season-stats/batting', f"views/season-stats/{stat_type}",
season=season, season=season,
team_id=team_id, team_id=team_id,
player_id=player_id, player_id=player_id,
sbaplayer_id=sbaplayer_id, sbaplayer_id=sbaplayer_id,
min_pa=min_pa,
sort_by=sort_by, sort_by=sort_by,
sort_order=sort_order, sort_order=sort_order,
limit=limit, 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( def get_season_pitching_stats(
self, 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, min_outs: Optional[int] = None,
sort_by: str = 'era', sort_by: str = "era",
sort_order: Literal['asc', 'desc'] = 'asc', sort_order: Literal["asc", "desc"] = "asc",
limit: int = 200, **kwargs,
offset: int = 0
) -> List[Dict]: ) -> List[Dict]:
""" """Get season pitching statistics. See _get_season_stats for full kwargs."""
Get season pitching statistics return self._get_season_stats(
"pitching",
Args: min_threshold=min_outs,
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,
sort_by=sort_by, sort_by=sort_by,
sort_order=sort_order, sort_order=sort_order,
limit=limit, **kwargs,
offset=offset
) )
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: def refresh_batting_stats(self, season: int) -> Dict:
""" """Refresh batting statistics for a season."""
Refresh batting statistics for a season (private endpoint) return self.refresh_stats("batting", season)
Args:
season: Season number
Returns:
Result dict with players_updated count
"""
return self.post(f'views/season-stats/batting/refresh', season=season)
def refresh_pitching_stats(self, season: int) -> Dict: def refresh_pitching_stats(self, season: int) -> Dict:
""" """Refresh pitching statistics for a season."""
Refresh pitching statistics for a season (private endpoint) return self.refresh_stats("pitching", season)
Args:
season: Season number
Returns:
Result dict with players_updated count
"""
return self.post(f'views/season-stats/pitching/refresh', season=season)
# ==================== # ====================
# Schedule & Results Operations # Schedule & Results Operations
@ -597,7 +603,7 @@ class MajorDomoAPI:
season: int, season: int,
team_id: Optional[List[int]] = None, team_id: Optional[List[int]] = None,
week: Optional[int] = None, week: Optional[int] = None,
short_output: bool = False short_output: bool = False,
) -> List[Dict]: ) -> List[Dict]:
""" """
Get game schedules Get game schedules
@ -612,20 +618,20 @@ class MajorDomoAPI:
List of schedule dicts List of schedule dicts
""" """
result = self.get( result = self.get(
'schedules', "schedules",
season=season, season=season,
team_id=team_id, team_id=team_id,
week=week, week=week,
short_output=short_output short_output=short_output,
) )
return result.get('schedules', []) return result.get("schedules", [])
def get_results( def get_results(
self, self,
season: int, season: int,
team_id: Optional[List[int]] = None, team_id: Optional[List[int]] = None,
week: Optional[int] = None, week: Optional[int] = None,
short_output: bool = False short_output: bool = False,
) -> List[Dict]: ) -> List[Dict]:
""" """
Get game results Get game results
@ -640,13 +646,13 @@ class MajorDomoAPI:
List of result dicts List of result dicts
""" """
result = self.get( result = self.get(
'results', "results",
season=season, season=season,
team_id=team_id, team_id=team_id,
week=week, week=week,
short_output=short_output short_output=short_output,
) )
return result.get('results', []) return result.get("results", [])
# ==================== # ====================
# Utility Methods # Utility Methods
@ -675,9 +681,11 @@ def main():
"""CLI interface for testing""" """CLI interface for testing"""
import argparse import argparse
parser = argparse.ArgumentParser(description='Major Domo API Client') parser = argparse.ArgumentParser(description="Major Domo API Client")
parser.add_argument('--env', choices=['prod', 'dev'], default='prod', help='Environment') parser.add_argument(
parser.add_argument('--verbose', action='store_true', help='Verbose output') "--env", choices=["prod", "dev"], default="prod", help="Environment"
)
parser.add_argument("--verbose", action="store_true", help="Verbose output")
args = parser.parse_args() args = parser.parse_args()
try: try:
@ -695,7 +703,7 @@ def main():
print(f" Trade Deadline: Week {current['trade_deadline']}") print(f" Trade Deadline: Week {current['trade_deadline']}")
# List teams # 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)}") print(f"\nActive Teams in Season {current['season']}: {len(teams)}")
for team in teams[:5]: for team in teams[:5]:
print(f" {team['abbrev']}: {team['lname']}") print(f" {team['abbrev']}: {team['lname']}")
@ -709,5 +717,5 @@ def main():
sys.exit(1) sys.exit(1)
if __name__ == '__main__': if __name__ == "__main__":
main() main()

View File

@ -19,7 +19,7 @@ Usage:
majordomo stats batting --sort woba --min-pa 100 majordomo stats batting --sort woba --min-pa 100
Environment: Environment:
API_TOKEN: Required. Bearer token for API authentication. API_TOKEN: Bearer token for API authentication (required for write operations only).
""" """
import os import os
@ -40,6 +40,7 @@ from cli_common import (
output_table, output_table,
handle_error, handle_error,
get_season, get_season,
safe_nested,
) )
# ============================================================================ # ============================================================================
@ -92,14 +93,6 @@ def main(
try: try:
state.api = MajorDomoAPI(environment=env, verbose=verbose) state.api = MajorDomoAPI(environment=env, verbose=verbose)
state.json_output = json_output 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: except Exception as e:
handle_error(e) handle_error(e)
@ -176,8 +169,7 @@ def player_get(
return return
# Extract nested team info # Extract nested team info
team = player.get("team", {}) team_abbrev = safe_nested(player, "team", "abbrev")
team_abbrev = team.get("abbrev", "N/A") if isinstance(team, dict) else "N/A"
# Collect positions # Collect positions
positions = [ positions = [
@ -227,8 +219,7 @@ def player_search(
rows = [] rows = []
for p in players: for p in players:
team = p.get("team", {}) team_abbrev = safe_nested(p, "team", "abbrev")
team_abbrev = team.get("abbrev", "N/A") if isinstance(team, dict) else "N/A"
rows.append( rows.append(
[ [
p["id"], p["id"],
@ -318,12 +309,7 @@ def player_move(
continue continue
# Get current team # Get current team
current_team = player.get("team", {}) current_abbrev = safe_nested(player, "team", "abbrev")
current_abbrev = (
current_team.get("abbrev", "N/A")
if isinstance(current_team, dict)
else "N/A"
)
# Find target team # Find target team
try: try:
@ -414,14 +400,8 @@ def team_list(
rows = [] rows = []
for t in teams: for t in teams:
manager = t.get("manager1", {}) manager_name = safe_nested(t, "manager1", "name", default="")
manager_name = manager.get("name", "") if isinstance(manager, dict) else "" div_abbrev = safe_nested(t, "division", "division_abbrev", default="")
division = t.get("division", {})
div_abbrev = (
division.get("division_abbrev", "")
if isinstance(division, dict)
else ""
)
rows.append( rows.append(
[ [
t["abbrev"], t["abbrev"],
@ -456,16 +436,8 @@ def team_get(
output_json(team) output_json(team)
return return
manager = team.get("manager1", {}) manager_name = safe_nested(team, "manager1", "name")
manager_name = ( div_name = safe_nested(team, "division", "division_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") salary_cap = team.get("salary_cap")
cap_str = f"{salary_cap:.1f}" if salary_cap is not None else "N/A" 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" f"\n[bold cyan]{team.get('lname', abbrev)} Roster ({which.title()})[/bold cyan]\n"
) )
# Active roster # Roster sections: (API key, display label, columns, row builder)
active = roster.get("active", {}).get("players", []) sections = [
if active: (
active_rows = [ "active",
[p["name"], p.get("pos_1", ""), f"{p.get('wara', 0):.2f}"] "Active",
for p in 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", "")],
),
] ]
output_table(
f"Active ({len(active)})", ["Name", "Pos", "WARA"], active_rows
)
# Short IL total = 0
short_il = roster.get("shortil", {}).get("players", []) for i, (key, label, columns, row_fn) in enumerate(sections):
if short_il: players = roster.get(key, {}).get("players", [])
if players:
if i > 0:
console.print() console.print()
il_rows = [
[p["name"], p.get("pos_1", ""), p.get("il_return", "")]
for p in short_il
]
output_table( output_table(
f"Short IL ({len(short_il)})", ["Name", "Pos", "Return"], il_rows f"{label} ({len(players)})", columns, [row_fn(p) for p in players]
) )
total += len(players)
# 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)
console.print(f"\n[dim]Total: {total} players[/dim]") console.print(f"\n[dim]Total: {total} players[/dim]")
except Exception as e: except Exception as e:
@ -592,9 +560,8 @@ def standings(
rows = [] rows = []
for s in standings_data: for s in standings_data:
team = s.get("team", {}) team_abbrev = safe_nested(s, "team", "abbrev")
team_abbrev = team.get("abbrev", "N/A") if isinstance(team, dict) else "N/A" team_name = safe_nested(s, "team", "lname")
team_name = team.get("lname", "N/A") if isinstance(team, dict) else "N/A"
wins = s.get("wins", 0) wins = s.get("wins", 0)
losses = s.get("losses", 0) losses = s.get("losses", 0)
total = wins + losses total = wins + losses

View File

@ -17,6 +17,7 @@ from cli_common import (
output_table, output_table,
handle_error, handle_error,
get_season, get_season,
safe_nested,
) )
from cli_stats import _format_rate_stat, _outs_to_ip from cli_stats import _format_rate_stat, _outs_to_ip
@ -32,9 +33,8 @@ def _show_standings(season: int):
rows = [] rows = []
for s in standings_data: for s in standings_data:
team = s.get("team", {}) abbrev = safe_nested(s, "team", "abbrev")
abbrev = team.get("abbrev", "N/A") if isinstance(team, dict) else "N/A" name = safe_nested(s, "team", "lname")
name = team.get("lname", "N/A") if isinstance(team, dict) else "N/A"
wins = s.get("wins", 0) wins = s.get("wins", 0)
losses = s.get("losses", 0) losses = s.get("losses", 0)
total = wins + losses total = wins + losses

View File

@ -7,7 +7,7 @@ All CLI sub-modules import from here for consistent output and state management.
import json import json
import os import os
import sys import sys
from typing import List, Optional from typing import Any, List, Optional
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
@ -67,6 +67,20 @@ def handle_error(e: Exception, context: str = ""):
raise typer.Exit(1) raise typer.Exit(1)
def get_season(season: Optional[int]) -> int: def safe_nested(obj: Any, *keys: str, default: str = "N/A") -> str:
"""Get season, defaulting to current if not specified""" """Safely extract a value from nested dicts. Returns default if any key is missing or obj isn't a dict."""
return season if season is not None else state.current_season 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

View File

@ -16,6 +16,7 @@ from cli_common import (
output_table, output_table,
handle_error, handle_error,
get_season, get_season,
safe_nested,
) )
injuries_app = typer.Typer(help="Injury operations") injuries_app = typer.Typer(help="Injury operations")
@ -90,12 +91,7 @@ def injuries_list(
for injury in injuries: for injury in injuries:
player = injury.get("player", {}) player = injury.get("player", {})
player_name = player.get("name", "N/A") player_name = player.get("name", "N/A")
player_team = player.get("team", {}) team_abbrev_display = safe_nested(player, "team", "abbrev")
team_abbrev_display = (
player_team.get("abbrev", "N/A")
if isinstance(player_team, dict)
else "N/A"
)
total_games = injury.get("total_games", 0) total_games = injury.get("total_games", 0)
start_week = injury.get("start_week", 0) start_week = injury.get("start_week", 0)

View File

@ -15,6 +15,7 @@ from cli_common import (
output_table, output_table,
handle_error, handle_error,
get_season, get_season,
safe_nested,
) )
results_app = typer.Typer( results_app = typer.Typer(
@ -96,18 +97,11 @@ def results_callback(
# Build table rows # Build table rows
rows = [] rows = []
for r in results_list: for r in results_list:
away_team = r.get("away_team", {}) away_abbrev = safe_nested(r, "away_team", "abbrev")
home_team = r.get("home_team", {}) home_abbrev = safe_nested(r, "home_team", "abbrev")
away_score = r.get("away_score", 0) away_score = r.get("away_score", 0)
home_score = r.get("home_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" # Format score as "away_score-home_score"
score_str = f"{away_score}-{home_score}" score_str = f"{away_score}-{home_score}"
@ -200,18 +194,11 @@ def results_list(
# Build table rows # Build table rows
rows = [] rows = []
for r in results_list: for r in results_list:
away_team = r.get("away_team", {}) away_abbrev = safe_nested(r, "away_team", "abbrev")
home_team = r.get("home_team", {}) home_abbrev = safe_nested(r, "home_team", "abbrev")
away_score = r.get("away_score", 0) away_score = r.get("away_score", 0)
home_score = r.get("home_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" # Format score as "away_score-home_score"
score_str = f"{away_score}-{home_score}" score_str = f"{away_score}-{home_score}"

View File

@ -15,6 +15,7 @@ from cli_common import (
output_table, output_table,
handle_error, handle_error,
get_season, get_season,
safe_nested,
) )
schedule_app = typer.Typer( schedule_app = typer.Typer(
@ -96,15 +97,8 @@ def schedule_callback(
# Build table rows # Build table rows
rows = [] rows = []
for s in schedules: for s in schedules:
away_team = s.get("away_team", {}) away_abbrev = safe_nested(s, "away_team", "abbrev")
home_team = s.get("home_team", {}) home_abbrev = safe_nested(s, "home_team", "abbrev")
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( rows.append(
[ [
@ -194,15 +188,8 @@ def schedule_list(
# Build table rows # Build table rows
rows = [] rows = []
for s in schedules: for s in schedules:
away_team = s.get("away_team", {}) away_abbrev = safe_nested(s, "away_team", "abbrev")
home_team = s.get("home_team", {}) home_abbrev = safe_nested(s, "home_team", "abbrev")
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( rows.append(
[ [

View File

@ -16,6 +16,7 @@ from cli_common import (
output_table, output_table,
handle_error, handle_error,
get_season, get_season,
safe_nested,
) )
transactions_app = typer.Typer( transactions_app = typer.Typer(
@ -77,8 +78,12 @@ def transactions_callback(
week_start = week week_start = week
week_end = week week_end = week
# Build filter parameters # Build filter parameters — include affiliate rosters (IL, MiL)
team_list = [team] if team else None 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 player_list = [player] if player else None
# Get transactions from API # Get transactions from API
@ -111,25 +116,9 @@ def transactions_callback(
# Build table rows # Build table rows
rows = [] rows = []
for t in transactions: for t in transactions:
player_dict = t.get("player", {}) player_name = safe_nested(t, "player", "name")
oldteam_dict = t.get("oldteam", {}) old_abbrev = safe_nested(t, "oldteam", "abbrev")
newteam_dict = t.get("newteam", {}) new_abbrev = safe_nested(t, "newteam", "abbrev")
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( rows.append(
[ [
@ -200,8 +189,12 @@ def transactions_list(
week_start = week week_start = week
week_end = week week_end = week
# Build filter parameters # Build filter parameters — include affiliate rosters (IL, MiL)
team_list = [team] if team else None 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 player_list = [player] if player else None
# Get transactions from API # Get transactions from API
@ -234,25 +227,9 @@ def transactions_list(
# Build table rows # Build table rows
rows = [] rows = []
for t in transactions: for t in transactions:
player_dict = t.get("player", {}) player_name = safe_nested(t, "player", "name")
oldteam_dict = t.get("oldteam", {}) old_abbrev = safe_nested(t, "oldteam", "abbrev")
newteam_dict = t.get("newteam", {}) new_abbrev = safe_nested(t, "newteam", "abbrev")
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( rows.append(
[ [
@ -363,11 +340,7 @@ def transactions_simulate(
p_name = player["name"] p_name = player["name"]
p_wara = float(player.get("wara", 0) or 0) p_wara = float(player.get("wara", 0) or 0)
p_team = ( p_team = safe_nested(player, "team", "abbrev", default="?")
player.get("team", {}).get("abbrev", "?")
if isinstance(player.get("team"), dict)
else "?"
)
# Determine if this move adds to or removes from ML # Determine if this move adds to or removes from ML
target_is_ml = target == team_upper target_is_ml = target == team_upper