""" Trello Plugin — Hermes Agent plugin for Trello board integration. Authentication & Connection module. Provides the TrelloClient for interacting with the Trello REST API. """ from __future__ import annotations import os from typing import Any, TypeGuard, TypedDict import requests # Base URL for the Trello REST API v1. # See https://developer.atlassian.com/cloud/trello/rest TRELLO_API_BASE = "https://api.trello.com/1" # --------------------------------------------------------------------------- # Typed response contracts # --------------------------------------------------------------------------- class SuccessResponse(TypedDict): """A successful API response carrying data.""" success: bool data: dict[str, Any] class ErrorResponse(TypedDict): """An unsuccessful API response with a human-readable message.""" success: bool message: str ApiResponse = SuccessResponse | ErrorResponse def _is_success(resp: ApiResponse) -> TypeGuard[SuccessResponse]: """Narrow an ApiResponse union to its success branch.""" return bool(resp.get("success", False)) # noqa: FBT003 # --------------------------------------------------------------------------- # Client # --------------------------------------------------------------------------- class TrelloClient: """Client for the Trello REST API. Reads credentials from environment variables TRELLO_API_KEY and TRELLO_TOKEN unless explicitly passed. All public methods return plain dicts suitable for JSON serialization by Hermes tool handlers. """ api_key: str token: str _session: requests.Session def __init__( self, api_key: str | None = None, token: str | None = None, session: requests.Session | None = None, ) -> None: self.api_key = api_key or os.environ.get("TRELLO_API_KEY", "") self.token = token or os.environ.get("TRELLO_TOKEN", "") self._session = session or requests.Session() # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _auth_params(self) -> dict[str, str]: """Return query parameters common to every Trello API call.""" return {"key": self.api_key, "token": self.token} def _check_credentials(self) -> ErrorResponse | None: """Return an ErrorResponse if credentials are missing, else None.""" if not self.api_key or not self.token: return ErrorResponse( success=False, message="TRELLO_API_KEY and TRELLO_TOKEN must both be set as environment variables.", ) return None def _handle_http_error(self, exc: requests.exceptions.HTTPError) -> ErrorResponse: """Map known HTTP status codes to user-facing error messages.""" status = exc.response.status_code if exc.response is not None else 0 messages: dict[int, str] = { 401: "Trello authentication failed (401). Check your TRELLO_API_KEY and TRELLO_TOKEN.", 403: "Trello access denied (403). Your token may not have the required scopes.", 429: "Trello rate limit exceeded. Try again later.", } message = messages.get( status, f"Trello API error ({status}): {exc}" ) return ErrorResponse(success=False, message=message) def _request( self, method: str, path: str, params: dict[str, Any] | None = None, json_body: dict[str, Any] | None = None, ) -> ApiResponse: """Make an authenticated HTTP request and return the JSON response. Handles credential checks, HTTP errors, network errors, and timeouts. """ cred_error = self._check_credentials() if cred_error is not None: return cred_error url = f"{TRELLO_API_BASE}{path}" merged_params = self._auth_params() if params: merged_params.update(params) try: resp = self._session.request( method, url, params=merged_params, json=json_body, timeout=15 ) resp.raise_for_status() data: dict[str, Any] = resp.json() return SuccessResponse(success=True, data=data) except requests.exceptions.HTTPError as exc: return self._handle_http_error(exc) except requests.exceptions.ConnectionError: return ErrorResponse( success=False, message="Could not connect to Trello API. Check your network connection.", ) except requests.exceptions.Timeout: return ErrorResponse( success=False, message="Trello API request timed out. Try again later.", ) except requests.exceptions.RequestException as exc: return ErrorResponse( success=False, message=f"Trello API request failed: {exc}" ) def _get( self, path: str, params: dict[str, Any] | None = None ) -> ApiResponse: """Make an authenticated GET request.""" return self._request("GET", path, params=params) def _post( self, path: str, json_body: dict[str, Any] | None = None ) -> ApiResponse: """Make an authenticated POST request.""" return self._request("POST", path, json_body=json_body) def _put( self, path: str, json_body: dict[str, Any] | None = None ) -> ApiResponse: """Make an authenticated PUT request.""" return self._request("PUT", path, json_body=json_body) def _delete(self, path: str) -> ApiResponse: """Make an authenticated DELETE request.""" return self._request("DELETE", path) # ------------------------------------------------------------------ # Public API — Auth & Connection # ------------------------------------------------------------------ def verify_credentials(self) -> dict[str, Any]: """Verify that the stored credentials are valid by fetching member info. Returns: dict with ``success`` bool and ``message`` string. On success the ``member`` key contains the Trello member id and username. """ result = self._get("/members/me") if _is_success(result): member = result["data"] return { "success": True, "message": ( f"✓ Trello credentials verified successfully " f"(authenticated as {member.get('username', 'unknown')})" ), "member": { "id": member.get("id"), "username": member.get("username"), "full_name": member.get("fullName"), }, } return { "success": False, "message": result.get("message", "Trello credential verification failed."), } def list_boards(self) -> dict[str, Any]: """Fetch all boards accessible to the authenticated user. Returns: dict with ``success`` bool and a ``boards`` list (each containing ``id``, ``name``, ``url``, ``closed``, ``starred``). """ result = self._get( "/members/me/boards", params={"fields": "id,name,url,closed,starred"} ) if not _is_success(result): return { "success": False, "message": result.get("message", "Failed to list Trello boards."), } boards = [ { "id": b.get("id"), "name": b.get("name"), "url": b.get("url"), "closed": b.get("closed", False), "starred": b.get("starred", False), } for b in result["data"] ] return {"success": True, "boards": boards} def disconnect(self) -> dict[str, Any]: """Clear credentials from the in-memory client. Since credentials are stored in environment variables, the next tool call will automatically re-read them and reconnect. To fully disconnect, also unset TRELLO_API_KEY and TRELLO_TOKEN from the Hermes profile config. Note: this does NOT revoke the Trello token. The user must invalidate it via Trello's account settings if they want full revocation. """ self.api_key = "" self.token = "" return { "success": True, "message": ( "Trello credentials cleared from memory. " "Next tool call will re-read TRELLO_API_KEY and TRELLO_TOKEN " "from environment and reconnect automatically." ), } # ------------------------------------------------------------------ # Public API — Board Management # ------------------------------------------------------------------ def _resolve_board_id(self, board_id: str) -> dict[str, Any]: """Resolve a board name or ID to a Trello board ID. Returns board info dict with at least an ``id`` key on success, or an error dict with ``success: False``. """ # Try as ID first (Trello IDs are 8-24 char hex strings) result = self._get(f"/boards/{board_id}", params={"fields": "id,name,url"}) if _is_success(result): board = result["data"] return {"id": board["id"], "name": board.get("name", ""), "url": board.get("url", "")} # If the error is about missing credentials, propagate that directly error_msg = result.get("message", "") if "TRELLO_API_KEY" in error_msg: return {"success": False, "message": error_msg} # Try resolving by name boards_result = self.list_boards() if not _is_success(boards_result): # type: ignore[arg-type] return { "success": False, "message": ( f"Board '{board_id}' not found. " "Use trello_list_boards to see available boards." ), } matches = [b for b in boards_result["boards"] if b["name"] == board_id] # type: ignore[typeddict-item] if len(matches) == 1: return dict(matches[0]) # already has id, name, url if len(matches) > 1: return { "success": False, "message": ( f"Multiple boards named '{board_id}' found. " "Use the board ID instead of the name." ), } return { "success": False, "message": ( f"Board '{board_id}' not found. " "Use trello_list_boards to see available boards." ), } def create_board( self, name: str, default_lists: bool = True ) -> dict[str, Any]: """Create a new Trello board. Args: name: The name for the new board. default_lists: Whether to create the default lists (default: True). Returns: dict with board details on success, or an error dict. """ body: dict[str, Any] = { "name": name, "defaultLists": default_lists, } result = self._post("/boards", json_body=body) if _is_success(result): board = result["data"] return { "success": True, "board": { "id": board.get("id"), "name": board.get("name"), "url": board.get("url"), }, } return { "success": False, "message": result.get("message", "Failed to create board."), } def rename_board(self, board_id: str, name: str) -> dict[str, Any]: """Rename an existing Trello board. Args: board_id: Board ID or name. name: The new name for the board. Returns: dict with board details on success, or an error dict. """ resolved = self._resolve_board_id(board_id) if "success" in resolved and resolved["success"] is False: return resolved # type: ignore[typeddict-item] trello_id = resolved["id"] result = self._put( f"/boards/{trello_id}", json_body={"name": name} ) if _is_success(result): board = result["data"] return { "success": True, "board": { "id": board.get("id"), "name": board.get("name"), "url": board.get("url"), }, } return { "success": False, "message": result.get("message", "Failed to rename board."), } def archive_board(self, board_id: str) -> dict[str, Any]: """Close/archive a Trello board. Args: board_id: Board ID or name. Returns: dict with success message or error. """ resolved = self._resolve_board_id(board_id) if "success" in resolved and resolved["success"] is False: return resolved # type: ignore[typeddict-item] trello_id = resolved["id"] board_name = resolved.get("name", trello_id) result = self._put( f"/boards/{trello_id}", json_body={"closed": True} ) if _is_success(result): return {"success": True, "message": f"Board '{board_name}' archived."} return { "success": False, "message": result.get("message", "Failed to archive board."), } def open_board(self, board_id: str) -> dict[str, Any]: """Re-open a closed/archived Trello board. Args: board_id: Board ID or name. Returns: dict with success message or error. """ resolved = self._resolve_board_id(board_id) if "success" in resolved and resolved["success"] is False: return resolved # type: ignore[typeddict-item] trello_id = resolved["id"] board_name = resolved.get("name", trello_id) result = self._put( f"/boards/{trello_id}", json_body={"closed": False} ) if _is_success(result): return {"success": True, "message": f"Board '{board_name}' opened."} return { "success": False, "message": result.get("message", "Failed to open board."), } def board_details(self, board_id: str) -> dict[str, Any]: """Fetch detailed information about a Trello board. Returns the board's lists, members, and a direct URL. Args: board_id: Board ID or name. Returns: dict with board details including lists and members on success. """ resolved = self._resolve_board_id(board_id) if "success" in resolved and resolved["success"] is False: return resolved # type: ignore[typeddict-item] trello_id = resolved["id"] result = self._get( f"/boards/{trello_id}", params={ "fields": "id,name,desc,url,closed,starred", "lists": "all", "members": "all", "members_fields": "id,username,fullName", }, ) if not _is_success(result): return { "success": False, "message": result.get("message", "Failed to fetch board details."), } board = result["data"] return { "success": True, "board": { "id": board.get("id"), "name": board.get("name"), "url": board.get("url"), "desc": board.get("desc", ""), "closed": board.get("closed", False), "starred": board.get("starred", False), "lists": [ {"id": lst.get("id"), "name": lst.get("name")} for lst in board.get("lists", []) ], "members": [ { "id": m.get("id"), "username": m.get("username"), "full_name": m.get("fullName"), } for m in board.get("members", []) ], }, } # ------------------------------------------------------------------ # Public API — List Management # ------------------------------------------------------------------ def create_list(self, name: str, board_id: str, pos: str = "bottom") -> dict[str, Any]: """Create a new list on a Trello board. Args: name: The name for the new list. board_id: Board ID or name. pos: Position — ``"top"``, ``"bottom"``, or a number. Returns: dict with list details on success, or an error dict. """ resolved = self._resolve_board_id(board_id) if "success" in resolved and resolved["success"] is False: return resolved # type: ignore[typeddict-item] body: dict[str, Any] = { "name": name, "idBoard": resolved["id"], "pos": pos, } result = self._post("/lists", json_body=body) if _is_success(result): lst = result["data"] return { "success": True, "list": { "id": lst.get("id"), "name": lst.get("name"), "id_board": lst.get("idBoard"), }, } return { "success": False, "message": result.get("message", "Failed to create list."), } def rename_list(self, list_id: str, name: str) -> dict[str, Any]: """Rename an existing list. Args: list_id: Trello list ID. name: The new name. Returns: dict with list details on success, or an error dict. """ result = self._put(f"/lists/{list_id}", json_body={"name": name}) if _is_success(result): lst = result["data"] return { "success": True, "list": { "id": lst.get("id"), "name": lst.get("name"), }, } return { "success": False, "message": result.get("message", "Failed to rename list."), } def archive_list(self, list_id: str) -> dict[str, Any]: """Archive a list. Args: list_id: Trello list ID. Returns: dict with success message or error. """ result = self._put(f"/lists/{list_id}", json_body={"closed": True}) if _is_success(result): lst = result["data"] list_name = lst.get("name", list_id) return {"success": True, "message": f"List '{list_name}' archived."} return { "success": False, "message": result.get("message", "Failed to archive list."), } def move_list(self, list_id: str, pos: str = "bottom") -> dict[str, Any]: """Move a list to a different position on the board. Args: list_id: Trello list ID. pos: Position — ``"top"``, ``"bottom"``, or a number. Returns: dict with success message or error. """ result = self._put(f"/lists/{list_id}", json_body={"pos": pos}) if _is_success(result): lst = result["data"] list_name = lst.get("name", list_id) return {"success": True, "message": f"List '{list_name}' moved."} return { "success": False, "message": result.get("message", "Failed to move list."), } # ------------------------------------------------------------------ # Public API — Card Management # ------------------------------------------------------------------ def create_card( self, name: str, list_id: str, desc: str = "", due: str | None = None, ) -> dict[str, Any]: """Create a new card on a Trello list. Args: name: Card title. list_id: Trello list ID. desc: Card description. due: Due date in ISO 8601 format. Returns: dict with card details on success. """ body: dict[str, Any] = {"name": name, "idList": list_id} if desc: body["desc"] = desc if due: body["due"] = due result = self._post("/cards", json_body=body) if _is_success(result): card = result["data"] return { "success": True, "card": { "id": card.get("id"), "name": card.get("name"), "url": card.get("url"), "short_url": card.get("shortUrl"), "id_list": card.get("idList"), }, } return { "success": False, "message": result.get("message", "Failed to create card."), } def card_details(self, card_id: str) -> dict[str, Any]: """Fetch detailed information about a card. Returns card attributes, members, checklists, and comments. Args: card_id: Trello card ID. Returns: dict with full card details. """ result = self._get( f"/cards/{card_id}", params={ "fields": "id,name,desc,due,dueComplete,url,shortUrl,idList,closed", "members": "true", "member_fields": "id,username,fullName", "checklists": "all", "actions": "commentCard", "actions_limit": "50", "list": "true", }, ) if not _is_success(result): return { "success": False, "message": result.get("message", "Failed to fetch card details."), } card = result["data"] return { "success": True, "card": { "id": card.get("id"), "name": card.get("name"), "desc": card.get("desc", ""), "due": card.get("due"), "due_complete": card.get("dueComplete", False), "url": card.get("url"), "short_url": card.get("shortUrl"), "closed": card.get("closed", False), "list": {"id": card.get("idList"), "name": (card.get("list") or {}).get("name", "")}, "members": [ {"id": m.get("id"), "username": m.get("username"), "full_name": m.get("fullName")} for m in card.get("members", []) ], "checklists": [ { "id": cl.get("id"), "name": cl.get("name"), "items": [ { "id": item.get("id"), "name": item.get("name"), "state": item.get("state"), } for item in cl.get("checkItems", []) ], } for cl in card.get("checklists", []) ], "comments": [ { "id": a.get("id"), "text": a.get("data", {}).get("text", ""), "member": a.get("memberCreator", {}).get("username", "unknown"), "date": a.get("date"), } for a in card.get("actions", []) if a.get("type") == "commentCard" ], }, } def update_card( self, card_id: str, name: str | None = None, desc: str | None = None, due: str | None = None, ) -> dict[str, Any]: """Update a card's core attributes. Args: card_id: Trello card ID. name: New title (omit to keep current). desc: New description (omit to keep current). due: New due date ISO 8601, or empty string to clear. Returns: dict with updated card details. """ body: dict[str, Any] = {} if name is not None: body["name"] = name if desc is not None: body["desc"] = desc if due is not None: body["due"] = due if not body: return { "success": True, "message": "No changes specified.", } result = self._put(f"/cards/{card_id}", json_body=body) if _is_success(result): card = result["data"] return { "success": True, "card": { "id": card.get("id"), "name": card.get("name"), "desc": card.get("desc", ""), "due": card.get("due"), }, } return { "success": False, "message": result.get("message", "Failed to update card."), } def move_card(self, card_id: str, list_id: str) -> dict[str, Any]: """Move a card to a different list. Args: card_id: Trello card ID. list_id: Target list ID. Returns: dict with success message. """ result = self._put(f"/cards/{card_id}", json_body={"idList": list_id}) if _is_success(result): card = result["data"] return { "success": True, "message": f"Card '{card.get('name', card_id)}' moved to list '{card.get('idList', list_id)}'.", } return { "success": False, "message": result.get("message", "Failed to move card."), } def archive_card(self, card_id: str) -> dict[str, Any]: """Archive a card. Args: card_id: Trello card ID. Returns: dict with success message. """ result = self._put(f"/cards/{card_id}", json_body={"closed": True}) if _is_success(result): card = result["data"] return {"success": True, "message": f"Card '{card.get('name', card_id)}' archived."} return { "success": False, "message": result.get("message", "Failed to archive card."), } def assign_member(self, card_id: str, member_id: str) -> dict[str, Any]: """Add a member to a card. Args: card_id: Trello card ID. member_id: Trello member ID. Returns: dict with success message. """ result = self._post(f"/cards/{card_id}/idMembers", json_body={"value": member_id}) if _is_success(result): return {"success": True, "message": f"Member {member_id} assigned to card."} return { "success": False, "message": result.get("message", "Failed to assign member."), } def remove_member(self, card_id: str, member_id: str) -> dict[str, Any]: """Remove a member from a card. Args: card_id: Trello card ID. member_id: Trello member ID. Returns: dict with success message. """ result = self._delete(f"/cards/{card_id}/idMembers/{member_id}") if _is_success(result): return {"success": True, "message": f"Member {member_id} removed from card."} return { "success": False, "message": result.get("message", "Failed to remove member."), } def add_comment(self, card_id: str, text: str) -> dict[str, Any]: """Add a comment to a card. Args: card_id: Trello card ID. text: Comment text. Returns: dict with comment details. """ result = self._post( f"/cards/{card_id}/actions/comments", json_body={"text": text}, ) if _is_success(result): action = result["data"] return { "success": True, "comment": { "id": action.get("id"), "text": action.get("data", {}).get("text", text), }, } return { "success": False, "message": result.get("message", "Failed to add comment."), } def delete_comment(self, card_id: str, comment_id: str) -> dict[str, Any]: """Delete a comment from a card. Args: card_id: Trello card ID. comment_id: Action/comment ID. Returns: dict with success message. """ result = self._delete(f"/cards/{card_id}/actions/{comment_id}/comments") if _is_success(result): return {"success": True, "message": "Comment deleted."} return { "success": False, "message": result.get("message", "Failed to delete comment."), } def add_checklist_item(self, card_id: str, name: str, checklist_id: str | None = None) -> dict[str, Any]: """Add a checklist item to a card. Args: card_id: Trello card ID. name: Checklist item text. checklist_id: Specific checklist (fetches first if omitted). Returns: dict with item details. """ resolved_checklist_id = checklist_id if not resolved_checklist_id: # Fetch checklists to find the first one details = self.card_details(card_id) if not details.get("success"): return { "success": False, "message": "Card has no checklists. Add one via Trello web first.", } checklists = details["card"].get("checklists", []) if not checklists: return { "success": False, "message": "Card has no checklists. Add one via Trello web first.", } resolved_checklist_id = checklists[0]["id"] result = self._post( f"/cards/{card_id}/checklistItems", json_body={ "idChecklist": resolved_checklist_id, "name": name, "checked": False, }, ) if _is_success(result): item = result["data"] return { "success": True, "item": { "id": item.get("id"), "name": item.get("name"), "state": item.get("state", "incomplete"), "id_checklist": item.get("idChecklist"), }, } return { "success": False, "message": result.get("message", "Failed to add checklist item."), } def toggle_checklist_item(self, card_id: str, item_id: str, checked: bool) -> dict[str, Any]: # noqa: FBT001 """Mark a checklist item as complete or incomplete. Args: card_id: Trello card ID. item_id: Checklist item ID. checked: True for complete, False for incomplete. Returns: dict with updated item state. """ state = "complete" if checked else "incomplete" result = self._put( f"/cards/{card_id}/checklistItem/{item_id}", json_body={"state": state}, ) if _is_success(result): item = result["data"] return { "success": True, "item": { "id": item.get("id"), "name": item.get("name"), "state": item.get("state"), }, } return { "success": False, "message": result.get("message", "Failed to update checklist item."), } def delete_checklist_item(self, card_id: str, item_id: str) -> dict[str, Any]: """Remove a checklist item. Args: card_id: Trello card ID. item_id: Checklist item ID. Returns: dict with success message. """ result = self._delete(f"/cards/{card_id}/checklistItems/{item_id}") if _is_success(result): return {"success": True, "message": "Checklist item deleted."} return { "success": False, "message": result.get("message", "Failed to delete checklist item."), } @staticmethod def check_requirements() -> bool: """Check if the required environment variables are set.""" return bool(os.environ.get("TRELLO_API_KEY")) and bool( os.environ.get("TRELLO_TOKEN") )