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`.
## 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

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": [
{
"plugin": "code-review@claude-plugins-official",

View File

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

View File

@ -225,6 +225,7 @@ ls -lt ~/.local/share/claude-scheduled/logs/<task-name>/
| 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

View File

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

View File

@ -38,7 +38,7 @@ Comprehensive system for managing the **Strat-o-Matic Baseball Association (SBA)
```bash
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
- 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.
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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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