@tiny-fish/hermes 0.1.1

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,695 @@
1
+ """CLI helpers for ``hermes tinyfish``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from typing import Any
10
+
11
+ from . import rest_client
12
+ from .config import (
13
+ CREDIT_FEATURES,
14
+ CREDIT_POLICIES,
15
+ DEFAULT_CREDIT_POLICY,
16
+ browser_cloud_provider,
17
+ credit_policy_summary,
18
+ normalize_feature,
19
+ normalize_policy,
20
+ routing_context_enabled,
21
+ set_credit_policy,
22
+ )
23
+ from .credit_policy import request_credit_approval
24
+ from .provider import (
25
+ API_KEY_ENV_VARS,
26
+ API_KEY_URL,
27
+ MISSING_KEY_ERROR,
28
+ TinyFishWebSearchProvider,
29
+ _api_key,
30
+ )
31
+ from .routing_context import tinyfish_mcp_configured
32
+
33
+
34
+ def setup_tinyfish_cli(parser: argparse.ArgumentParser) -> None:
35
+ parser.description = "Configure and diagnose the TinyFish provider plugin."
36
+ sub = parser.add_subparsers(dest="tinyfish_command")
37
+
38
+ setup = sub.add_parser("setup", help="Route Hermes web search/extract to TinyFish")
39
+ setup.add_argument(
40
+ "--yes", "-y", action="store_true", help="Accept config changes without prompts"
41
+ )
42
+ setup.add_argument("--api-key", help="Save TINYFISH_API_KEY in Hermes .env")
43
+ setup.add_argument(
44
+ "--no-web-backend",
45
+ action="store_true",
46
+ help="Do not set web.search_backend/extract_backend",
47
+ )
48
+ setup.add_argument(
49
+ "--live", action="store_true", help="Run live doctor checks after setup"
50
+ )
51
+
52
+ status = sub.add_parser("status", help="Print non-secret TinyFish status")
53
+ status.add_argument(
54
+ "--json", action="store_true", help="Print machine-readable JSON"
55
+ )
56
+
57
+ doctor = sub.add_parser("doctor", help="Check TinyFish plugin configuration")
58
+ doctor.add_argument(
59
+ "--json", action="store_true", help="Print machine-readable JSON"
60
+ )
61
+ doctor.add_argument(
62
+ "--live", action="store_true", help="Run a live search/fetch check"
63
+ )
64
+ doctor.add_argument(
65
+ "--live-paid",
66
+ action="store_true",
67
+ help="Create and close a TinyFish Browser session per its credit policy",
68
+ )
69
+
70
+ credits = sub.add_parser(
71
+ "credits", help="Inspect or update TinyFish credit policies"
72
+ )
73
+ credits_sub = credits.add_subparsers(dest="credits_command")
74
+ credits_status = credits_sub.add_parser(
75
+ "status", help="Show TinyFish credit policies"
76
+ )
77
+ credits_status.add_argument(
78
+ "--json", action="store_true", help="Print machine-readable JSON"
79
+ )
80
+ credits_set = credits_sub.add_parser(
81
+ "set", help="Set a feature policy: deny, request, or allow"
82
+ )
83
+ credits_set.add_argument("feature", choices=list(CREDIT_FEATURES))
84
+ credits_set.add_argument("policy", choices=list(CREDIT_POLICIES))
85
+ credits_sub.add_parser(
86
+ "reset", help=f"Restore the default policy ({DEFAULT_CREDIT_POLICY})"
87
+ )
88
+
89
+ browser = sub.add_parser("browser", help="Route Hermes browser tools to TinyFish")
90
+ browser_sub = browser.add_subparsers(dest="browser_command")
91
+ browser_sub.add_parser("enable", help="Set browser.cloud_provider to tinyfish")
92
+ browser_sub.add_parser("disable", help="Unset browser.cloud_provider if tinyfish")
93
+ browser_sub.add_parser("status", help="Show browser provider and credit policy")
94
+
95
+ usage = sub.add_parser(
96
+ "usage", help="Read TinyFish wallet balance and billing rates"
97
+ )
98
+ usage.add_argument(
99
+ "--json", action="store_true", help="Print machine-readable JSON"
100
+ )
101
+
102
+
103
+ def dispatch_tinyfish_cli(
104
+ args: argparse.Namespace,
105
+ *,
106
+ provider: TinyFishWebSearchProvider | None = None,
107
+ ) -> int:
108
+ command = getattr(args, "tinyfish_command", None) or "status"
109
+ if command == "setup":
110
+ return cmd_setup(args, provider=provider)
111
+ if command == "status":
112
+ return cmd_status(args, provider=provider)
113
+ if command == "doctor":
114
+ return cmd_doctor(args, provider=provider)
115
+ if command == "credits":
116
+ return cmd_credits(args)
117
+ if command == "browser":
118
+ return cmd_browser(args)
119
+ if command == "usage":
120
+ return cmd_usage(args)
121
+ print(
122
+ "Usage: hermes tinyfish {setup,status,doctor,credits,browser,usage}",
123
+ file=sys.stderr,
124
+ )
125
+ return 2
126
+
127
+
128
+ def _load_config() -> dict[str, Any]:
129
+ from hermes_cli.config import load_config
130
+
131
+ return dict(load_config() or {})
132
+
133
+
134
+ def _save_config(config: dict[str, Any]) -> None:
135
+ from hermes_cli.config import save_config
136
+
137
+ save_config(config)
138
+
139
+
140
+ def _get_env(name: str) -> str:
141
+ try:
142
+ from hermes_cli.config import get_env_value
143
+
144
+ return str(get_env_value(name) or "").strip()
145
+ except Exception:
146
+ return str(os.getenv(name, "") or "").strip()
147
+
148
+
149
+ def _save_env_secure(name: str, value: str) -> dict[str, Any]:
150
+ from hermes_cli.config import save_env_value_secure
151
+
152
+ return dict(save_env_value_secure(name, value) or {})
153
+
154
+
155
+ def _read_saved_env(name: str) -> str:
156
+ try:
157
+ from hermes_cli.config import get_env_value_prefer_dotenv
158
+
159
+ return str(get_env_value_prefer_dotenv(name) or "").strip()
160
+ except Exception:
161
+ return ""
162
+
163
+
164
+ def _api_key_env_var() -> str:
165
+ for name in API_KEY_ENV_VARS:
166
+ if _get_env(name):
167
+ return name
168
+ return ""
169
+
170
+
171
+ def _confirm(question: str, *, default: bool = True, assume_yes: bool = False) -> bool:
172
+ if assume_yes:
173
+ return True
174
+ if not sys.stdin.isatty():
175
+ # A piped run must not accept config changes without an explicit --yes.
176
+ return False
177
+ suffix = "Y/n" if default else "y/N"
178
+ try:
179
+ answer = input(f"{question} [{suffix}]: ").strip().lower()
180
+ except (EOFError, KeyboardInterrupt):
181
+ print()
182
+ return default
183
+ if not answer:
184
+ return default
185
+ return answer in {"y", "yes"}
186
+
187
+
188
+ def _prompt_secret(question: str) -> str:
189
+ import getpass
190
+
191
+ try:
192
+ return getpass.getpass(question).strip()
193
+ except (EOFError, KeyboardInterrupt):
194
+ print()
195
+ return ""
196
+
197
+
198
+ def _apply_web_backend_config(config: dict[str, Any]) -> None:
199
+ web = config.setdefault("web", {})
200
+ web["search_backend"] = "tinyfish"
201
+ web["extract_backend"] = "tinyfish"
202
+ if not web.get("backend"):
203
+ web["backend"] = "tinyfish"
204
+
205
+
206
+ def _store_api_key(api_key: str) -> bool:
207
+ try:
208
+ outcome = _save_env_secure("TINYFISH_API_KEY", api_key)
209
+ except Exception as exc:
210
+ outcome = {"success": False, "error": type(exc).__name__}
211
+ # The secure save reports success even on managed-install refusal; trust read-back.
212
+ if outcome.get("success") and _read_saved_env("TINYFISH_API_KEY") == api_key:
213
+ print("Saved TINYFISH_API_KEY in Hermes .env")
214
+ shell_value = str(os.environ.get("TINYFISH_API_KEY", "") or "").strip()
215
+ if shell_value and shell_value != api_key:
216
+ print(
217
+ "Warning: the TINYFISH_API_KEY exported in this shell differs "
218
+ "from the saved key and shadows it at runtime."
219
+ )
220
+ return True
221
+ print(
222
+ "Could not save TINYFISH_API_KEY (managed install or managed key); "
223
+ "set it in your environment instead.",
224
+ file=sys.stderr,
225
+ )
226
+ return False
227
+
228
+
229
+ def cmd_setup(
230
+ args: argparse.Namespace,
231
+ *,
232
+ provider: TinyFishWebSearchProvider | None = None,
233
+ ) -> int:
234
+ config = _load_config()
235
+
236
+ if not getattr(args, "no_web_backend", False) and _confirm(
237
+ "Set Hermes web.search_backend and web.extract_backend to tinyfish?",
238
+ default=True,
239
+ assume_yes=bool(getattr(args, "yes", False)),
240
+ ):
241
+ _apply_web_backend_config(config)
242
+ _save_config(config)
243
+ print("Configured Hermes web backends to use TinyFish")
244
+
245
+ api_key = (getattr(args, "api_key", None) or "").strip()
246
+ if (
247
+ not api_key
248
+ and not _api_key_env_var()
249
+ and sys.stdin.isatty()
250
+ and _confirm("Add TINYFISH_API_KEY now?", default=True, assume_yes=False)
251
+ ):
252
+ api_key = _prompt_secret(f"TinyFish API key (create at {API_KEY_URL}): ")
253
+ if api_key and not _store_api_key(api_key):
254
+ return 1
255
+
256
+ if getattr(args, "live", False):
257
+ doctor_args = argparse.Namespace(json=False, live=True, live_paid=False)
258
+ return cmd_doctor(doctor_args, provider=provider)
259
+
260
+ print("Run `hermes tinyfish doctor --live` to verify the setup.")
261
+ return 0
262
+
263
+
264
+ def collect_status(
265
+ *,
266
+ live: bool = False,
267
+ provider: TinyFishWebSearchProvider | None = None,
268
+ ) -> dict[str, Any]:
269
+ from . import __version__
270
+
271
+ config = _load_config()
272
+ web_cfg = config.get("web") if isinstance(config.get("web"), dict) else {}
273
+ provider = provider or TinyFishWebSearchProvider()
274
+ api_key_env = _api_key_env_var()
275
+
276
+ checks: dict[str, Any] = {
277
+ "plugin_version": __version__,
278
+ "provider_available": provider.is_available(),
279
+ "api_key_env_var": api_key_env or None,
280
+ "api_key_configured": bool(api_key_env),
281
+ "web_search_backend": (web_cfg or {}).get("search_backend")
282
+ or (web_cfg or {}).get("backend")
283
+ or "",
284
+ "web_extract_backend": (web_cfg or {}).get("extract_backend")
285
+ or (web_cfg or {}).get("backend")
286
+ or "",
287
+ "browser_cloud_provider": browser_cloud_provider(config),
288
+ "credit_policy": credit_policy_summary(config),
289
+ "routing_context_enabled": routing_context_enabled(config),
290
+ "routing_context_active": bool(
291
+ routing_context_enabled(config) and tinyfish_mcp_configured(config)
292
+ ),
293
+ "mcp_configured": tinyfish_mcp_configured(config),
294
+ }
295
+ checks["web_backend_configured"] = (
296
+ checks["web_search_backend"] == "tinyfish"
297
+ and checks["web_extract_backend"] == "tinyfish"
298
+ )
299
+ configured_ok = bool(
300
+ checks["web_backend_configured"] and checks["api_key_configured"]
301
+ )
302
+ checks["ok"] = configured_ok
303
+
304
+ if live:
305
+ try:
306
+ search = provider.search("TinyFish web agent", limit=1)
307
+ checks["live_search_ok"] = bool(
308
+ search.get("success") and search.get("data", {}).get("web")
309
+ )
310
+ if not checks["live_search_ok"]:
311
+ checks["live_search_error"] = str(
312
+ search.get("error") or "TinyFish Search returned no web results."
313
+ )
314
+ except Exception as exc: # noqa: BLE001 - diagnostics must not crash
315
+ checks["live_search_ok"] = False
316
+ checks["live_search_error"] = (
317
+ f"TinyFish Search check failed ({type(exc).__name__})."
318
+ )
319
+
320
+ try:
321
+ fetch = provider.extract(["https://docs.tinyfish.ai/"], format="markdown")
322
+ content = ""
323
+ if fetch:
324
+ content = str(
325
+ fetch[0].get("content") or fetch[0].get("raw_content") or ""
326
+ ).strip()
327
+ checks["live_fetch_ok"] = bool(
328
+ fetch and not fetch[0].get("error") and content
329
+ )
330
+ if not checks["live_fetch_ok"]:
331
+ error = fetch[0].get("error") if fetch else None
332
+ checks["live_fetch_error"] = str(
333
+ error or "TinyFish Fetch returned no extracted content."
334
+ )
335
+ except Exception as exc: # noqa: BLE001 - diagnostics must not crash
336
+ checks["live_fetch_ok"] = False
337
+ checks["live_fetch_error"] = (
338
+ f"TinyFish Fetch check failed ({type(exc).__name__})."
339
+ )
340
+
341
+ checks["ok"] = bool(
342
+ configured_ok and checks["live_search_ok"] and checks["live_fetch_ok"]
343
+ )
344
+ return checks
345
+
346
+
347
+ def _status_lines(status: dict[str, Any]) -> list[str]:
348
+ lines = ["TinyFish Hermes plugin status"]
349
+ for key in sorted(status):
350
+ value = status[key]
351
+ if key == "credit_policy" and isinstance(value, dict):
352
+ lines.append(" credit_policy:")
353
+ for feature in CREDIT_FEATURES:
354
+ lines.append(
355
+ f" {feature}: {value.get(feature, DEFAULT_CREDIT_POLICY)}"
356
+ )
357
+ continue
358
+ if isinstance(value, bool):
359
+ value = "yes" if value else "no"
360
+ if value is None:
361
+ value = "none"
362
+ lines.append(f" {key}: {value}")
363
+ return lines
364
+
365
+
366
+ def _print_status(status: dict[str, Any]) -> None:
367
+ print("\n".join(_status_lines(status)))
368
+
369
+
370
+ def cmd_status(
371
+ args: argparse.Namespace,
372
+ *,
373
+ provider: TinyFishWebSearchProvider | None = None,
374
+ ) -> int:
375
+ status = collect_status(live=False, provider=provider)
376
+ if getattr(args, "json", False):
377
+ print(json.dumps(status, indent=2, sort_keys=True))
378
+ else:
379
+ _print_status(status)
380
+ return 0
381
+
382
+
383
+ def _recommended_next_step(status: dict[str, Any]) -> str:
384
+ configured = bool(
385
+ status.get("web_backend_configured") and status.get("api_key_configured")
386
+ )
387
+ if not configured:
388
+ return "run `hermes tinyfish setup`."
389
+ if not status.get("live_search_ok", True) or not status.get("live_fetch_ok", True):
390
+ return (
391
+ "verify your TinyFish API key and service availability, then retry "
392
+ "`hermes tinyfish doctor --live`."
393
+ )
394
+ return (
395
+ "review the browser credit policy and API key, then retry "
396
+ "`hermes tinyfish doctor --live-paid`."
397
+ )
398
+
399
+
400
+ def cmd_doctor(
401
+ args: argparse.Namespace,
402
+ *,
403
+ provider: TinyFishWebSearchProvider | None = None,
404
+ ) -> int:
405
+ status = collect_status(live=bool(getattr(args, "live", False)), provider=provider)
406
+ if getattr(args, "live_paid", False):
407
+ paid_ok = _run_live_paid_checks(status)
408
+ status["ok"] = bool(status["ok"] and paid_ok)
409
+ if getattr(args, "json", False):
410
+ print(json.dumps(status, indent=2, sort_keys=True))
411
+ else:
412
+ _print_status(status)
413
+ if not status["ok"]:
414
+ print()
415
+ print(f"Recommended next step: {_recommended_next_step(status)}")
416
+ return 0 if status["ok"] else 1
417
+
418
+
419
+ def _run_live_paid_checks(status: dict[str, Any]) -> bool:
420
+ policy_status = status.get("credit_policy") or {}
421
+ browser_policy = (
422
+ policy_status.get("browser", "deny")
423
+ if isinstance(policy_status, dict)
424
+ else "deny"
425
+ )
426
+ if browser_policy == "deny":
427
+ status["live_paid_ok"] = False
428
+ status["live_paid_browser_ok"] = False
429
+ status["live_paid_error"] = "TinyFish browser policy is deny."
430
+ return False
431
+
432
+ approved, message = request_credit_approval(
433
+ "browser", "doctor-live-paid", "TinyFish Browser session"
434
+ )
435
+ if not approved:
436
+ status["live_paid_ok"] = False
437
+ status["live_paid_browser_ok"] = False
438
+ status["live_paid_error"] = message
439
+ return False
440
+
441
+ api_key = _api_key()
442
+ if not api_key:
443
+ status["live_paid_ok"] = False
444
+ status["live_paid_browser_ok"] = False
445
+ status["live_paid_error"] = (
446
+ "A TinyFish API key is required for paid live checks."
447
+ )
448
+ return False
449
+
450
+ session_id = ""
451
+ try:
452
+ session = rest_client.create_browser_session(api_key=api_key)
453
+ session_id = str(session.get("session_id") or session.get("id") or "").strip()
454
+ if not session_id:
455
+ status["live_paid_error"] = (
456
+ "TinyFish Browser returned no session ID; a session it may still "
457
+ "have created cannot be closed and will expire via the server's "
458
+ "1h inactivity timeout."
459
+ )
460
+ except Exception as exc: # noqa: BLE001 - diagnostics must not crash
461
+ status["live_paid_error"] = (
462
+ f"TinyFish Browser session creation failed ({type(exc).__name__})."
463
+ )
464
+
465
+ cleanup_ok = False
466
+ if session_id:
467
+ try:
468
+ cleanup_ok = bool(
469
+ rest_client.close_browser_session(session_id, api_key=api_key)
470
+ )
471
+ except Exception: # noqa: BLE001 - cleanup result is reported below
472
+ cleanup_ok = False
473
+ if not cleanup_ok:
474
+ status["live_paid_error"] = "TinyFish Browser session cleanup failed."
475
+
476
+ status["live_paid_browser_cleanup_ok"] = cleanup_ok
477
+ status["live_paid_browser_ok"] = bool(session_id and cleanup_ok)
478
+ status["live_paid_ok"] = status["live_paid_browser_ok"]
479
+ return bool(status["live_paid_ok"])
480
+
481
+
482
+ def cmd_credits(args: argparse.Namespace) -> int:
483
+ subcommand = getattr(args, "credits_command", None) or "status"
484
+ config = _load_config()
485
+ if subcommand == "status":
486
+ payload = {"credit_policy": credit_policy_summary(config)}
487
+ if getattr(args, "json", False):
488
+ print(json.dumps(payload, indent=2, sort_keys=True))
489
+ else:
490
+ print("TinyFish credit policies")
491
+ for feature in CREDIT_FEATURES:
492
+ print(f" {feature}: {payload['credit_policy'][feature]}")
493
+ return 0
494
+ if subcommand == "set":
495
+ feature = normalize_feature(args.feature)
496
+ policy = normalize_policy(args.policy)
497
+ set_credit_policy(config, feature, policy)
498
+ _save_config(config)
499
+ print(f"Set TinyFish {feature} credit policy to {policy}.")
500
+ return 0
501
+ if subcommand == "reset":
502
+ for feature in CREDIT_FEATURES:
503
+ set_credit_policy(config, feature, DEFAULT_CREDIT_POLICY)
504
+ _save_config(config)
505
+ print(
506
+ f"Reset TinyFish credit policies to {DEFAULT_CREDIT_POLICY} (the default)."
507
+ )
508
+ return 0
509
+ print("Usage: hermes tinyfish credits {status,set,reset}", file=sys.stderr)
510
+ return 2
511
+
512
+
513
+ def _policy_effect_line(policy: str) -> str:
514
+ if policy == "allow":
515
+ return "Policy 'allow': sessions start without per-session approval."
516
+ if policy == "deny":
517
+ return "Policy 'deny': TinyFish browser sessions are blocked."
518
+ return "Policy 'request': each TinyFish browser session asks for approval."
519
+
520
+
521
+ def cmd_browser(args: argparse.Namespace) -> int:
522
+ subcommand = getattr(args, "browser_command", None) or "status"
523
+ config = _load_config()
524
+ current = browser_cloud_provider(config)
525
+ policy = credit_policy_summary(config)["browser"]
526
+ if subcommand == "enable":
527
+ if current == "tinyfish":
528
+ print("TinyFish is already Hermes' browser cloud provider.")
529
+ else:
530
+ section = config.setdefault("browser", {})
531
+ if not isinstance(section, dict):
532
+ section = {}
533
+ config["browser"] = section
534
+ section["cloud_provider"] = "tinyfish"
535
+ _save_config(config)
536
+ print("Set browser.cloud_provider to tinyfish.")
537
+ print(_policy_effect_line(policy))
538
+ return 0
539
+ if subcommand == "disable":
540
+ if current != "tinyfish":
541
+ print("TinyFish is not Hermes' browser cloud provider; nothing to change.")
542
+ return 0
543
+ section = config.get("browser")
544
+ if isinstance(section, dict):
545
+ section.pop("cloud_provider", None)
546
+ _save_config(config)
547
+ print("Removed browser.cloud_provider (was tinyfish).")
548
+ return 0
549
+ if subcommand == "status":
550
+ print(f"browser.cloud_provider: {current or 'unset'}")
551
+ print(f"tinyfish.credit_policy.browser: {policy}")
552
+ print(_policy_effect_line(policy))
553
+ return 0
554
+ print("Usage: hermes tinyfish browser {enable,disable,status}", file=sys.stderr)
555
+ return 2
556
+
557
+
558
+ def _api_key_or_error(*, as_json: bool = False) -> str | None:
559
+ api_key = _api_key()
560
+ if not api_key:
561
+ if as_json:
562
+ payload = {
563
+ "success": False,
564
+ "wallet_available": None,
565
+ "error": MISSING_KEY_ERROR,
566
+ }
567
+ print(json.dumps(payload, indent=2, sort_keys=True))
568
+ else:
569
+ print(MISSING_KEY_ERROR, file=sys.stderr)
570
+ return None
571
+ return api_key
572
+
573
+
574
+ def _wallet_amount(value: Any, currency: Any) -> str:
575
+ amount = str(value) if value is not None else "unavailable"
576
+ code = str(currency or "").strip()
577
+ if amount == "unavailable":
578
+ return amount
579
+ if code == "USD":
580
+ return f"${amount} USD"
581
+ return f"{amount} {code}".rstrip()
582
+
583
+
584
+ def _wallet_text_lines(wallet: dict[str, Any]) -> list[str]:
585
+ currency = wallet.get("currency")
586
+ balance = _wallet_amount(wallet.get("available_balance"), currency)
587
+ lines = [
588
+ "TinyFish usage",
589
+ f" Available balance: {balance}",
590
+ f" As of: {wallet.get('as_of') or 'unavailable'}",
591
+ ]
592
+
593
+ auto_reload = wallet.get("auto_reload")
594
+ if auto_reload is None:
595
+ lines.append(" Auto-reload: unavailable")
596
+ elif isinstance(auto_reload, dict):
597
+ state = str(auto_reload.get("state") or "unknown").replace("_", " ")
598
+ if state == "unconfigured":
599
+ lines.append(" Auto-reload: not configured")
600
+ else:
601
+ threshold = _wallet_amount(auto_reload.get("threshold"), currency)
602
+ recharge_to = _wallet_amount(auto_reload.get("recharge_to"), currency)
603
+ lines.append(
604
+ f" Auto-reload: {state} (at {threshold}, recharge to {recharge_to})"
605
+ )
606
+ else:
607
+ lines.append(" Auto-reload: invalid response")
608
+
609
+ pending = wallet.get("pending_top_up")
610
+ if pending is None:
611
+ lines.append(" Pending top-up: none")
612
+ elif isinstance(pending, dict):
613
+ amount = _wallet_amount(pending.get("amount"), currency)
614
+ lines.append(
615
+ f" Pending top-up: {amount} since {pending.get('started_at') or 'unknown'}"
616
+ )
617
+ else:
618
+ lines.append(" Pending top-up: invalid response")
619
+
620
+ rates = wallet.get("rates")
621
+ meters = rates.get("meters") if isinstance(rates, dict) else None
622
+ if isinstance(meters, list) and meters:
623
+ lines.append(" Rates:")
624
+ for meter in meters:
625
+ if not isinstance(meter, dict):
626
+ continue
627
+ label = meter.get("label") or meter.get("product_id") or "Unnamed meter"
628
+ amount = _wallet_amount(meter.get("unit_amount"), meter.get("currency"))
629
+ per = f" per {meter['per']}" if meter.get("per") else ""
630
+ lines.append(f" {label}: {amount}{per}")
631
+ elif rates is None:
632
+ lines.append(" Rates: unavailable")
633
+ else:
634
+ lines.append(" Rates: none")
635
+
636
+ return lines
637
+
638
+
639
+ def cmd_usage(args: argparse.Namespace) -> int:
640
+ api_key = _api_key_or_error(as_json=bool(getattr(args, "json", False)))
641
+ if not api_key:
642
+ return 1
643
+ payload: dict[str, Any]
644
+ try:
645
+ wallet = rest_client.wallet(api_key=api_key)
646
+ except rest_client.TinyFishWalletNotFound:
647
+ payload = {
648
+ "success": True,
649
+ "wallet_available": False,
650
+ "reason": "legacy_billing_or_no_metronome_customer",
651
+ "message": (
652
+ "This account uses legacy billing or does not have a Metronome "
653
+ "wallet yet."
654
+ ),
655
+ "billing_url": API_KEY_URL,
656
+ }
657
+ text_lines = [
658
+ "TinyFish usage",
659
+ " Wallet balance unavailable.",
660
+ f" {payload['message']}",
661
+ f" Billing: {API_KEY_URL}",
662
+ ]
663
+ except Exception as exc: # noqa: BLE001 - CLI reports the failure and exits 1
664
+ payload = {"success": False, "wallet_available": None, "error": str(exc)}
665
+ text_lines = ["TinyFish usage", f" Error: {exc}"]
666
+ else:
667
+ if not isinstance(wallet, dict):
668
+ payload = {
669
+ "success": False,
670
+ "wallet_available": None,
671
+ "error": "TinyFish Wallet returned an invalid response",
672
+ }
673
+ text_lines = ["TinyFish usage", f" Error: {payload['error']}"]
674
+ else:
675
+ payload = {"success": True, "wallet_available": True, "wallet": wallet}
676
+ text_lines = _wallet_text_lines(wallet)
677
+ if bool(getattr(args, "json", False)):
678
+ print(json.dumps(payload, indent=2, sort_keys=True))
679
+ else:
680
+ print("\n".join(text_lines))
681
+ return 0 if payload["success"] else 1
682
+
683
+
684
+ def tinyfish_status_command(
685
+ raw_args: str,
686
+ *,
687
+ provider: TinyFishWebSearchProvider,
688
+ ) -> str:
689
+ """Serve the in-session ``/tinyfish-status`` command."""
690
+
691
+ option = (raw_args or "").strip().lower()
692
+ if option not in {"", "live"}:
693
+ return "Usage: /tinyfish-status [live]"
694
+ status = collect_status(live=option == "live", provider=provider)
695
+ return "\n".join(_status_lines(status))