@tiny-fish/hermes 0.2.4 → 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,13 +89,35 @@ 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
 
95
110
  ## Telemetry
96
111
 
97
- Each `hermes tinyfish setup` run sends one event to TinyFish, 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. Set `TINYFISH_NO_TELEMETRY=1` in your shell or in `~/.hermes/.env` to turn it off.
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.
98
121
 
99
122
  ## Development
100
123
 
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.4",
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.4
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.4"
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 ""
@@ -9,6 +9,7 @@ import sys
9
9
  import time
10
10
  import uuid
11
11
  from typing import Any, NoReturn, cast
12
+ from urllib.parse import quote
12
13
 
13
14
  import httpx
14
15
 
@@ -57,6 +58,15 @@ def _json_headers(api_key: str) -> dict[str, str]:
57
58
  return headers
58
59
 
59
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
+
60
70
  def _raise_http_error(service: str, exc: httpx.HTTPStatusError) -> NoReturn:
61
71
  status = exc.response.status_code
62
72
  if status == 402:
@@ -65,7 +75,12 @@ def _raise_http_error(service: str, exc: httpx.HTTPStatusError) -> NoReturn:
65
75
  "TinyFish credits or billing may be required.",
66
76
  status,
67
77
  ) from exc
68
- 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
69
84
 
70
85
 
71
86
  def search(
@@ -294,6 +309,7 @@ def _agent_request(
294
309
  api_key: str,
295
310
  json_body: dict[str, Any] | None = None,
296
311
  timeout: float = 30.0,
312
+ service: str = "TinyFish Agent",
297
313
  ) -> dict[str, Any]:
298
314
  try:
299
315
  response = httpx.request(
@@ -306,13 +322,13 @@ def _agent_request(
306
322
  response.raise_for_status()
307
323
  data = response.json()
308
324
  except httpx.HTTPStatusError as exc:
309
- _raise_http_error("TinyFish Agent", exc)
325
+ _raise_http_error(service, exc)
310
326
  except httpx.RequestError as exc:
311
- raise TinyFishRestError(f"Could not reach TinyFish Agent: {exc}") from exc
327
+ raise TinyFishRestError(f"Could not reach {service}: {exc}") from exc
312
328
  except ValueError as exc:
313
- raise TinyFishRestError("TinyFish Agent returned invalid JSON") from exc
329
+ raise TinyFishRestError(f"{service} returned invalid JSON") from exc
314
330
  if not isinstance(data, dict):
315
- raise TinyFishRestError("TinyFish Agent returned an unexpected response")
331
+ raise TinyFishRestError(f"{service} returned an unexpected response")
316
332
  return data
317
333
 
318
334
 
@@ -323,6 +339,8 @@ def start_run(
323
339
  api_key: str,
324
340
  browser_profile: str = "lite",
325
341
  proxy_country_code: str | None = None,
342
+ use_profile: bool = False,
343
+ profile_id: str | None = None,
326
344
  ) -> dict[str, Any]:
327
345
  """Enqueue a TinyFish Agent run; returns immediately with ``run_id``."""
328
346
 
@@ -334,6 +352,11 @@ def start_run(
334
352
  }
335
353
  if proxy_country_code:
336
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
337
360
  return _agent_request(
338
361
  "POST", "/v1/automation/run-async", api_key=api_key, json_body=body
339
362
  )
@@ -375,3 +398,53 @@ def get_run(run_id: str, *, api_key: str) -> dict[str, Any]:
375
398
 
376
399
  def cancel_run(run_id: str, *, api_key: str) -> dict[str, Any]:
377
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,11 +248,41 @@ 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()
260
287
  key_source = "flag" if api_key else "none"
261
288
  existing = _api_key_env_var()
@@ -268,8 +295,7 @@ def cmd_setup(
268
295
 
269
296
  # Catalog installs skip `tinyfish connect`; this is their install signal.
270
297
  def report(outcome: str) -> None:
271
- # Hermes keeps settings in its .env, which never reaches os.environ.
272
- if rest_client.telemetry_disabled(_get_env("TINYFISH_NO_TELEMETRY")):
298
+ if not share_usage:
273
299
  return
274
300
  rest_client.report_setup(
275
301
  outcome, key_source=key_source, api_key=api_key or _api_key()