hermes-plugin-tinyfish 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ """TinyFish provider plugin for Hermes Agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ __version__ = "0.1.0"
8
+
9
+
10
+ def register(ctx: Any) -> None:
11
+ """Register the TinyFish web provider and CLI setup commands."""
12
+
13
+ from .provider import TinyFishWebSearchProvider
14
+ from .setup_cli import dispatch_tinyfish_cli, setup_tinyfish_cli
15
+
16
+ provider = TinyFishWebSearchProvider(dispatch_tool=getattr(ctx, "dispatch_tool", None))
17
+ ctx.register_web_search_provider(provider)
18
+ ctx.register_cli_command(
19
+ name="tinyfish",
20
+ help="Configure and diagnose the TinyFish web provider",
21
+ setup_fn=setup_tinyfish_cli,
22
+ handler_fn=dispatch_tinyfish_cli,
23
+ description="Configure TinyFish MCP OAuth, API-key fallback, and Hermes web backend routing.",
24
+ )
25
+
26
+
27
+ def __getattr__(name: str) -> Any:
28
+ if name == "TinyFishWebSearchProvider":
29
+ from .provider import TinyFishWebSearchProvider
30
+
31
+ return TinyFishWebSearchProvider
32
+ raise AttributeError(name)
33
+
34
+
35
+ __all__ = ["TinyFishWebSearchProvider", "register"]
@@ -0,0 +1,175 @@
1
+ """Normalize TinyFish MCP and REST payloads into Hermes web-provider shapes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, cast
7
+
8
+
9
+ class TinyFishPayloadError(ValueError):
10
+ """Raised when a TinyFish or MCP payload reports an error."""
11
+
12
+
13
+ def parse_jsonish(value: Any) -> Any:
14
+ """Parse JSON strings when possible and return other values unchanged."""
15
+
16
+ if isinstance(value, str):
17
+ text = value.strip()
18
+ if not text:
19
+ return text
20
+ try:
21
+ return json.loads(text)
22
+ except json.JSONDecodeError:
23
+ return value
24
+ return value
25
+
26
+
27
+ def unwrap_mcp_payload(raw: Any) -> Any:
28
+ """Convert Hermes' MCP wrapper output to the underlying TinyFish payload."""
29
+
30
+ payload = parse_jsonish(raw)
31
+ if isinstance(payload, dict) and payload.get("error"):
32
+ raise TinyFishPayloadError(str(payload["error"]))
33
+
34
+ if isinstance(payload, dict) and "structuredContent" in payload:
35
+ structured = payload.get("structuredContent")
36
+ if structured is not None:
37
+ return structured
38
+
39
+ if isinstance(payload, dict) and "result" in payload:
40
+ result = parse_jsonish(payload.get("result"))
41
+ if isinstance(result, dict) and result.get("error"):
42
+ raise TinyFishPayloadError(str(result["error"]))
43
+ return result
44
+
45
+ return payload
46
+
47
+
48
+ def normalize_search_response(payload: Any, limit: int = 5) -> dict[str, Any]:
49
+ """Return Hermes' standard web-search response envelope."""
50
+
51
+ data = unwrap_mcp_payload(payload)
52
+ if isinstance(data, list):
53
+ results = data
54
+ elif isinstance(data, dict):
55
+ if data.get("error"):
56
+ raise TinyFishPayloadError(str(data["error"]))
57
+ if isinstance(data.get("data"), dict) and isinstance(data["data"].get("web"), list):
58
+ return {
59
+ "success": True,
60
+ "data": {"web": data["data"]["web"][: max(1, int(limit or 5))]},
61
+ }
62
+ results = data.get("results") or data.get("web") or []
63
+ else:
64
+ results = []
65
+
66
+ count = max(1, int(limit or 5))
67
+ web_results: list[dict[str, Any]] = []
68
+ for idx, item in enumerate(list(results)[:count]):
69
+ if not isinstance(item, dict):
70
+ continue
71
+ url = str(item.get("url") or item.get("link") or "")
72
+ web_results.append(
73
+ {
74
+ "title": str(item.get("title") or item.get("site_name") or url),
75
+ "url": url,
76
+ "description": str(
77
+ item.get("snippet")
78
+ or item.get("description")
79
+ or item.get("content")
80
+ or item.get("text")
81
+ or ""
82
+ ),
83
+ "position": int(item.get("position") or idx + 1),
84
+ }
85
+ )
86
+ return {"success": True, "data": {"web": web_results}}
87
+
88
+
89
+ def normalize_fetch_documents(payload: Any, fallback_urls: list[str] | None = None) -> list[dict[str, Any]]:
90
+ """Return Hermes' standard extract document list."""
91
+
92
+ urls = list(fallback_urls or [])
93
+ data = unwrap_mcp_payload(payload)
94
+ if isinstance(data, dict) and data.get("error"):
95
+ raise TinyFishPayloadError(str(data["error"]))
96
+
97
+ if isinstance(data, dict) and isinstance(data.get("data"), list):
98
+ return cast(list[dict[str, Any]], data["data"])
99
+
100
+ if isinstance(data, dict):
101
+ results = data.get("results") or data.get("documents") or []
102
+ errors = data.get("errors") or data.get("failed_results") or []
103
+ elif isinstance(data, list):
104
+ results = data
105
+ errors = []
106
+ else:
107
+ results = []
108
+ errors = []
109
+
110
+ documents: list[dict[str, Any]] = []
111
+ for idx, item in enumerate(list(results)):
112
+ if isinstance(item, str):
113
+ url = urls[idx] if idx < len(urls) else ""
114
+ documents.append(
115
+ {
116
+ "url": url,
117
+ "title": "",
118
+ "content": item,
119
+ "raw_content": item,
120
+ "metadata": {"sourceURL": url},
121
+ }
122
+ )
123
+ continue
124
+ if not isinstance(item, dict):
125
+ continue
126
+ url = str(item.get("url") or item.get("final_url") or (urls[idx] if idx < len(urls) else ""))
127
+ raw = str(
128
+ item.get("text") or item.get("markdown") or item.get("raw_content") or item.get("content") or ""
129
+ )
130
+ documents.append(
131
+ {
132
+ "url": url,
133
+ "title": str(item.get("title") or ""),
134
+ "content": raw,
135
+ "raw_content": raw,
136
+ "metadata": {
137
+ "sourceURL": url,
138
+ "finalURL": str(item.get("final_url") or url),
139
+ "description": str(item.get("description") or ""),
140
+ "language": str(item.get("language") or ""),
141
+ },
142
+ }
143
+ )
144
+
145
+ for idx, item in enumerate(list(errors)):
146
+ if isinstance(item, dict):
147
+ url = str(item.get("url") or (urls[idx] if idx < len(urls) else ""))
148
+ error = str(item.get("error") or item.get("message") or "fetch failed")
149
+ else:
150
+ url = urls[idx] if idx < len(urls) else ""
151
+ error = str(item)
152
+ documents.append(
153
+ {
154
+ "url": url,
155
+ "title": "",
156
+ "content": "",
157
+ "raw_content": "",
158
+ "error": error,
159
+ "metadata": {"sourceURL": url},
160
+ }
161
+ )
162
+
163
+ if not documents and urls:
164
+ return [
165
+ {
166
+ "url": url,
167
+ "title": "",
168
+ "content": "",
169
+ "raw_content": "",
170
+ "error": "TinyFish returned no content",
171
+ "metadata": {"sourceURL": url},
172
+ }
173
+ for url in urls
174
+ ]
175
+ return documents
@@ -0,0 +1,271 @@
1
+ """Hermes WebSearchProvider implementation for TinyFish."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from collections.abc import Callable
8
+ from typing import Any, cast
9
+
10
+ try:
11
+ from agent.web_search_provider import WebSearchProvider as _HermesWebSearchProvider
12
+ except Exception: # pragma: no cover - lets package import outside Hermes
13
+
14
+ class _HermesWebSearchProvider: # type: ignore[no-redef]
15
+ pass
16
+
17
+
18
+ from . import rest_client
19
+ from .normalize import TinyFishPayloadError, normalize_fetch_documents, normalize_search_response
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ MCP_SERVER_NAME = "tinyfish"
24
+ MCP_SEARCH_TOOLS = (
25
+ "mcp__tinyfish__search",
26
+ "mcp_tinyfish_search",
27
+ )
28
+ MCP_FETCH_TOOLS = (
29
+ "mcp__tinyfish__fetch_content",
30
+ "mcp__tinyfish__fetch",
31
+ "mcp_tinyfish_fetch_content",
32
+ "mcp_tinyfish_fetch",
33
+ )
34
+
35
+
36
+ def _provider_env(name: str) -> str:
37
+ """Read Hermes config-aware environment values when available."""
38
+
39
+ try:
40
+ from agent.web_search_provider import get_provider_env
41
+
42
+ return str(get_provider_env(name) or "").strip()
43
+ except Exception:
44
+ return str(os.getenv(name, "") or "").strip()
45
+
46
+
47
+ def _registry() -> Any | None:
48
+ try:
49
+ from tools.registry import registry
50
+
51
+ return registry
52
+ except Exception:
53
+ return None
54
+
55
+
56
+ def _registered_tool_names() -> set[str]:
57
+ registry = _registry()
58
+ if registry is None:
59
+ return set()
60
+ try:
61
+ return set(registry.get_all_tool_names())
62
+ except Exception:
63
+ pass
64
+ try:
65
+ return set(getattr(registry, "_tools", {}).keys())
66
+ except Exception:
67
+ return set()
68
+
69
+
70
+ def _tinyfish_mcp_configured() -> bool:
71
+ try:
72
+ from hermes_cli.config import load_config
73
+
74
+ config = load_config() or {}
75
+ except Exception:
76
+ return False
77
+ mcp_config = (config.get("mcp_servers") or {}).get(MCP_SERVER_NAME) or {}
78
+ return bool(mcp_config.get("url") and mcp_config.get("auth") == "oauth")
79
+
80
+
81
+ def _discover_tinyfish_mcp_tools() -> None:
82
+ """Register configured MCP tools for this process without prompting for OAuth."""
83
+
84
+ if not _tinyfish_mcp_configured():
85
+ return
86
+ try:
87
+ from tools.mcp_oauth import suppress_interactive_oauth
88
+ except Exception:
89
+ suppress_interactive_oauth = None
90
+
91
+ try:
92
+ from tools.mcp_tool import discover_mcp_tools
93
+ except Exception:
94
+ return
95
+
96
+ if suppress_interactive_oauth is None:
97
+ discover_mcp_tools()
98
+ return
99
+ with suppress_interactive_oauth():
100
+ discover_mcp_tools()
101
+
102
+
103
+ def _first_registered(candidates: tuple[str, ...]) -> str | None:
104
+ names = _registered_tool_names()
105
+ for candidate in candidates:
106
+ if candidate in names:
107
+ return candidate
108
+ return None
109
+
110
+
111
+ class TinyFishWebSearchProvider(_HermesWebSearchProvider): # type: ignore[misc]
112
+ """TinyFish search and extract provider.
113
+
114
+ The provider prefers TinyFish's hosted MCP server because that path uses
115
+ OAuth and Hermes' built-in MCP token storage. If MCP tools are unavailable,
116
+ it falls back to direct REST calls using ``TINYFISH_API_KEY``.
117
+ """
118
+
119
+ def __init__(self, dispatch_tool: Callable[[str, dict[str, Any]], str] | None = None):
120
+ self._dispatch_tool = dispatch_tool
121
+
122
+ @property
123
+ def name(self) -> str:
124
+ return "tinyfish"
125
+
126
+ @property
127
+ def display_name(self) -> str:
128
+ return "TinyFish"
129
+
130
+ def is_available(self) -> bool:
131
+ """Cheap availability check: registered MCP tools or API key only."""
132
+
133
+ return bool(
134
+ _first_registered(MCP_SEARCH_TOOLS)
135
+ or _first_registered(MCP_FETCH_TOOLS)
136
+ or _provider_env("TINYFISH_API_KEY")
137
+ )
138
+
139
+ def supports_search(self) -> bool:
140
+ return True
141
+
142
+ def supports_extract(self) -> bool:
143
+ return True
144
+
145
+ def supports_crawl(self) -> bool:
146
+ return False
147
+
148
+ def get_setup_schema(self) -> dict[str, Any]:
149
+ return {
150
+ "name": "TinyFish",
151
+ "badge": "free search/fetch",
152
+ "tag": "OAuth MCP preferred; TINYFISH_API_KEY REST fallback.",
153
+ "env_vars": [
154
+ {
155
+ "key": "TINYFISH_API_KEY",
156
+ "prompt": "TinyFish API key (fallback when MCP OAuth is not configured)",
157
+ "url": "https://agent.tinyfish.ai/api-keys",
158
+ }
159
+ ],
160
+ }
161
+
162
+ def _dispatch_registered_tool(self, tool_name: str, args: dict[str, Any]) -> str:
163
+ if self._dispatch_tool is not None:
164
+ return self._dispatch_tool(tool_name, args)
165
+ registry = _registry()
166
+ if registry is None:
167
+ raise TinyFishPayloadError("Hermes tool registry is not available")
168
+ return cast(str, registry.dispatch(tool_name, args))
169
+
170
+ def _call_mcp(self, candidates: tuple[str, ...], args: dict[str, Any]) -> Any | None:
171
+ tool_name = _first_registered(candidates)
172
+ if tool_name is None:
173
+ _discover_tinyfish_mcp_tools()
174
+ tool_name = _first_registered(candidates)
175
+ if tool_name is None:
176
+ return None
177
+ logger.debug("TinyFish using MCP tool %s", tool_name)
178
+ return self._dispatch_registered_tool(tool_name, args)
179
+
180
+ def search(self, query: str, limit: int = 5) -> dict[str, Any]:
181
+ try:
182
+ from tools.interrupt import is_interrupted
183
+
184
+ if is_interrupted():
185
+ return {"success": False, "error": "Interrupted"}
186
+ except Exception:
187
+ pass
188
+
189
+ mcp_error = ""
190
+ try:
191
+ payload = self._call_mcp(MCP_SEARCH_TOOLS, {"query": query})
192
+ if payload is not None:
193
+ return normalize_search_response(payload, limit=limit)
194
+ except Exception as exc: # noqa: BLE001 - REST fallback may still work
195
+ mcp_error = str(exc)
196
+ logger.info("TinyFish MCP search unavailable, trying REST fallback: %s", exc)
197
+
198
+ api_key = _provider_env("TINYFISH_API_KEY")
199
+ if not api_key:
200
+ suffix = f" MCP error: {mcp_error}" if mcp_error else ""
201
+ return {
202
+ "success": False,
203
+ "error": (
204
+ "TinyFish is not configured. Run `hermes tinyfish setup` "
205
+ "to configure MCP OAuth, or set TINYFISH_API_KEY for REST fallback." + suffix
206
+ ),
207
+ }
208
+
209
+ try:
210
+ raw = rest_client.search(query, api_key=api_key)
211
+ return normalize_search_response(raw, limit=limit)
212
+ except Exception as exc: # noqa: BLE001
213
+ logger.warning("TinyFish REST search failed: %s", exc)
214
+ detail = f"; MCP error: {mcp_error}" if mcp_error else ""
215
+ return {"success": False, "error": f"TinyFish search failed: {exc}{detail}"}
216
+
217
+ def extract(self, urls: list[str], **kwargs: Any) -> list[dict[str, Any]]:
218
+ try:
219
+ from tools.interrupt import is_interrupted
220
+
221
+ if is_interrupted():
222
+ return [{"url": url, "title": "", "content": "", "error": "Interrupted"} for url in urls]
223
+ except Exception:
224
+ pass
225
+
226
+ output_format = str(kwargs.get("format") or kwargs.get("output_format") or "markdown")
227
+ mcp_error = ""
228
+ try:
229
+ payload = self._call_mcp(
230
+ MCP_FETCH_TOOLS,
231
+ {"urls": urls, "format": output_format},
232
+ )
233
+ if payload is not None:
234
+ return normalize_fetch_documents(payload, fallback_urls=urls)
235
+ except Exception as exc: # noqa: BLE001 - REST fallback may still work
236
+ mcp_error = str(exc)
237
+ logger.info("TinyFish MCP fetch unavailable, trying REST fallback: %s", exc)
238
+
239
+ api_key = _provider_env("TINYFISH_API_KEY")
240
+ if not api_key:
241
+ suffix = f" MCP error: {mcp_error}" if mcp_error else ""
242
+ return [
243
+ {
244
+ "url": url,
245
+ "title": "",
246
+ "content": "",
247
+ "raw_content": "",
248
+ "error": (
249
+ "TinyFish is not configured. Run `hermes tinyfish setup` "
250
+ "to configure MCP OAuth, or set TINYFISH_API_KEY for REST fallback." + suffix
251
+ ),
252
+ }
253
+ for url in urls
254
+ ]
255
+
256
+ try:
257
+ raw = rest_client.fetch(urls, api_key=api_key, output_format=output_format)
258
+ return normalize_fetch_documents(raw, fallback_urls=urls)
259
+ except Exception as exc: # noqa: BLE001
260
+ logger.warning("TinyFish REST fetch failed: %s", exc)
261
+ detail = f"; MCP error: {mcp_error}" if mcp_error else ""
262
+ return [
263
+ {
264
+ "url": url,
265
+ "title": "",
266
+ "content": "",
267
+ "raw_content": "",
268
+ "error": f"TinyFish fetch failed: {exc}{detail}",
269
+ }
270
+ for url in urls
271
+ ]
@@ -0,0 +1,64 @@
1
+ """TinyFish REST API client used as the API-key fallback path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, cast
6
+
7
+ import httpx
8
+
9
+ SEARCH_URL = "https://api.search.tinyfish.ai"
10
+ FETCH_URL = "https://api.fetch.tinyfish.ai"
11
+
12
+
13
+ class TinyFishRestError(RuntimeError):
14
+ """Raised for TinyFish REST transport or HTTP failures."""
15
+
16
+
17
+ def _headers(api_key: str) -> dict[str, str]:
18
+ return {"X-API-Key": api_key, "Accept": "application/json"}
19
+
20
+
21
+ def search(query: str, *, api_key: str, timeout: float = 30.0) -> dict[str, Any]:
22
+ """Run a TinyFish Search API query."""
23
+
24
+ try:
25
+ response = httpx.get(
26
+ SEARCH_URL,
27
+ params={"query": query},
28
+ headers=_headers(api_key),
29
+ timeout=timeout,
30
+ )
31
+ response.raise_for_status()
32
+ return cast(dict[str, Any], response.json())
33
+ except httpx.HTTPStatusError as exc:
34
+ raise TinyFishRestError(f"TinyFish Search returned HTTP {exc.response.status_code}") from exc
35
+ except httpx.RequestError as exc:
36
+ raise TinyFishRestError(f"Could not reach TinyFish Search: {exc}") from exc
37
+ except ValueError as exc:
38
+ raise TinyFishRestError("TinyFish Search returned invalid JSON") from exc
39
+
40
+
41
+ def fetch(
42
+ urls: list[str],
43
+ *,
44
+ api_key: str,
45
+ output_format: str = "markdown",
46
+ timeout: float = 60.0,
47
+ ) -> dict[str, Any]:
48
+ """Run the TinyFish Fetch API for one or more URLs."""
49
+
50
+ try:
51
+ response = httpx.post(
52
+ FETCH_URL,
53
+ json={"urls": urls, "format": output_format},
54
+ headers=_headers(api_key),
55
+ timeout=timeout,
56
+ )
57
+ response.raise_for_status()
58
+ return cast(dict[str, Any], response.json())
59
+ except httpx.HTTPStatusError as exc:
60
+ raise TinyFishRestError(f"TinyFish Fetch returned HTTP {exc.response.status_code}") from exc
61
+ except httpx.RequestError as exc:
62
+ raise TinyFishRestError(f"Could not reach TinyFish Fetch: {exc}") from exc
63
+ except ValueError as exc:
64
+ raise TinyFishRestError("TinyFish Fetch returned invalid JSON") from exc
@@ -0,0 +1,266 @@
1
+ """CLI helpers for ``hermes tinyfish``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .provider import (
14
+ MCP_FETCH_TOOLS,
15
+ MCP_SEARCH_TOOLS,
16
+ TinyFishWebSearchProvider,
17
+ _discover_tinyfish_mcp_tools,
18
+ )
19
+
20
+ TINYFISH_MCP_URL = "https://agent.tinyfish.ai/mcp"
21
+
22
+
23
+ def setup_tinyfish_cli(parser: argparse.ArgumentParser) -> None:
24
+ sub = parser.add_subparsers(dest="tinyfish_command")
25
+
26
+ setup = sub.add_parser("setup", help="Configure TinyFish for Hermes web search/extract")
27
+ setup.add_argument("--yes", "-y", action="store_true", help="Accept config changes without prompts")
28
+ setup.add_argument("--api-key", help="Save TINYFISH_API_KEY as REST fallback")
29
+ setup.add_argument("--skip-mcp", action="store_true", help="Do not configure TinyFish MCP OAuth")
30
+ setup.add_argument("--skip-login", action="store_true", help="Do not run `hermes mcp login tinyfish`")
31
+ setup.add_argument(
32
+ "--login", action="store_true", help="Run `hermes mcp login tinyfish` after writing config"
33
+ )
34
+ setup.add_argument(
35
+ "--no-web-backend", action="store_true", help="Do not set web.search_backend/extract_backend"
36
+ )
37
+ setup.add_argument("--live", action="store_true", help="Run live doctor checks after setup")
38
+
39
+ doctor = sub.add_parser("doctor", help="Check TinyFish plugin configuration")
40
+ doctor.add_argument("--json", action="store_true", help="Print machine-readable JSON")
41
+ doctor.add_argument("--live", action="store_true", help="Run a live search/fetch check")
42
+
43
+ status = sub.add_parser("status", help="Print non-secret TinyFish configuration status")
44
+ status.add_argument("--json", action="store_true", help="Print machine-readable JSON")
45
+
46
+
47
+ def dispatch_tinyfish_cli(args: argparse.Namespace) -> int:
48
+ command = getattr(args, "tinyfish_command", None) or "status"
49
+ if command == "setup":
50
+ return cmd_setup(args)
51
+ if command == "doctor":
52
+ return cmd_doctor(args)
53
+ if command == "status":
54
+ return cmd_status(args)
55
+ print("Usage: hermes tinyfish {setup,doctor,status}", file=sys.stderr)
56
+ return 2
57
+
58
+
59
+ def _load_config() -> dict[str, Any]:
60
+ from hermes_cli.config import load_config
61
+
62
+ return dict(load_config() or {})
63
+
64
+
65
+ def _save_config(config: dict[str, Any]) -> None:
66
+ from hermes_cli.config import save_config
67
+
68
+ save_config(config)
69
+
70
+
71
+ def _get_env(name: str) -> str:
72
+ try:
73
+ from hermes_cli.config import get_env_value
74
+
75
+ return str(get_env_value(name) or "").strip()
76
+ except Exception:
77
+ return str(os.getenv(name, "") or "").strip()
78
+
79
+
80
+ def _save_env(name: str, value: str) -> None:
81
+ from hermes_cli.config import save_env_value
82
+
83
+ save_env_value(name, value)
84
+
85
+
86
+ def _hermes_home() -> Path:
87
+ try:
88
+ from hermes_constants import get_hermes_home
89
+
90
+ return Path(get_hermes_home())
91
+ except Exception:
92
+ return Path(os.getenv("HERMES_HOME") or Path.home() / ".hermes")
93
+
94
+
95
+ def _confirm(question: str, *, default: bool = True, assume_yes: bool = False) -> bool:
96
+ if assume_yes:
97
+ return True
98
+ if not sys.stdin.isatty():
99
+ return default
100
+ suffix = "Y/n" if default else "y/N"
101
+ try:
102
+ answer = input(f"{question} [{suffix}]: ").strip().lower()
103
+ except (EOFError, KeyboardInterrupt):
104
+ print()
105
+ return default
106
+ if not answer:
107
+ return default
108
+ return answer in {"y", "yes"}
109
+
110
+
111
+ def _prompt_secret(question: str) -> str:
112
+ import getpass
113
+
114
+ try:
115
+ return getpass.getpass(question).strip()
116
+ except (EOFError, KeyboardInterrupt):
117
+ print()
118
+ return ""
119
+
120
+
121
+ def _apply_mcp_config(config: dict[str, Any]) -> None:
122
+ config.setdefault("mcp_servers", {})["tinyfish"] = {
123
+ "url": TINYFISH_MCP_URL,
124
+ "auth": "oauth",
125
+ "tools": {
126
+ "include": ["search", "fetch_content"],
127
+ "resources": False,
128
+ "prompts": False,
129
+ },
130
+ }
131
+
132
+
133
+ def _apply_web_backend_config(config: dict[str, Any]) -> None:
134
+ web = config.setdefault("web", {})
135
+ web["search_backend"] = "tinyfish"
136
+ web["extract_backend"] = "tinyfish"
137
+ if not web.get("backend"):
138
+ web["backend"] = "tinyfish"
139
+
140
+
141
+ def cmd_setup(args: argparse.Namespace) -> int:
142
+ config = _load_config()
143
+
144
+ if not getattr(args, "skip_mcp", False):
145
+ _apply_mcp_config(config)
146
+ print("Configured TinyFish hosted MCP OAuth at https://agent.tinyfish.ai/mcp")
147
+
148
+ if not getattr(args, "no_web_backend", False) and _confirm(
149
+ "Set Hermes web.search_backend and web.extract_backend to tinyfish?",
150
+ default=True,
151
+ assume_yes=bool(getattr(args, "yes", False)),
152
+ ):
153
+ _apply_web_backend_config(config)
154
+ print("Configured Hermes web backends to use TinyFish")
155
+
156
+ _save_config(config)
157
+
158
+ api_key = (getattr(args, "api_key", None) or "").strip()
159
+ if (
160
+ not api_key
161
+ and not _get_env("TINYFISH_API_KEY")
162
+ and sys.stdin.isatty()
163
+ and _confirm(
164
+ "Add TINYFISH_API_KEY as REST fallback now?",
165
+ default=False,
166
+ assume_yes=False,
167
+ )
168
+ ):
169
+ api_key = _prompt_secret("TinyFish API key: ")
170
+ if api_key:
171
+ _save_env("TINYFISH_API_KEY", api_key)
172
+ print("Saved TINYFISH_API_KEY fallback in Hermes .env")
173
+
174
+ should_login = bool(getattr(args, "login", False))
175
+ if not should_login and not getattr(args, "skip_login", False) and sys.stdin.isatty():
176
+ should_login = _confirm("Run `hermes mcp login tinyfish` now?", default=True)
177
+ if should_login:
178
+ result = subprocess.run(["hermes", "mcp", "login", "tinyfish"], check=False)
179
+ if result.returncode != 0:
180
+ print("TinyFish MCP login did not complete. You can retry with `hermes mcp login tinyfish`.")
181
+
182
+ if getattr(args, "live", False):
183
+ doctor_args = argparse.Namespace(json=False, live=True)
184
+ return cmd_doctor(doctor_args)
185
+
186
+ print("Run `hermes tinyfish doctor --live` to verify the setup.")
187
+ return 0
188
+
189
+
190
+ def _tool_names(*, discover_mcp: bool = False) -> set[str]:
191
+ if discover_mcp:
192
+ _discover_tinyfish_mcp_tools()
193
+ try:
194
+ from tools.registry import registry
195
+
196
+ return set(registry.get_all_tool_names())
197
+ except Exception:
198
+ return set()
199
+
200
+
201
+ def collect_status(*, live: bool = False) -> dict[str, Any]:
202
+ config = _load_config()
203
+ mcp_cfg = (config.get("mcp_servers") or {}).get("tinyfish") or {}
204
+ web_cfg = config.get("web") or {}
205
+ names = _tool_names(discover_mcp=live)
206
+ token_path = _hermes_home() / "mcp-tokens" / "tinyfish.json"
207
+ api_key_configured = bool(_get_env("TINYFISH_API_KEY"))
208
+ provider = TinyFishWebSearchProvider()
209
+
210
+ checks: dict[str, Any] = {
211
+ "plugin_loaded": True,
212
+ "provider_available": provider.is_available(),
213
+ "mcp_configured": bool(mcp_cfg.get("url") == TINYFISH_MCP_URL and mcp_cfg.get("auth") == "oauth"),
214
+ "mcp_search_tool_registered": any(name in names for name in MCP_SEARCH_TOOLS),
215
+ "mcp_fetch_tool_registered": any(name in names for name in MCP_FETCH_TOOLS),
216
+ "mcp_token_cached": token_path.exists(),
217
+ "api_key_fallback_configured": api_key_configured,
218
+ "web_search_backend": web_cfg.get("search_backend") or web_cfg.get("backend") or "",
219
+ "web_extract_backend": web_cfg.get("extract_backend") or web_cfg.get("backend") or "",
220
+ }
221
+
222
+ checks["web_backend_configured"] = (
223
+ checks["web_search_backend"] == "tinyfish" and checks["web_extract_backend"] == "tinyfish"
224
+ )
225
+
226
+ if live:
227
+ search = provider.search("TinyFish web agent", limit=1)
228
+ checks["live_search_ok"] = bool(search.get("success") and search.get("data", {}).get("web"))
229
+ fetch = provider.extract(["https://docs.tinyfish.ai/"], format="markdown")
230
+ checks["live_fetch_ok"] = bool(fetch and not fetch[0].get("error"))
231
+
232
+ checks["ok"] = bool(
233
+ checks["web_backend_configured"]
234
+ and (checks["mcp_configured"] or checks["api_key_fallback_configured"])
235
+ )
236
+ return checks
237
+
238
+
239
+ def _print_status(status: dict[str, Any]) -> None:
240
+ print("TinyFish Hermes plugin status")
241
+ for key in sorted(status):
242
+ value = status[key]
243
+ if isinstance(value, bool):
244
+ value = "yes" if value else "no"
245
+ print(f" {key}: {value}")
246
+
247
+
248
+ def cmd_status(args: argparse.Namespace) -> int:
249
+ status = collect_status(live=False)
250
+ if getattr(args, "json", False):
251
+ print(json.dumps(status, indent=2, sort_keys=True))
252
+ else:
253
+ _print_status(status)
254
+ return 0
255
+
256
+
257
+ def cmd_doctor(args: argparse.Namespace) -> int:
258
+ status = collect_status(live=bool(getattr(args, "live", False)))
259
+ if getattr(args, "json", False):
260
+ print(json.dumps(status, indent=2, sort_keys=True))
261
+ else:
262
+ _print_status(status)
263
+ if not status["ok"]:
264
+ print()
265
+ print("Recommended next step: run `hermes tinyfish setup`.")
266
+ return 0 if status["ok"] else 1
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: hermes-plugin-tinyfish
3
+ Version: 0.1.3
4
+ Summary: TinyFish web search and extraction provider plugin for Hermes Agent
5
+ Project-URL: Homepage, https://github.com/gabeosx/hermes-plugin-tinyfish
6
+ Project-URL: Documentation, https://github.com/gabeosx/hermes-plugin-tinyfish#readme
7
+ Project-URL: Issues, https://github.com/gabeosx/hermes-plugin-tinyfish/issues
8
+ Project-URL: Changelog, https://github.com/gabeosx/hermes-plugin-tinyfish/blob/main/CHANGELOG.md
9
+ Author: gabeosx
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: hermes-agent,mcp,tinyfish,web-extraction,web-search
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.27
23
+ Provides-Extra: dev
24
+ Requires-Dist: build>=1.2; extra == 'dev'
25
+ Requires-Dist: mypy>=1.10; extra == 'dev'
26
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
27
+ Requires-Dist: pytest>=8.2; extra == 'dev'
28
+ Requires-Dist: ruff>=0.6; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # Hermes TinyFish Plugin
32
+
33
+ [![CI](https://github.com/gabeosx/hermes-plugin-tinyfish/actions/workflows/ci.yml/badge.svg)](https://github.com/gabeosx/hermes-plugin-tinyfish/actions/workflows/ci.yml)
34
+ [![PyPI](https://img.shields.io/badge/PyPI-pending-lightgrey.svg)](https://github.com/gabeosx/hermes-plugin-tinyfish/releases)
35
+ [![Python](https://img.shields.io/badge/Python-3.10%20%7C%203.11%20%7C%203.12-blue.svg)](pyproject.toml)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
37
+ [![Security Policy](https://img.shields.io/badge/Security-policy-green.svg)](SECURITY.md)
38
+
39
+ TinyFish web search and content extraction for [Hermes Agent](https://hermes-agent.nousresearch.com/docs/).
40
+
41
+ The plugin adds `tinyfish` as a native Hermes `web_search` and `web_extract`
42
+ backend. It prefers TinyFish's hosted OAuth MCP server and falls back to direct
43
+ REST API calls with `TINYFISH_API_KEY` when MCP OAuth is unavailable.
44
+
45
+ ## Install
46
+
47
+ Hermes Git plugin install:
48
+
49
+ ```bash
50
+ hermes plugins install gabeosx/hermes-plugin-tinyfish --enable
51
+ hermes tinyfish setup
52
+ ```
53
+
54
+ Hermes' Git plugin installer currently clones the repository default branch.
55
+ Stable release artifacts are published on the
56
+ [GitHub Releases page](https://github.com/gabeosx/hermes-plugin-tinyfish/releases).
57
+
58
+ PyPI install is planned after PyPI Trusted Publishing is configured:
59
+
60
+ ```bash
61
+ pip install hermes-plugin-tinyfish
62
+ hermes plugins enable web-tinyfish
63
+ hermes tinyfish setup
64
+ ```
65
+
66
+ ## What Setup Does
67
+
68
+ `hermes tinyfish setup` writes ordinary user configuration only:
69
+
70
+ ```yaml
71
+ mcp_servers:
72
+ tinyfish:
73
+ url: https://agent.tinyfish.ai/mcp
74
+ auth: oauth
75
+ tools:
76
+ include: [search, fetch_content]
77
+ resources: false
78
+ prompts: false
79
+
80
+ web:
81
+ search_backend: tinyfish
82
+ extract_backend: tinyfish
83
+ ```
84
+
85
+ It may also save `TINYFISH_API_KEY` to `~/.hermes/.env` if you choose to add
86
+ an API-key fallback.
87
+
88
+ The plugin does not patch Hermes Agent, update scripts, Dockerfiles, or any
89
+ files inside a Hermes checkout.
90
+
91
+ ## Verify
92
+
93
+ ```bash
94
+ hermes tinyfish doctor
95
+ hermes tinyfish doctor --live
96
+ ```
97
+
98
+ `doctor --live` performs a real TinyFish search and fetch. It requires either
99
+ working MCP OAuth or `TINYFISH_API_KEY`.
100
+
101
+ ## Manual Configuration
102
+
103
+ OAuth MCP only:
104
+
105
+ ```bash
106
+ hermes mcp add tinyfish --url https://agent.tinyfish.ai/mcp --auth oauth
107
+ hermes mcp login tinyfish
108
+ hermes mcp configure tinyfish
109
+ ```
110
+
111
+ Then set:
112
+
113
+ ```yaml
114
+ web:
115
+ search_backend: tinyfish
116
+ extract_backend: tinyfish
117
+ ```
118
+
119
+ REST fallback only:
120
+
121
+ ```bash
122
+ export TINYFISH_API_KEY="..."
123
+ ```
124
+
125
+ ## Upgrade Safety
126
+
127
+ This plugin uses public Hermes extension points:
128
+
129
+ - `ctx.register_web_search_provider(...)`
130
+ - `ctx.register_cli_command(...)`
131
+ - Hermes MCP config under `mcp_servers`
132
+ - Hermes `.env` config helpers for optional API-key fallback
133
+
134
+ Because it is installed as a user plugin or Python entry point, normal
135
+ `hermes update` operations do not overwrite it.
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ python -m venv .venv
141
+ source .venv/bin/activate
142
+ python -m pip install -U pip
143
+ python -m pip install -e ".[dev]"
144
+ ruff format .
145
+ ruff check .
146
+ mypy hermes_plugin_tinyfish
147
+ pytest
148
+ python -m build
149
+ ```
150
+
151
+ Live tests are opt-in:
152
+
153
+ ```bash
154
+ TINYFISH_LIVE_TESTS=1 TINYFISH_API_KEY=... pytest tests/test_live.py
155
+ ```
156
+
157
+ ## References
158
+
159
+ - [TinyFish MCP Integration](https://docs.tinyfish.ai/mcp-integration)
160
+ - [TinyFish Authentication](https://docs.tinyfish.ai/authentication)
161
+ - [TinyFish Search API](https://docs.tinyfish.ai/search-api)
162
+ - [TinyFish Fetch API](https://docs.tinyfish.ai/fetch-api)
163
+ - [Hermes Web Search Provider Plugins](https://hermes-agent.nousresearch.com/docs/developer-guide/web-search-provider-plugin)
164
+ - [Hermes Plugins](https://hermes-agent.nousresearch.com/docs/user-guide/features/plugins)
165
+ - [Hermes MCP](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp)
@@ -0,0 +1,10 @@
1
+ hermes_plugin_tinyfish/__init__.py,sha256=kYdF_yi9sAGVi-3CitDlIXDaP-z7OF-AOIg5XB2v9Vk,1069
2
+ hermes_plugin_tinyfish/normalize.py,sha256=UR7iwSC57wks88GnvSSWzXWImGOKvWpHVW0imcbFJ8M,5899
3
+ hermes_plugin_tinyfish/provider.py,sha256=5rvkpse7hu2KhCBt54JPmDUZaW3rpprrIltjZcaHQiE,9003
4
+ hermes_plugin_tinyfish/rest_client.py,sha256=bHVpF0CJ-T5kSoFCL1O97P7YAyuyQwSOVxefRbQI7Pk,2091
5
+ hermes_plugin_tinyfish/setup_cli.py,sha256=WbDa61oPQzGQYJMCtyxoXUUJb2YBrL6QtubR-00Tn4U,9031
6
+ hermes_plugin_tinyfish-0.1.3.dist-info/METADATA,sha256=fTZb-8VE79MlGO5k4_tgUeAHClkh5zp3op1STNaJBBk,5121
7
+ hermes_plugin_tinyfish-0.1.3.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ hermes_plugin_tinyfish-0.1.3.dist-info/entry_points.txt,sha256=qWGzby0SgzF97ICje8dr15LZyA-PBexEeC96FmruWpg,61
9
+ hermes_plugin_tinyfish-0.1.3.dist-info/licenses/LICENSE,sha256=hy8tmDdq46UUrOKt91rKGzBVtHnQs2Qoqt7i-UtL3m8,1064
10
+ hermes_plugin_tinyfish-0.1.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [hermes_agent.plugins]
2
+ web-tinyfish = hermes_plugin_tinyfish
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gabeosx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.