From 6dac5d225e3adb21a5e17b53c6ebea59c1901112 Mon Sep 17 00:00:00 2001 From: "Marko (Hermes Implementer)" Date: Tue, 26 May 2026 21:30:35 +0000 Subject: [PATCH] feat: implement Trello authentication & connection plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TrelloClient for Trello REST API interaction with env-var-based credential management (TRELLO_API_KEY, TRELLO_TOKEN). Implements: - trello_verify_credentials — verify API key/token against Trello API - trello_list_boards — list accessible Trello boards - trello_disconnect — clear in-memory credentials Includes full test suite (18 tests), spec document, and plugin metadata. Issue: #1 --- .gitignore | 5 + README.md | 94 ++++++++- docs/backend/trello-auth-spec.md | 111 +++++++++++ pyproject.toml | 35 ++++ src/trello_plugin/__init__.py | 25 +++ src/trello_plugin/client.py | 175 ++++++++++++++++ src/trello_plugin/tools.py | 96 +++++++++ tests/test_auth.py | 333 +++++++++++++++++++++++++++++++ 8 files changed, 873 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 docs/backend/trello-auth-spec.md create mode 100644 pyproject.toml create mode 100644 src/trello_plugin/__init__.py create mode 100644 src/trello_plugin/client.py create mode 100644 src/trello_plugin/tools.py create mode 100644 tests/test_auth.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59adb4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.egg-info/ +*.pyc +*.pyo +.pytest_cache/ \ No newline at end of file diff --git a/README.md b/README.md index e19508a..9ef7109 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,95 @@ # trello-plugin -Hermes Agent plugin for Trello board integration \ No newline at end of file +Hermes Agent plugin for Trello board integration. + +## Setup + +### 1. Get Trello Credentials + +1. Get your **API Key** from: https://trello.com/app-key +2. Generate a **Token** from the same page (click "Token" under API Key) +3. Set these as environment variables in your Hermes profile config (`.env` or `config.yaml`): + +```bash +export TRELLO_API_KEY="your-trello-api-key" +export TRELLO_TOKEN="your-trello-token" +``` + +### 2. Install + +```bash +cd trello-plugin +pip install . +``` + +For development: + +```bash +pip install -e ".[dev]" +``` + +### 3. Register with Hermes Agent + +Add the plugin to your Hermes config (`~/.hermes/config.yaml` or profile): + +```yaml +plugins: + - trello-plugin +``` + +## Available Tools + +Once configured, the agent has access to these tools: + +### `trello_verify_credentials` +Verifies your Trello API key and token are valid by calling the Trello API. + +``` +✓ Trello credentials verified successfully (authenticated as your-username) +``` + +### `trello_list_boards` +Lists all Trello boards accessible with your credentials. + +| Field | Description | +|-------|-------------| +| `id` | Trello board ID | +| `name` | Board name | +| `url` | Direct Trello URL | +| `closed` | Whether the board is archived | +| `starred` | Whether the board is starred | + +### `trello_disconnect` +Clears Trello credentials from the in-memory client. Since credentials are stored in environment variables, the agent can reconnect automatically on the next tool call. + +## Development + +```bash +# Install dev dependencies +pip install -e ".[dev]" + +# Run tests +PYTHONPATH=src python3 -m pytest tests/ -v + +# Lint +pip install ruff +ruff check src/ +``` + +## Architecture + +``` +trello-plugin/ +├── src/ +│ └── trello_plugin/ +│ ├── __init__.py # Package exports +│ ├── client.py # TrelloClient — REST API wrapper +│ └── tools.py # Hermes tool definitions +├── tests/ +│ └── test_auth.py # Test suite (18 tests) +├── docs/ +│ └── backend/ +│ └── trello-auth-spec.md +├── pyproject.toml +└── README.md +``` \ No newline at end of file diff --git a/docs/backend/trello-auth-spec.md b/docs/backend/trello-auth-spec.md new file mode 100644 index 0000000..1818f2c --- /dev/null +++ b/docs/backend/trello-auth-spec.md @@ -0,0 +1,111 @@ +# Trello Plugin — Authentication & Connection + +**Feature:** US: Authenticate and Connect to Trello Account +**Issue:** #1 +**Branch:** `feature/trello-auth` + +## Overview + +This feature provides the authentication layer for the Trello plugin. Users configure their Trello credentials via environment variables (`TRELLO_API_KEY` and `TRELLO_TOKEN`), and the plugin exposes tools to verify credentials, list accessible boards, and disconnect. + +## Design Decisions + +- **No interactive credential input** — The Hermes Agent has no UI for prompting users. Credentials are set in the profile's `.env` or `config.yaml` as environment variables, which is the standard Hermes pattern. +- **Trello uses API Key + Token auth** — Trello's API requires an API key (identifies the application) and a token (user-specific authorization). Both are required for any API call. +- **One client class** — All Trello API interactions go through a single `TrelloClient` class to centralize auth, base URL, and error handling. Future features (boards, lists, cards) will extend this same client. + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `TRELLO_API_KEY` | Yes | Trello API application key | +| `TRELLO_TOKEN` | Yes | Trello user authorization token | + +## Tools Exposed + +### 1. `trello_verify_credentials` + +Verifies that `TRELLO_API_KEY` and `TRELLO_TOKEN` are set and the Trello API responds successfully. + +**Parameters:** None + +**Returns:** +```json +{ + "success": true, + "message": "✓ Trello credentials verified successfully (authenticated as user@example.com)" +} +``` + +On failure: +```json +{ + "success": false, + "message": "✗ Trello authentication failed: invalid key" +} +``` + +### 2. `trello_list_boards` + +Fetches and returns all Trello boards accessible to the authenticated user. + +**Parameters:** None + +**Returns:** +```json +{ + "success": true, + "boards": [ + {"id": "abc123", "name": "My Project Board", "url": "https://trello.com/b/abc123"}, + {"id": "def456", "name": "Personal Tasks", "url": "https://trello.com/b/def456"} + ] +} +``` + +### 3. `trello_disconnect` + +Clears the stored credentials from memory. Note: this does not revoke the Trello token — the user must invalidate it via Trello's settings if needed. + +**Parameters:** None + +**Returns:** +```json +{ + "success": true, + "message": "Trello credentials cleared. Set TRELLO_API_KEY and TRELLO_TOKEN again to reconnect." +} +``` + +## API Contract + +### Internal: `TrelloClient` + +```python +class TrelloClient: + def __init__(self, api_key: str | None = None, token: str | None = None) + + def verify_credentials(self) -> dict + def list_boards(self) -> list[dict] + def disconnect(self) -> dict +``` + +### Trello API Endpoints Used + +| Purpose | Method | Endpoint | Docs | +|---------|--------|----------|------| +| Verify credentials | GET | `/1/members/me` | https://developer.atlassian.com/cloud/trello/rest/api-group-members/ | +| List boards | GET | `/1/members/me/boards` | https://developer.atlassian.com/cloud/trello/rest/api-group-boards/ | + +## Error Handling + +- Missing env vars → return `{"success": false, "message": "TRELLO_API_KEY and TRELLO_TOKEN must be set"}` with env var name +- Network errors → catch `requests.exceptions.RequestException` and return descriptive message +- Auth failures (401/403) → clear message about invalid credentials +- Rate limiting → Trello returns 429; surface the retry-after header + +## Testing + +- Unit tests with mocked HTTP responses +- Test all error paths: missing creds, invalid creds, network error, rate limit +- Test successful verification via mock that returns member info +- Test board listing with mock JSON response \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6037ebb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "trello-plugin" +version = "0.1.0" +description = "Hermes Agent plugin for Trello board integration" +requires-python = ">=3.12" +license = {text = "MIT"} +dependencies = [ + "requests>=2.31", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-cov>=5", + "requests-mock>=1.12", + "ruff>=0.4", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP", "PL"] diff --git a/src/trello_plugin/__init__.py b/src/trello_plugin/__init__.py new file mode 100644 index 0000000..63ca571 --- /dev/null +++ b/src/trello_plugin/__init__.py @@ -0,0 +1,25 @@ +""" +trello-plugin — Hermes Agent plugin for Trello board integration. +""" + +from trello_plugin.tools import ( + PLUGIN_DESCRIPTION, + PLUGIN_NAME, + PLUGIN_TOOLS, + PLUGIN_VERSION, + check_requirements, + trello_disconnect, + trello_list_boards, + trello_verify_credentials, +) + +__all__ = [ + "PLUGIN_NAME", + "PLUGIN_DESCRIPTION", + "PLUGIN_VERSION", + "PLUGIN_TOOLS", + "check_requirements", + "trello_verify_credentials", + "trello_list_boards", + "trello_disconnect", +] \ No newline at end of file diff --git a/src/trello_plugin/client.py b/src/trello_plugin/client.py new file mode 100644 index 0000000..61796a6 --- /dev/null +++ b/src/trello_plugin/client.py @@ -0,0 +1,175 @@ +""" +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 + +import requests + +TRELLO_API_BASE = "https://api.trello.com/1" + + +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. + """ + + 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 _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + """Make an authenticated GET request and return the JSON response.""" + if not self.api_key or not self.token: + return { + "success": False, + "message": "TRELLO_API_KEY and TRELLO_TOKEN must both be set as environment variables.", + } + + url = f"{TRELLO_API_BASE}{path}" + merged_params = self._auth_params() + if params: + merged_params.update(params) + + try: + resp = self._session.get(url, params=merged_params, timeout=15) + resp.raise_for_status() + data: dict[str, Any] = resp.json() + return {"success": True, "data": data} + except requests.exceptions.HTTPError as exc: + status = exc.response.status_code if exc.response is not None else 0 + if status == 401: + return { + "success": False, + "message": "Trello authentication failed (401). Check your TRELLO_API_KEY and TRELLO_TOKEN.", + } + if status == 403: + return { + "success": False, + "message": "Trello access denied (403). Your token may not have the required scopes.", + } + if status == 429: + return { + "success": False, + "message": "Trello rate limit exceeded. Try again later.", + } + return { + "success": False, + "message": f"Trello API error ({status}): {exc}", + } + except requests.exceptions.ConnectionError: + return { + "success": False, + "message": "Could not connect to Trello API. Check your network connection.", + } + except requests.exceptions.Timeout: + return { + "success": False, + "message": "Trello API request timed out. Try again later.", + } + except requests.exceptions.RequestException as exc: + return { + "success": False, + "message": f"Trello API request failed: {exc}", + } + + # ------------------------------------------------------------------ + # 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 result["success"]: + 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 result["success"]: + 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. + + 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. " + "Set TRELLO_API_KEY and TRELLO_TOKEN again to reconnect." + ), + } + + @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")) \ No newline at end of file diff --git a/src/trello_plugin/tools.py b/src/trello_plugin/tools.py new file mode 100644 index 0000000..8920923 --- /dev/null +++ b/src/trello_plugin/tools.py @@ -0,0 +1,96 @@ +""" +Trello Plugin — Hermes Agent tool definitions. + +Each function decorated with ``@tool`` registers itself as a Hermes tool +that the agent can invoke during a conversation. +""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from trello_plugin.client import TrelloClient + +# --------------------------------------------------------------------------- +# Module-level client (lazily initialised so env vars can be overridden) +# --------------------------------------------------------------------------- + +_client: TrelloClient | None = None + + +def _get_client() -> TrelloClient: + global _client # noqa: PLW0603 + api_key = os.environ.get("TRELLO_API_KEY", "") + token = os.environ.get("TRELLO_TOKEN", "") + if _client is None or _client.api_key != api_key or _client.token != token: + _client = TrelloClient(api_key=api_key, token=token) + return _client + + +# --------------------------------------------------------------------------- +# Tool helpers +# --------------------------------------------------------------------------- + +def _respond(data: dict[str, Any]) -> str: + """Wrap a result dict in a JSON string, the Hermes tool contract.""" + return json.dumps(data, indent=2) + + +# --------------------------------------------------------------------------- +# Tools +# --------------------------------------------------------------------------- + +def trello_verify_credentials() -> str: + """Verify that the Trello API key and token are valid. + + Checks the ``TRELLO_API_KEY`` and ``TRELLO_TOKEN`` environment variables + and attempts a test call to the Trello API. Returns the authenticated + user's Trello username on success. + """ + client = _get_client() + result = client.verify_credentials() + return _respond(result) + + +def trello_list_boards() -> str: + """List all Trello boards accessible to the authenticated user. + + Requires ``TRELLO_API_KEY`` and ``TRELLO_TOKEN`` to be set. + Returns board id, name, URL, and whether the board is closed/starred. + """ + client = _get_client() + result = client.list_boards() + return _respond(result) + + +def trello_disconnect() -> str: + """Disconnect from Trello by clearing credentials from memory. + + This does NOT revoke the Trello token — invalidate it via + Trello's account settings if full revocation is desired. + """ + global _client # noqa: PLW0603 + _client = None + return _respond({"success": True, "message": "Trello credentials cleared. Reconnect by calling trello_verify_credentials after setting TRELLO_API_KEY and TRELLO_TOKEN."}) + + +# --------------------------------------------------------------------------- +# Plugin metadata (used by Hermes plugin loader) +# --------------------------------------------------------------------------- + +PLUGIN_NAME = "trello-plugin" +PLUGIN_DESCRIPTION = "Hermes Agent plugin for Trello board integration" +PLUGIN_VERSION = "0.1.0" +PLUGIN_TOOLS = [ + trello_verify_credentials, + trello_list_boards, + trello_disconnect, +] +PLUGIN_REQUIRES_ENV = ["TRELLO_API_KEY", "TRELLO_TOKEN"] + + +def check_requirements() -> bool: + """Return True when both Trello env vars are present.""" + return TrelloClient.check_requirements() \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..857a445 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,333 @@ +"""Tests for the Trello plugin — auth & connection feature.""" + +from __future__ import annotations + +import json +import os +from collections.abc import Generator +from typing import Any + +import pytest +import requests + +# Tell pytest we plan to set env vars in tests +pytestmark = pytest.mark.usefixtures("clear_env") + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def clear_env() -> Generator[None, None, None]: + """Remove Trello env vars before each test so state is predictable.""" + # Use monkeypatch via request directly — fallback to os.environ + saved_key = os.environ.pop("TRELLO_API_KEY", None) + saved_token = os.environ.pop("TRELLO_TOKEN", None) + yield + if saved_key is not None: + os.environ["TRELLO_API_KEY"] = saved_key + if saved_token is not None: + os.environ["TRELLO_TOKEN"] = saved_token + + +def _make_session(mocker: Any) -> requests.Session: + """Build a requests.Session with a mocked adapter.""" + session = requests.Session() + adapter = mocker.get_adapter() + session.mount("https://", adapter) + return session + + +# --------------------------------------------------------------------------- +# TrelloClient — verify_credentials +# --------------------------------------------------------------------------- + +class TestVerifyCredentials: + """Tests for TrelloClient.verify_credentials().""" + + def test_success(self, requests_mock: Any) -> None: + """Happy path: valid creds return member info.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/members/me", + json={"id": "member123", "username": "testuser", "fullName": "Test User"}, + ) + client = TrelloClient(api_key="key", token="tok") + result = client.verify_credentials() + + assert result["success"] is True + assert "testuser" in result["message"] + assert result["member"]["id"] == "member123" + assert result["member"]["username"] == "testuser" + + def test_missing_credentials(self) -> None: + """Both API key and token must be set.""" + from trello_plugin.client import TrelloClient + + client = TrelloClient(api_key="", token="") + result = client.verify_credentials() + + assert result["success"] is False + assert "TRELLO_API_KEY" in result["message"] + + def test_auth_failure_401(self, requests_mock: Any) -> None: + """401 response surfaces as auth failure.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/members/me", + status_code=401, + ) + client = TrelloClient(api_key="bad_key", token="bad_tok") + result = client.verify_credentials() + + assert result["success"] is False + assert "401" in result["message"] + + def test_forbidden_403(self, requests_mock: Any) -> None: + """403 surfaces as access denied.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/members/me", + status_code=403, + ) + client = TrelloClient(api_key="key", token="tok") + result = client.verify_credentials() + + assert result["success"] is False + assert "403" in result["message"] + + def test_rate_limit_429(self, requests_mock: Any) -> None: + """429 surfaces as rate limit error.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/members/me", + status_code=429, + ) + client = TrelloClient(api_key="key", token="tok") + result = client.verify_credentials() + + assert result["success"] is False + assert "rate limit" in result["message"].lower() + + def test_network_error(self, requests_mock: Any) -> None: + """Connection errors surface as network failure.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/members/me", + exc=requests.exceptions.ConnectionError("connection refused"), + ) + client = TrelloClient(api_key="key", token="tok") + result = client.verify_credentials() + + assert result["success"] is False + assert "connect" in result["message"].lower() + + def test_timeout(self, requests_mock: Any) -> None: + """Timeout surfaces gracefully.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/members/me", + exc=requests.exceptions.Timeout("timed out"), + ) + client = TrelloClient(api_key="key", token="tok") + result = client.verify_credentials() + + assert result["success"] is False + assert "timed out" in result["message"].lower() + + +# --------------------------------------------------------------------------- +# TrelloClient — list_boards +# --------------------------------------------------------------------------- + +class TestListBoards: + """Tests for TrelloClient.list_boards().""" + + def test_success(self, requests_mock: Any) -> None: + """Happy path: returns a list of boards.""" + from trello_plugin.client import TrelloClient + + mock_boards = [ + {"id": "b1", "name": "Board One", "url": "https://trello.com/b/b1", "closed": False, "starred": False}, + {"id": "b2", "name": "Board Two", "url": "https://trello.com/b/b2", "closed": True, "starred": False}, + ] + requests_mock.get( + "https://api.trello.com/1/members/me/boards", + json=mock_boards, + ) + client = TrelloClient(api_key="key", token="tok") + result = client.list_boards() + + assert result["success"] is True + assert len(result["boards"]) == 2 + assert result["boards"][0]["name"] == "Board One" + assert result["boards"][1]["closed"] is True + + def test_missing_credentials(self) -> None: + """Missing creds returns error before any network call.""" + from trello_plugin.client import TrelloClient + + client = TrelloClient(api_key="", token="") + result = client.list_boards() + + assert result["success"] is False + assert "TRELLO_API_KEY" in result["message"] + + def test_empty_boards(self, requests_mock: Any) -> None: + """No boards returns an empty list.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/members/me/boards", + json=[], + ) + client = TrelloClient(api_key="key", token="tok") + result = client.list_boards() + + assert result["success"] is True + assert result["boards"] == [] + + +# --------------------------------------------------------------------------- +# TrelloClient — disconnect +# --------------------------------------------------------------------------- + +class TestDisconnect: + """Tests for TrelloClient.disconnect().""" + + def test_disconnect_clears_credentials(self) -> None: + """Disconnect clears the in-memory credentials.""" + from trello_plugin.client import TrelloClient + + client = TrelloClient(api_key="key", token="tok") + result = client.disconnect() + + assert result["success"] is True + assert "cleared" in result["message"].lower() + + # Subsequent calls should fail + verify_result = client.verify_credentials() + assert verify_result["success"] is False + assert "TRELLO_API_KEY" in verify_result["message"] + + +# --------------------------------------------------------------------------- +# check_requirements +# --------------------------------------------------------------------------- + +class TestCheckRequirements: + """Tests for TrelloClient.check_requirements().""" + + def test_returns_false_when_missing(self) -> None: + """Returns False when env vars are not set.""" + from trello_plugin.client import TrelloClient + + assert TrelloClient.check_requirements() is False + + def test_returns_true_when_set(self) -> None: + """Returns True when both env vars are set.""" + from trello_plugin.client import TrelloClient + + os.environ["TRELLO_API_KEY"] = "test-key" + os.environ["TRELLO_TOKEN"] = "test-token" + assert TrelloClient.check_requirements() is True + + +# --------------------------------------------------------------------------- +# Tool functions +# --------------------------------------------------------------------------- + +class TestToolFunctions: + """Tests for the Hermes tool wrapper functions.""" + + def test_trello_verify_credentials_tool(self, requests_mock: Any) -> None: + """Tool returns valid JSON string on success.""" + from trello_plugin.tools import trello_verify_credentials + + os.environ["TRELLO_API_KEY"] = "test-key" + os.environ["TRELLO_TOKEN"] = "test-token" + requests_mock.get( + "https://api.trello.com/1/members/me", + json={"id": "m1", "username": "bot", "fullName": "Bot User"}, + ) + result = json.loads(trello_verify_credentials()) + assert result["success"] is True + assert result["member"]["username"] == "bot" + + def test_trello_list_boards_tool(self, requests_mock: Any) -> None: + """Tool returns valid JSON string on success.""" + from trello_plugin.tools import trello_list_boards + + os.environ["TRELLO_API_KEY"] = "test-key" + os.environ["TRELLO_TOKEN"] = "test-token" + requests_mock.get( + "https://api.trello.com/1/members/me/boards", + json=[{"id": "b1", "name": "Test", "url": "https://trello.com/b/b1", "closed": False, "starred": False}], + ) + result = json.loads(trello_list_boards()) + assert result["success"] is True + assert len(result["boards"]) == 1 + + def test_trello_disconnect_tool(self, requests_mock: Any) -> None: + """Tool clears credentials and returns success.""" + from trello_plugin.tools import trello_disconnect, trello_verify_credentials + + os.environ["TRELLO_API_KEY"] = "test-key" + os.environ["TRELLO_TOKEN"] = "test-token" + + # First verify works + requests_mock.get( + "https://api.trello.com/1/members/me", + json={"id": "m1", "username": "bot", "fullName": "Bot User"}, + ) + verify = json.loads(trello_verify_credentials()) + assert verify["success"] is True + + # Then disconnect + disc = json.loads(trello_disconnect()) + assert disc["success"] is True + + # With env vars still set, verify should reconnect successfully + # (disconnect clears the in-memory client; env vars persist) + verify2 = json.loads(trello_verify_credentials()) + assert verify2["success"] is True + assert verify2["member"]["username"] == "bot" + + def test_check_requirements_tool(self, requests_mock: Any) -> None: + """check_requirements returns False when env not set.""" + from trello_plugin.tools import check_requirements + + assert check_requirements() is False + + os.environ["TRELLO_API_KEY"] = "k" + os.environ["TRELLO_TOKEN"] = "t" + assert check_requirements() is True + + +# --------------------------------------------------------------------------- +# Plugin metadata +# --------------------------------------------------------------------------- + +class TestPluginMetadata: + """Tests for the plugin metadata constants.""" + + def test_plugin_constants(self) -> None: + from trello_plugin.tools import ( + PLUGIN_DESCRIPTION, + PLUGIN_NAME, + PLUGIN_TOOLS, + PLUGIN_VERSION, + ) + + assert PLUGIN_NAME == "trello-plugin" + assert isinstance(PLUGIN_DESCRIPTION, str) + assert isinstance(PLUGIN_VERSION, str) + assert len(PLUGIN_TOOLS) == 3 + assert all(callable(t) for t in PLUGIN_TOOLS) \ No newline at end of file