@tiny-fish/hermes 0.2.3 → 0.2.5

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.
package/README.md CHANGED
@@ -7,20 +7,21 @@
7
7
  | Search, Fetch (free) | `tinyfish` web provider behind Hermes' `web_search` / `web_extract` |
8
8
  | Browser (credits) | `tinyfish` cloud browser provider behind Hermes' browser tools |
9
9
  | Agent (credits) | `tinyfish_agent` tool: goal-based automation on a real site |
10
+ | Profiles (free) | `tinyfish_*_profile(s)` tools: saved website sign-ins that `tinyfish_agent` runs with |
10
11
 
11
12
  ## Install
12
13
 
13
- The supported path is the TinyFish CLI, which installs the plugin from npm
14
- (`@tiny-fish/hermes@latest`), seeds the API key, and points Hermes' web backends at it:
14
+ Install from the Hermes plugin catalog, then add your key and route web tools to TinyFish:
15
15
 
16
16
  ```bash
17
- tinyfish connect hermes
17
+ hermes plugins install tinyfish --enable
18
+ hermes tinyfish setup
18
19
  ```
19
20
 
20
- To install just the plugin straight from this repo (no key seeding, no backend wiring):
21
+ Or let the TinyFish CLI do both: it installs the same catalog plugin, seeds the API key, and points Hermes' web backends at it:
21
22
 
22
23
  ```bash
23
- hermes plugins install tinyfish-io/tinyfish-web-agent-integrations/hermes --enable
24
+ npx @tiny-fish/cli connect hermes
24
25
  ```
25
26
 
26
27
  ## Authentication
@@ -30,7 +31,7 @@ The plugin authenticates with a TinyFish API key — create one at [agent.tinyfi
30
31
  | Env var | Source |
31
32
  | ------- | ------ |
32
33
  | `TINYFISH_API_KEY` | Set it yourself (shell env, or `hermes tinyfish setup --api-key`) |
33
- | `MCP_TINYFISH_API_KEY` | Seeded by the TinyFish npm CLI during `tinyfish connect hermes` |
34
+ | `MCP_TINYFISH_API_KEY` | Seeded by the TinyFish npm CLI during `npx @tiny-fish/cli connect hermes` |
34
35
 
35
36
  ## CLI
36
37
 
@@ -88,10 +89,36 @@ tinyfish:
88
89
  timeout_seconds: 300 # optional session timeout
89
90
  ```
90
91
 
92
+ ## Browser Context Profiles
93
+
94
+ A Browser Context Profile is a saved website sign-in. It is unrelated to Hermes profiles and to `tinyfish_agent`'s `browser_profile` (lite/stealth).
95
+
96
+ | Tool | Does |
97
+ | ---- | ---- |
98
+ | `tinyfish_list_profiles` | Lists profiles with their `signed_in_sites` |
99
+ | `tinyfish_create_profile` | Creates a profile and returns `setup_url`, where the user signs in and clicks Save profile |
100
+ | `tinyfish_profile_sign_in` | Returns `setup_url` to add or refresh a site on an existing profile |
101
+ | `tinyfish_update_profile` | Renames a profile, makes it the default, or sets its proxy country (`none` removes it) |
102
+ | `tinyfish_delete_profile` | Deletes a profile; asks for approval first unless Hermes approvals are off (`--yolo`) |
103
+
104
+ `tinyfish_agent` runs signed in with `profile_id` (or `use_profile: true` for the default profile). A profile's proxy country (set on create or update) is used for both sign-in and runs, and overrides the run's `proxy_country_code`. If a site blocks the sign-in page, set one and reopen the same link. `setup_url` asks the user to sign in to TinyFish first if that browser isn't already. Nothing bills until the user opens it; runs bill under `tinyfish.credit_policy.agent`.
105
+
91
106
  ## Tool-routing guidance
92
107
 
93
108
  When the `tinyfish` MCP server is also configured in Hermes (`mcp_servers.tinyfish.url: https://agent.tinyfish.ai/mcp`), the plugin injects once-per-context guidance steering the model between the generic web tools and the native MCP tools. Disable with `tinyfish.routing_context: false`.
94
109
 
110
+ ## Telemetry
111
+
112
+ Off unless you turn it on. When you opt in, each `hermes tinyfish setup` run sends TinyFish one event, whether it succeeds or fails: the outcome (for example `completed` or `key_rejected`), where the key came from (`flag`, `prompt`, `env` or `none`), the plugin version and OS. It sends a one-way hash of the API key, never the key itself, so the event joins your account.
113
+
114
+ | Opt in | How |
115
+ |---|---|
116
+ | Interactive setup | Answer `y` to the setup question (default No). The answer is saved. |
117
+ | Flag | `hermes tinyfish setup --share-usage` |
118
+ | Config | `tinyfish.telemetry: true` in Hermes config |
119
+
120
+ Set `tinyfish.telemetry: false` to stop sending. `TINYFISH_NO_TELEMETRY=1` (in your shell or `~/.hermes/.env`) turns it off regardless of the other settings.
121
+
95
122
  ## Development
96
123
 
97
124
  ```bash
package/after-install.md CHANGED
@@ -9,3 +9,5 @@ hermes tinyfish setup
9
9
  No key yet? Create one at [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys?source=hermes) — Search and Fetch are free. `setup` will ask for it.
10
10
 
11
11
  Check anytime with `hermes tinyfish doctor --live` or `/tinyfish-status` in a session.
12
+
13
+ Signed-in sites: ask Hermes to create a TinyFish profile for a site, sign in at the link it gives you, and `tinyfish_agent` runs as you.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiny-fish/hermes",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "TinyFish web provider plugin for Hermes Agent, delivered via npm for the TinyFish CLI. Installed by `tinyfish connect hermes`; not a JavaScript library.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://docs.tinyfish.ai",
package/plugin.yaml CHANGED
@@ -1,6 +1,6 @@
1
1
  name: tinyfish
2
- version: 0.2.3
3
- description: "TinyFish: the web, built into Hermes. Free Search and Fetch power Hermes's own web_search and web_extract, and when a site needs clicks, forms or a login, a remote browser and the tinyfish_agent web agent handle it."
2
+ version: 0.2.5
3
+ description: "TinyFish: the web, built into Hermes. Free Search and Fetch power Hermes's own web_search and web_extract, and when a site needs clicks, forms or a login, a remote browser and the tinyfish_agent web agent handle it, signed in through saved profiles when needed."
4
4
  author: TinyFish
5
5
  kind: backend
6
6
  # 0.19 added the pre_tool_call approve directive; older Hermes would skip credit approval.
@@ -13,6 +13,11 @@ provides_browser_providers:
13
13
  - tinyfish
14
14
  provides_tools:
15
15
  - tinyfish_agent
16
+ - tinyfish_list_profiles
17
+ - tinyfish_create_profile
18
+ - tinyfish_profile_sign_in
19
+ - tinyfish_update_profile
20
+ - tinyfish_delete_profile
16
21
  provides_hooks:
17
22
  - pre_tool_call
18
23
  - pre_llm_call
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tinyfish-hermes"
7
- version = "0.2.3"
7
+ version = "0.2.5"
8
8
  description = "TinyFish plugin for Hermes Agent - Search, Fetch, Browser, and the tinyfish_agent web agent"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -48,6 +48,7 @@ def register(ctx: Any) -> None:
48
48
  from .agent_tool import TINYFISH_AGENT_SCHEMA, tinyfish_agent
49
49
  from .browser_provider import TinyFishBrowserProvider, shutdown_cleanup
50
50
  from .credit_policy import pre_tool_call_policy
51
+ from .profile_tools import PROFILE_TOOLS
51
52
  from .provider import TinyFishWebSearchProvider, _api_key
52
53
  from .routing_context import routing_context_hook
53
54
  from .setup_cli import (
@@ -65,16 +66,20 @@ def register(ctx: Any) -> None:
65
66
  ctx.register_hook("pre_tool_call", pre_tool_call_policy)
66
67
  ctx.register_hook("pre_llm_call", routing_context_hook)
67
68
  ctx.register_hook("on_session_finalize", shutdown_cleanup)
68
- # tinyfish_agent spends credits, so it also needs the pre_tool_call gate.
69
+ # Agent credits and profile delete approval both rely on the pre_tool_call gate.
69
70
  if hasattr(ctx, "register_hook") and hasattr(ctx, "register_tool"):
70
- ctx.register_tool(
71
- name="tinyfish_agent",
72
- toolset="tinyfish",
73
- schema=TINYFISH_AGENT_SCHEMA,
74
- handler=tinyfish_agent,
75
- check_fn=lambda: bool(_api_key()),
76
- emoji="🐟",
77
- )
71
+ for schema, handler in (
72
+ (TINYFISH_AGENT_SCHEMA, tinyfish_agent),
73
+ *PROFILE_TOOLS,
74
+ ):
75
+ ctx.register_tool(
76
+ name=schema["name"],
77
+ toolset="tinyfish",
78
+ schema=schema,
79
+ handler=handler,
80
+ check_fn=lambda: bool(_api_key()),
81
+ emoji="🐟",
82
+ )
78
83
 
79
84
  if hasattr(ctx, "register_cli_command"):
80
85
 
@@ -7,6 +7,7 @@ import time
7
7
  from typing import Any
8
8
 
9
9
  from . import rest_client
10
+ from .profile_tools import PROFILE_TERM, error_json
10
11
  from .provider import MISSING_KEY_ERROR, _api_key, _is_interrupted
11
12
 
12
13
  TERMINAL_STATUSES = frozenset({"COMPLETED", "FAILED", "CANCELLED"})
@@ -21,8 +22,11 @@ TINYFISH_AGENT_SCHEMA: dict[str, Any] = {
21
22
  "description": (
22
23
  "Run a TinyFish web agent on a real website to complete a goal: navigate, "
23
24
  "click, fill forms, and extract data across multiple pages. Use for "
24
- "multi-step site tasks that web_search/web_extract cannot do. Consumes "
25
- "TinyFish credits. Returns the agent's result as JSON."
25
+ "multi-step site tasks that web_search/web_extract cannot do. If the site "
26
+ "needs sign-in, call tinyfish_list_profiles and pass the profile_id that "
27
+ "covers it. Ask the user before buying, posting, sending, or changing "
28
+ "account settings. Consumes TinyFish credits. Returns the agent's result "
29
+ "as JSON."
26
30
  ),
27
31
  "parameters": {
28
32
  "type": "object",
@@ -43,7 +47,22 @@ TINYFISH_AGENT_SCHEMA: dict[str, Any] = {
43
47
  "proxy_country_code": {
44
48
  "type": "string",
45
49
  "enum": ["US", "GB", "CA", "DE", "FR", "JP", "AU"],
46
- "description": "Route through a proxy in this country.",
50
+ "description": (
51
+ "Route through a proxy in this country. A profile's own proxy "
52
+ "country overrides this."
53
+ ),
54
+ },
55
+ "use_profile": {
56
+ "type": "boolean",
57
+ "description": (
58
+ f"Run signed in with the user's default profile. {PROFILE_TERM}"
59
+ ),
60
+ },
61
+ "profile_id": {
62
+ "type": "string",
63
+ "description": (
64
+ "Run signed in with this profile from tinyfish_list_profiles."
65
+ ),
47
66
  },
48
67
  },
49
68
  "required": ["url", "goal"],
@@ -68,8 +87,19 @@ def _result(payload: dict[str, Any]) -> str:
68
87
 
69
88
 
70
89
  def _summary(run: dict[str, Any]) -> dict[str, Any]:
71
- keys = ("run_id", "status", "result", "error", "streaming_url")
72
- return {key: run[key] for key in keys if run.get(key) is not None}
90
+ keys = ("run_id", "status", "result", "error", "streaming_url", "profile_hint")
91
+ summary = {key: run[key] for key in keys if run.get(key) is not None}
92
+ # Failed runs nest the hint under error, and its relative link opens outside Hermes.
93
+ for holder in (summary, summary.get("error")):
94
+ if not isinstance(holder, dict):
95
+ continue
96
+ hint = holder.get("profile_hint")
97
+ if isinstance(hint, dict) and str(hint.get("setup_url") or "").startswith("/"):
98
+ holder["profile_hint"] = {
99
+ **hint,
100
+ "setup_url": rest_client.AGENT_URL + hint["setup_url"],
101
+ }
102
+ return summary
73
103
 
74
104
 
75
105
  def tinyfish_agent(args: dict[str, Any], **_: Any) -> str:
@@ -88,9 +118,11 @@ def tinyfish_agent(args: dict[str, Any], **_: Any) -> str:
88
118
  api_key=api_key,
89
119
  browser_profile=str(args.get("browser_profile") or "lite"),
90
120
  proxy_country_code=args.get("proxy_country_code") or None,
121
+ use_profile=args.get("use_profile") is True,
122
+ profile_id=str(args.get("profile_id") or "").strip() or None,
91
123
  )
92
124
  except rest_client.TinyFishRestError as exc:
93
- return json.dumps({"error": str(exc)})
125
+ return error_json(exc)
94
126
  run_id = str(started.get("run_id") or "")
95
127
  if not run_id:
96
128
  return json.dumps({"error": "TinyFish Agent did not return a run_id."})
@@ -115,6 +115,20 @@ def _directive_for_feature(
115
115
  return None
116
116
 
117
117
 
118
+ def _profile_label(profile_id: str) -> str:
119
+ from . import rest_client
120
+ from .provider import _api_key
121
+
122
+ # The approver needs the real name, not the agent's claim; the id alone still works.
123
+ try:
124
+ name = rest_client.get_profile(profile_id, api_key=_api_key(), timeout=5.0).get(
125
+ "name"
126
+ )
127
+ except Exception:
128
+ return profile_id
129
+ return f"'{name}' ({profile_id})" if name else profile_id
130
+
131
+
118
132
  def pre_tool_call_policy(
119
133
  tool_name: str, args: dict[str, Any] | None = None, **_: Any
120
134
  ) -> dict[str, str] | None:
@@ -125,6 +139,15 @@ def pre_tool_call_policy(
125
139
 
126
140
  if tool_name == "tinyfish_agent":
127
141
  return _directive_for_feature("agent", tool_name, target)
142
+ if tool_name == "tinyfish_delete_profile":
143
+ profile_id = str(params.get("profile_id") or "")
144
+ label = _profile_label(profile_id)
145
+ # Keyed per profile so one approval never covers deleting a different profile.
146
+ return {
147
+ "action": "approve",
148
+ "message": f"Permanently delete TinyFish profile {label}?",
149
+ "rule_key": f"tinyfish:profile_delete:{profile_id}",
150
+ }
128
151
  if not tool_name.startswith("browser_"):
129
152
  return None
130
153
  config = load_config()
@@ -0,0 +1,272 @@
1
+ """Browser Context Profile tools: saved website sign-ins for ``tinyfish_agent``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable
7
+ from http import HTTPStatus
8
+ from typing import Any
9
+ from urllib.parse import quote, urlencode, urlparse
10
+
11
+ from . import rest_client
12
+ from .provider import MISSING_KEY_ERROR, _api_key
13
+
14
+ PROFILE_TERM = (
15
+ "A TinyFish Browser Context Profile is a saved website sign-in "
16
+ "(not a Hermes profile, and unrelated to browser_profile)."
17
+ )
18
+ _SUMMARY_KEYS = ("id", "name", "is_default", "proxy_country_code", "domain_count")
19
+
20
+ _PROFILE_ID = {
21
+ "type": "string",
22
+ "description": "Profile id from tinyfish_list_profiles.",
23
+ }
24
+ _SITE = {
25
+ "type": "string",
26
+ "description": "Site the user will sign in to, e.g. amazon.com or a URL on it.",
27
+ }
28
+ _PROXY = {
29
+ "type": "string",
30
+ "description": (
31
+ "Proxy country for both sign-in and runs on this profile, as an ISO 3166-1 "
32
+ "alpha-2 code such as US; 'none' connects directly. Set one when a site "
33
+ "blocks the sign-in page or needs a local IP. Overrides tinyfish_agent's "
34
+ "proxy_country_code."
35
+ ),
36
+ }
37
+ _NO_PROXY = frozenset({"none", "off", "direct"})
38
+
39
+
40
+ def _schema(
41
+ name: str, description: str, properties: dict[str, Any], required: list[str]
42
+ ) -> dict[str, Any]:
43
+ return {
44
+ "name": name,
45
+ "description": description,
46
+ "parameters": {
47
+ "type": "object",
48
+ "properties": properties,
49
+ "required": required,
50
+ },
51
+ }
52
+
53
+
54
+ LIST_SCHEMA = _schema(
55
+ "tinyfish_list_profiles",
56
+ f"List the user's TinyFish Browser Context Profiles. {PROFILE_TERM} For a site "
57
+ "that needs sign-in, pass the profile_id whose signed_in_sites covers it (an "
58
+ "entry covers its subdomains) to tinyfish_agent; if signed_in_sites is absent, "
59
+ "match by name and ask the user. Free.",
60
+ {},
61
+ [],
62
+ )
63
+ CREATE_SCHEMA = _schema(
64
+ "tinyfish_create_profile",
65
+ "Create a TinyFish Browser Context Profile and get the link where the user "
66
+ f"signs in. {PROFILE_TERM} Name one per account, e.g. 'Amazon Personal'. Free "
67
+ "until the user opens the link.",
68
+ {
69
+ "name": {"type": "string", "description": "Profile name, 1-100 characters."},
70
+ "site": _SITE,
71
+ "set_as_default": {
72
+ "type": "boolean",
73
+ "description": "Make this the profile use_profile picks by default.",
74
+ },
75
+ "proxy_country_code": _PROXY,
76
+ },
77
+ ["name"],
78
+ )
79
+ SIGN_IN_SCHEMA = _schema(
80
+ "tinyfish_profile_sign_in",
81
+ "Get a link where the user signs in to a site on an existing TinyFish Browser "
82
+ "Context Profile, to add a site or refresh an expired sign-in. Free until the "
83
+ "user opens it.",
84
+ {"profile_id": _PROFILE_ID, "site": _SITE},
85
+ ["profile_id"],
86
+ )
87
+ UPDATE_SCHEMA = _schema(
88
+ "tinyfish_update_profile",
89
+ "Rename a TinyFish Browser Context Profile, make it the default, or change "
90
+ "its proxy country (e.g. when a site blocks the sign-in link).",
91
+ {
92
+ "profile_id": _PROFILE_ID,
93
+ "name": {"type": "string", "description": "New name, 1-100 characters."},
94
+ "set_as_default": {
95
+ "type": "boolean",
96
+ "description": "true makes this the default profile; false is ignored.",
97
+ },
98
+ "proxy_country_code": _PROXY,
99
+ },
100
+ ["profile_id"],
101
+ )
102
+ DELETE_SCHEMA = _schema(
103
+ "tinyfish_delete_profile",
104
+ "Permanently delete a TinyFish Browser Context Profile and its saved sign-ins. "
105
+ "Only when the user asks; name the profile to them first. It cannot be undone. "
106
+ "Deletes one profile per call: to delete several, call once per profile, one "
107
+ "after another, never in parallel or batched.",
108
+ {"profile_id": _PROFILE_ID},
109
+ ["profile_id"],
110
+ )
111
+
112
+
113
+ def setup_url(profile_id: str, site: str = "") -> str:
114
+ query = {"domain": site} if site else {}
115
+ query["source"] = "hermes"
116
+ path = f"/profiles/{quote(profile_id, safe='')}/setup"
117
+ return f"{rest_client.AGENT_URL}{path}?{urlencode(query)}"
118
+
119
+
120
+ def site_host(site: str) -> str:
121
+ # The setup page ignores anything but a bare hostname.
122
+ value = site.strip()
123
+ if not value:
124
+ return ""
125
+ host = urlparse(value if "://" in value else f"https://{value}").hostname or ""
126
+ if "." not in host:
127
+ raise ValueError(f"'{site}' is not a site; pass a domain such as amazon.com.")
128
+ return host
129
+
130
+
131
+ def sign_in_next_step(profile_id: str) -> str:
132
+ return (
133
+ "Give the user setup_url. They may be asked to sign in to TinyFish first; "
134
+ "that is expected. They then sign in to the site and click Save profile; "
135
+ "never ask for their password. Wait until they say they are done, check "
136
+ "tinyfish_list_profiles (if signed_in_sites is absent, take their word), "
137
+ f'then run tinyfish_agent with profile_id "{profile_id}". If the site blocks '
138
+ "the sign-in page, set proxy_country_code with tinyfish_update_profile and "
139
+ "send the same link again."
140
+ )
141
+
142
+
143
+ def error_next_step(exc: rest_client.TinyFishRestError) -> str | None:
144
+ message = str(exc).lower()
145
+ if "no default browser context profile" in message:
146
+ return (
147
+ "Call tinyfish_list_profiles and pass a profile_id; if there are none, "
148
+ "call tinyfish_create_profile and give the user setup_url."
149
+ )
150
+ if "not found" in message and "profile" in message:
151
+ return "Call tinyfish_list_profiles and use an exact profile_id from it."
152
+ if exc.status == HTTPStatus.CONFLICT:
153
+ return "Wait for the previous call on this profile to finish, then retry."
154
+ return None
155
+
156
+
157
+ def error_json(exc: rest_client.TinyFishRestError) -> str:
158
+ payload = {"error": str(exc)}
159
+ next_step = error_next_step(exc)
160
+ if next_step:
161
+ payload["next_step"] = next_step
162
+ return json.dumps(payload)
163
+
164
+
165
+ def _summary(profile: dict[str, Any]) -> dict[str, Any]:
166
+ out = {key: profile.get(key) for key in _SUMMARY_KEYS}
167
+ sites = profile.get("signed_in_sites")
168
+ if isinstance(sites, list):
169
+ out["signed_in_sites"] = [s.get("site") for s in sites if isinstance(s, dict)]
170
+ return out
171
+
172
+
173
+ def _with_key(
174
+ handler: Callable[[dict[str, Any], str], dict[str, Any]],
175
+ ) -> Callable[..., str]:
176
+ def run(args: dict[str, Any], **_: Any) -> str:
177
+ api_key = _api_key()
178
+ if not api_key:
179
+ return json.dumps({"error": MISSING_KEY_ERROR})
180
+ try:
181
+ return json.dumps(handler(args or {}, api_key), ensure_ascii=False)
182
+ except ValueError as exc:
183
+ return json.dumps({"error": str(exc)})
184
+ except rest_client.TinyFishRestError as exc:
185
+ return error_json(exc)
186
+
187
+ return run
188
+
189
+
190
+ def _required(args: dict[str, Any], key: str) -> str:
191
+ value = str(args.get(key) or "").strip()
192
+ if not value:
193
+ raise ValueError(f"{key} is required.")
194
+ return value
195
+
196
+
197
+ def _list(args: dict[str, Any], api_key: str) -> dict[str, Any]:
198
+ profiles = rest_client.list_profiles(api_key=api_key).get("profiles") or []
199
+ return {"profiles": [_summary(p) for p in profiles if isinstance(p, dict)]}
200
+
201
+
202
+ def _proxy_fields(args: dict[str, Any]) -> dict[str, Any]:
203
+ value = str(args.get("proxy_country_code") or "").strip()
204
+ if not value:
205
+ return {}
206
+ if value.lower() in _NO_PROXY:
207
+ return {"proxy_country_code": None}
208
+ if len(value) != 2 or not value.isalpha():
209
+ raise ValueError(
210
+ f"proxy_country_code '{value}' must be a 2-letter country code or 'none'."
211
+ )
212
+ return {"proxy_country_code": value.upper()}
213
+
214
+
215
+ def _create(args: dict[str, Any], api_key: str) -> dict[str, Any]:
216
+ fields: dict[str, Any] = {"name": _required(args, "name")}
217
+ site = site_host(str(args.get("site") or ""))
218
+ if args.get("set_as_default") is not None:
219
+ fields["set_as_default"] = bool(args["set_as_default"])
220
+ fields.update(_proxy_fields(args))
221
+ profile = rest_client.create_profile(fields, api_key=api_key)
222
+ profile_id = str(profile.get("id") or "")
223
+ if not profile_id:
224
+ raise rest_client.TinyFishRestError(
225
+ "TinyFish Profiles did not return a profile id."
226
+ )
227
+ return {
228
+ **_summary(profile),
229
+ "setup_url": setup_url(profile_id, site),
230
+ "next_step": sign_in_next_step(profile_id),
231
+ }
232
+
233
+
234
+ def _sign_in(args: dict[str, Any], api_key: str) -> dict[str, Any]:
235
+ profile_id = _required(args, "profile_id")
236
+ site = site_host(str(args.get("site") or ""))
237
+ return {
238
+ "profile_id": profile_id,
239
+ "setup_url": setup_url(profile_id, site),
240
+ "next_step": sign_in_next_step(profile_id),
241
+ }
242
+
243
+
244
+ def _update(args: dict[str, Any], api_key: str) -> dict[str, Any]:
245
+ profile_id = _required(args, "profile_id")
246
+ fields: dict[str, Any] = {}
247
+ name = str(args.get("name") or "").strip()
248
+ if name:
249
+ fields["name"] = name
250
+ if args.get("set_as_default") is True:
251
+ fields["set_as_default"] = True
252
+ fields.update(_proxy_fields(args))
253
+ if not fields:
254
+ raise ValueError(
255
+ "Nothing to update: pass name, proxy_country_code, or set_as_default true."
256
+ )
257
+ return _summary(rest_client.update_profile(profile_id, fields, api_key=api_key))
258
+
259
+
260
+ def _delete(args: dict[str, Any], api_key: str) -> dict[str, Any]:
261
+ profile_id = _required(args, "profile_id")
262
+ rest_client.delete_profile(profile_id, api_key=api_key)
263
+ return {"profile_id": profile_id, "deleted": True}
264
+
265
+
266
+ PROFILE_TOOLS: tuple[tuple[dict[str, Any], Callable[..., str]], ...] = (
267
+ (LIST_SCHEMA, _with_key(_list)),
268
+ (CREATE_SCHEMA, _with_key(_create)),
269
+ (SIGN_IN_SCHEMA, _with_key(_sign_in)),
270
+ (UPDATE_SCHEMA, _with_key(_update)),
271
+ (DELETE_SCHEMA, _with_key(_delete)),
272
+ )
@@ -28,20 +28,20 @@ MISSING_KEY_ERROR = (
28
28
  )
29
29
 
30
30
 
31
- def _provider_env(name: str) -> str:
32
- """Read Hermes config-aware environment values when available."""
31
+ def _secret(name: str) -> str:
32
+ """Read a credential through Hermes' profile-aware secret scope."""
33
33
 
34
34
  try:
35
- from agent.web_search_provider import get_provider_env
36
-
37
- return str(get_provider_env(name) or "").strip()
38
- except Exception:
35
+ from agent.secret_scope import get_secret_str
36
+ except ImportError: # Hermes before profile secret scopes (2026-06)
39
37
  return str(os.getenv(name, "") or "").strip()
38
+ # Let UnscopedSecretError raise; os.environ there holds another profile's key.
39
+ return str(get_secret_str(name) or "").strip()
40
40
 
41
41
 
42
42
  def _api_key() -> str:
43
43
  for name in API_KEY_ENV_VARS:
44
- value = _provider_env(name)
44
+ value = _secret(name)
45
45
  if value:
46
46
  return value
47
47
  return ""
@@ -2,9 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
5
6
  import math
7
+ import os
8
+ import sys
6
9
  import time
10
+ import uuid
7
11
  from typing import Any, NoReturn, cast
12
+ from urllib.parse import quote
8
13
 
9
14
  import httpx
10
15
 
@@ -17,6 +22,7 @@ AGENT_URL = "https://agent.tinyfish.ai"
17
22
  WALLET_URL = f"{AGENT_URL}/v1/wallet"
18
23
  SEARCH_USAGE_URL = f"{SEARCH_URL}/usage"
19
24
  FETCH_USAGE_URL = f"{FETCH_URL}/usage"
25
+ TELEMETRY_URL = f"{AGENT_URL}/api/cli/connect-event"
20
26
 
21
27
  _BROWSER_CLOSE_MAX_ATTEMPTS = 3
22
28
  _BROWSER_CLOSE_RETRYABLE_STATUSES = frozenset({409, 429, 500, 503, 504})
@@ -52,6 +58,15 @@ def _json_headers(api_key: str) -> dict[str, str]:
52
58
  return headers
53
59
 
54
60
 
61
+ def _server_message(response: httpx.Response) -> str:
62
+ try:
63
+ error = response.json().get("error")
64
+ except Exception:
65
+ return ""
66
+ message = error.get("message") if isinstance(error, dict) else None
67
+ return message[:500] if isinstance(message, str) else ""
68
+
69
+
55
70
  def _raise_http_error(service: str, exc: httpx.HTTPStatusError) -> NoReturn:
56
71
  status = exc.response.status_code
57
72
  if status == 402:
@@ -60,7 +75,12 @@ def _raise_http_error(service: str, exc: httpx.HTTPStatusError) -> NoReturn:
60
75
  "TinyFish credits or billing may be required.",
61
76
  status,
62
77
  ) from exc
63
- raise TinyFishRestError(f"{service} returned HTTP {status}", status) from exc
78
+ # 4xx text is TinyFish's own validation message and tells the agent what to fix.
79
+ detail = _server_message(exc.response) if 400 <= status < 500 else ""
80
+ suffix = f": {detail}" if detail else ""
81
+ raise TinyFishRestError(
82
+ f"{service} returned HTTP {status}{suffix}", status
83
+ ) from exc
64
84
 
65
85
 
66
86
  def search(
@@ -289,6 +309,7 @@ def _agent_request(
289
309
  api_key: str,
290
310
  json_body: dict[str, Any] | None = None,
291
311
  timeout: float = 30.0,
312
+ service: str = "TinyFish Agent",
292
313
  ) -> dict[str, Any]:
293
314
  try:
294
315
  response = httpx.request(
@@ -301,13 +322,13 @@ def _agent_request(
301
322
  response.raise_for_status()
302
323
  data = response.json()
303
324
  except httpx.HTTPStatusError as exc:
304
- _raise_http_error("TinyFish Agent", exc)
325
+ _raise_http_error(service, exc)
305
326
  except httpx.RequestError as exc:
306
- raise TinyFishRestError(f"Could not reach TinyFish Agent: {exc}") from exc
327
+ raise TinyFishRestError(f"Could not reach {service}: {exc}") from exc
307
328
  except ValueError as exc:
308
- raise TinyFishRestError("TinyFish Agent returned invalid JSON") from exc
329
+ raise TinyFishRestError(f"{service} returned invalid JSON") from exc
309
330
  if not isinstance(data, dict):
310
- raise TinyFishRestError("TinyFish Agent returned an unexpected response")
331
+ raise TinyFishRestError(f"{service} returned an unexpected response")
311
332
  return data
312
333
 
313
334
 
@@ -318,6 +339,8 @@ def start_run(
318
339
  api_key: str,
319
340
  browser_profile: str = "lite",
320
341
  proxy_country_code: str | None = None,
342
+ use_profile: bool = False,
343
+ profile_id: str | None = None,
321
344
  ) -> dict[str, Any]:
322
345
  """Enqueue a TinyFish Agent run; returns immediately with ``run_id``."""
323
346
 
@@ -329,14 +352,99 @@ def start_run(
329
352
  }
330
353
  if proxy_country_code:
331
354
  body["proxy_config"] = {"enabled": True, "country_code": proxy_country_code}
355
+ # The API rejects profile_id without use_profile, so an id implies it.
356
+ if use_profile or profile_id:
357
+ body["use_profile"] = True
358
+ if profile_id:
359
+ body["profile_id"] = profile_id
332
360
  return _agent_request(
333
361
  "POST", "/v1/automation/run-async", api_key=api_key, json_body=body
334
362
  )
335
363
 
336
364
 
365
+ def telemetry_disabled(value: str | None = None) -> bool:
366
+ # Matches the TinyFish CLI: anything but empty, 0 or false opts out.
367
+ if value is None:
368
+ value = os.environ.get("TINYFISH_NO_TELEMETRY", "")
369
+ return value.strip().lower() not in ("", "0", "false")
370
+
371
+
372
+ def report_setup(outcome: str, *, key_source: str, api_key: str = "") -> None:
373
+ """Record a ``hermes tinyfish setup`` outcome; never raises."""
374
+
375
+ if telemetry_disabled():
376
+ return
377
+ headers = {"Content-Type": "application/json"}
378
+ if api_key:
379
+ headers["x-api-key-lookup"] = hashlib.sha256(api_key.encode()).hexdigest()
380
+ body = {
381
+ "event": "harness_plugin_setup",
382
+ "harness": "hermes",
383
+ "plugin_version": __version__,
384
+ "outcome": outcome,
385
+ "key_source": key_source,
386
+ "runtime_platform": sys.platform,
387
+ "setup_id": str(uuid.uuid4()),
388
+ }
389
+ try:
390
+ httpx.post(TELEMETRY_URL, headers=headers, json=body, timeout=2.0)
391
+ except Exception:
392
+ pass
393
+
394
+
337
395
  def get_run(run_id: str, *, api_key: str) -> dict[str, Any]:
338
396
  return _agent_request("GET", f"/v1/runs/{run_id}", api_key=api_key)
339
397
 
340
398
 
341
399
  def cancel_run(run_id: str, *, api_key: str) -> dict[str, Any]:
342
400
  return _agent_request("POST", f"/v1/runs/{run_id}/cancel", api_key=api_key)
401
+
402
+
403
+ def _profile_request(
404
+ method: str,
405
+ path: str,
406
+ *,
407
+ api_key: str,
408
+ json_body: dict[str, Any] | None = None,
409
+ timeout: float = 30.0,
410
+ ) -> dict[str, Any]:
411
+ return _agent_request(
412
+ method,
413
+ f"/v1/profiles{path}",
414
+ api_key=api_key,
415
+ json_body=json_body,
416
+ timeout=timeout,
417
+ service="TinyFish Profiles",
418
+ )
419
+
420
+
421
+ def _profile_path(profile_id: str) -> str:
422
+ return "/" + quote(profile_id, safe="")
423
+
424
+
425
+ def list_profiles(*, api_key: str) -> dict[str, Any]:
426
+ return _profile_request("GET", "", api_key=api_key)
427
+
428
+
429
+ def create_profile(fields: dict[str, Any], *, api_key: str) -> dict[str, Any]:
430
+ return _profile_request("POST", "", api_key=api_key, json_body=fields)
431
+
432
+
433
+ def get_profile(
434
+ profile_id: str, *, api_key: str, timeout: float = 30.0
435
+ ) -> dict[str, Any]:
436
+ return _profile_request(
437
+ "GET", _profile_path(profile_id), api_key=api_key, timeout=timeout
438
+ )
439
+
440
+
441
+ def update_profile(
442
+ profile_id: str, fields: dict[str, Any], *, api_key: str
443
+ ) -> dict[str, Any]:
444
+ return _profile_request(
445
+ "PATCH", _profile_path(profile_id), api_key=api_key, json_body=fields
446
+ )
447
+
448
+
449
+ def delete_profile(profile_id: str, *, api_key: str) -> dict[str, Any]:
450
+ return _profile_request("DELETE", _profile_path(profile_id), api_key=api_key)
@@ -30,6 +30,7 @@ from .provider import (
30
30
  MISSING_KEY_ERROR,
31
31
  TinyFishWebSearchProvider,
32
32
  _api_key,
33
+ _secret,
33
34
  )
34
35
  from .routing_context import tinyfish_mcp_configured
35
36
 
@@ -51,6 +52,11 @@ def setup_tinyfish_cli(parser: argparse.ArgumentParser) -> None:
51
52
  setup.add_argument(
52
53
  "--live", action="store_true", help="Run live doctor checks after setup"
53
54
  )
55
+ setup.add_argument(
56
+ "--share-usage",
57
+ action="store_true",
58
+ help="Send TinyFish the setup outcome (sets tinyfish.telemetry: true)",
59
+ )
54
60
 
55
61
  status = sub.add_parser("status", help="Print non-secret TinyFish status")
56
62
  status.add_argument(
@@ -140,15 +146,6 @@ def _save_config(config: dict[str, Any]) -> None:
140
146
  save_config(config)
141
147
 
142
148
 
143
- def _get_env(name: str) -> str:
144
- try:
145
- from hermes_cli.config import get_env_value
146
-
147
- return str(get_env_value(name) or "").strip()
148
- except Exception:
149
- return str(os.getenv(name, "") or "").strip()
150
-
151
-
152
149
  def _save_env_secure(name: str, value: str) -> dict[str, Any]:
153
150
  from hermes_cli.config import save_env_value_secure
154
151
 
@@ -166,7 +163,7 @@ def _read_saved_env(name: str) -> str:
166
163
 
167
164
  def _api_key_env_var() -> str:
168
165
  for name in API_KEY_ENV_VARS:
169
- if _get_env(name):
166
+ if _secret(name):
170
167
  return name
171
168
  return ""
172
169
 
@@ -251,26 +248,71 @@ def _store_api_key(api_key: str) -> bool:
251
248
  return False
252
249
 
253
250
 
251
+ TELEMETRY_QUESTION = (
252
+ "Share setup results with TinyFish (outcome, plugin version, OS and a hash "
253
+ "of your API key, never the key)?"
254
+ )
255
+
256
+
257
+ def _share_usage(args: argparse.Namespace) -> bool:
258
+ """Opt-in only: the flag, a saved tinyfish.telemetry, or a y/N answer."""
259
+
260
+ # Profile-scoped, so an opt-out in this profile's .env counts.
261
+ if rest_client.telemetry_disabled(_secret("TINYFISH_NO_TELEMETRY")):
262
+ return False
263
+ config = _load_config()
264
+ section = config.get("tinyfish")
265
+ if not isinstance(section, dict):
266
+ section = config["tinyfish"] = {}
267
+ if getattr(args, "share_usage", False):
268
+ choice = True
269
+ elif isinstance(section.get("telemetry"), bool):
270
+ return bool(section["telemetry"])
271
+ elif getattr(args, "yes", False) or not sys.stdin.isatty():
272
+ return False
273
+ else:
274
+ choice = _confirm(TELEMETRY_QUESTION, default=False)
275
+ section["telemetry"] = choice
276
+ _save_config(config)
277
+ return choice
278
+
279
+
254
280
  def cmd_setup(
255
281
  args: argparse.Namespace,
256
282
  *,
257
283
  provider: TinyFishWebSearchProvider | None = None,
258
284
  ) -> int:
285
+ share_usage = _share_usage(args)
259
286
  api_key = (getattr(args, "api_key", None) or "").strip()
287
+ key_source = "flag" if api_key else "none"
260
288
  existing = _api_key_env_var()
261
289
  if not api_key and existing:
262
290
  print(f"Using the TinyFish API key from {existing}")
291
+ key_source = "env"
263
292
  elif not api_key and sys.stdin.isatty():
264
293
  api_key = _prompt_for_key()
294
+ key_source = "prompt" if api_key else "none"
295
+
296
+ # Catalog installs skip `tinyfish connect`; this is their install signal.
297
+ def report(outcome: str) -> None:
298
+ if not share_usage:
299
+ return
300
+ rest_client.report_setup(
301
+ outcome, key_source=key_source, api_key=api_key or _api_key()
302
+ )
303
+
265
304
  if api_key:
266
305
  rejection = _validate_key(api_key)
267
306
  if rejection:
268
307
  print(rejection, file=sys.stderr)
308
+ report("key_rejected")
269
309
  return 1
270
310
  if not _store_api_key(api_key):
311
+ report("save_failed")
271
312
  return 1
272
313
  elif not existing:
273
314
  print(MISSING_KEY_ERROR, file=sys.stderr)
315
+ report("no_key")
274
316
  return 1
275
317
 
276
318
  # Route web tools only once a usable key exists, so a failed setup changes nothing.
@@ -284,8 +326,10 @@ def cmd_setup(
284
326
  _save_config(config)
285
327
  print("Configured Hermes web backends to use TinyFish")
286
328
  if not _enable_toolset("web"):
329
+ report("backend_failed")
287
330
  return 1
288
331
 
332
+ report("completed")
289
333
  if getattr(args, "live", False):
290
334
  doctor_args = argparse.Namespace(json=False, live=True, live_paid=False)
291
335
  return cmd_doctor(doctor_args, provider=provider)