hevn-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,451 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ import typer
7
+ from rich import box
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+
11
+ from hevn_cli.api import AppApi, McpApi
12
+ from hevn_cli.client import HevnError
13
+ from hevn_cli.config import get_config_value, remove_config_keys
14
+ from hevn_cli.env import site_url
15
+ from hevn_cli.output import OutputOptions, emit
16
+ from hevn_cli.render import console, money
17
+
18
+ app = typer.Typer(help="Manage the active HEVN account", no_args_is_help=True)
19
+
20
+
21
+ def _display_name(account: dict[str, Any]) -> str:
22
+ if account.get("entityName"):
23
+ return str(account["entityName"])
24
+ parts = [account.get("firstName"), account.get("middleName"), account.get("lastName")]
25
+ name = " ".join(str(part) for part in parts if part)
26
+ return name or str(account.get("email") or "-")
27
+
28
+
29
+ def _account_summary(account: dict[str, Any]) -> dict[str, Any]:
30
+ rails = account.get("fiatRails") or []
31
+ return {
32
+ "id": account.get("id"),
33
+ "email": account.get("email"),
34
+ "name": _display_name(account),
35
+ "isBusiness": account.get("isBusiness"),
36
+ "plan": account.get("plan"),
37
+ "country": account.get("country"),
38
+ "jurisdiction": account.get("jurisdiction"),
39
+ "phone": account.get("phone"),
40
+ "pushNotificationAllowed": account.get("pushNotificationAllowed"),
41
+ "fiatRails": [
42
+ {
43
+ "provider": rail.get("provider"),
44
+ "rail": rail.get("rail"),
45
+ "kycStatus": rail.get("kycStatus"),
46
+ "isKycApproved": rail.get("isKycApproved"),
47
+ }
48
+ for rail in rails
49
+ ],
50
+ "createdAt": account.get("createdAt"),
51
+ "updatedAt": account.get("updatedAt"),
52
+ }
53
+
54
+
55
+ def _kyc_summary_from_account(account: dict[str, Any]) -> dict[str, Any]:
56
+ rails = account.get("fiatRails") or []
57
+ providers = []
58
+ approved = False
59
+ primary_status = None
60
+ if isinstance(rails, list):
61
+ for rail in rails:
62
+ if not isinstance(rail, dict):
63
+ continue
64
+ status = rail.get("kycStatus")
65
+ is_approved = bool(rail.get("isKycApproved"))
66
+ provider = {
67
+ "provider": rail.get("provider"),
68
+ "rail": rail.get("rail"),
69
+ "status": status,
70
+ "isApproved": is_approved,
71
+ }
72
+ providers.append(provider)
73
+ if primary_status is None and status:
74
+ primary_status = status
75
+ if is_approved:
76
+ approved = True
77
+ primary_status = status or primary_status
78
+ return {
79
+ "status": primary_status or "not_started",
80
+ "isApproved": approved,
81
+ "providers": providers,
82
+ }
83
+
84
+
85
+ def _has_app_auth() -> bool:
86
+ return bool(os.getenv("HEVN_API_KEY") or get_config_value("api_key"))
87
+
88
+
89
+ def _has_mcp_auth() -> bool:
90
+ return bool(os.getenv("HEVN_MCP_KEY") or get_config_value("mcp_key"))
91
+
92
+
93
+ def _mcp_status() -> dict[str, Any]:
94
+ if not _has_mcp_auth():
95
+ return {
96
+ "configured": False,
97
+ "available": False,
98
+ "balance": None,
99
+ "allowance": None,
100
+ "error": "MCP key is not configured",
101
+ }
102
+ try:
103
+ data = McpApi().balance()
104
+ except HevnError as exc:
105
+ return {
106
+ "configured": True,
107
+ "available": False,
108
+ "balance": None,
109
+ "allowance": None,
110
+ "error": exc.message,
111
+ }
112
+ return {
113
+ "configured": True,
114
+ "available": True,
115
+ "email": data.get("email"),
116
+ "address": data.get("address"),
117
+ "balance": data.get("balance"),
118
+ "allowance": data.get("remaining"),
119
+ "error": None,
120
+ }
121
+
122
+
123
+ def _app_account_status() -> tuple[dict[str, Any], dict[str, Any]]:
124
+ configured = _has_app_auth()
125
+ if not configured:
126
+ return (
127
+ {
128
+ "id": None,
129
+ "email": None,
130
+ "firstName": None,
131
+ "middleName": None,
132
+ "lastName": None,
133
+ "entityName": None,
134
+ "isBusiness": None,
135
+ "plan": None,
136
+ "country": None,
137
+ "fiatRails": [],
138
+ },
139
+ {
140
+ "configured": False,
141
+ "available": False,
142
+ "error": "JWT is not configured. Run `hevn login` first or set HEVN_API_KEY.",
143
+ },
144
+ )
145
+ try:
146
+ account = AppApi().current_user()
147
+ except HevnError as exc:
148
+ return (
149
+ {
150
+ "id": None,
151
+ "email": None,
152
+ "firstName": None,
153
+ "middleName": None,
154
+ "lastName": None,
155
+ "entityName": None,
156
+ "isBusiness": None,
157
+ "plan": None,
158
+ "country": None,
159
+ "fiatRails": [],
160
+ },
161
+ {
162
+ "configured": True,
163
+ "available": False,
164
+ "error": exc.message,
165
+ },
166
+ )
167
+ return (
168
+ account,
169
+ {
170
+ "configured": True,
171
+ "available": True,
172
+ "error": None,
173
+ },
174
+ )
175
+
176
+
177
+ def _balance_total_usd(balance: dict[str, Any]) -> float | None:
178
+ accounts = balance.get("accounts") or []
179
+ if not isinstance(accounts, list):
180
+ return None
181
+ total = 0.0
182
+ for account in accounts:
183
+ if not isinstance(account, dict):
184
+ continue
185
+ value = account.get("balanceUsd")
186
+ if value is None:
187
+ value = account.get("balance_usd")
188
+ if value is None:
189
+ continue
190
+ total += float(value)
191
+ return total
192
+
193
+
194
+ def _app_balance_status(jwt: dict[str, Any]) -> dict[str, Any]:
195
+ if not jwt.get("configured"):
196
+ return {
197
+ "available": False,
198
+ "totalUsd": None,
199
+ "error": "JWT is not configured.",
200
+ }
201
+ if not jwt.get("available"):
202
+ return {
203
+ "available": False,
204
+ "totalUsd": None,
205
+ "error": jwt.get("error"),
206
+ }
207
+ try:
208
+ data = AppApi().balance()
209
+ except HevnError as exc:
210
+ return {
211
+ "available": False,
212
+ "totalUsd": None,
213
+ "error": exc.message,
214
+ }
215
+ return {
216
+ "available": True,
217
+ "totalUsd": _balance_total_usd(data),
218
+ "accounts": data.get("accounts") or [],
219
+ "error": None,
220
+ }
221
+
222
+
223
+ def _auth_status(jwt: dict[str, Any], mcp: dict[str, Any]) -> dict[str, Any]:
224
+ return {
225
+ "jwt": jwt,
226
+ "mcp": mcp,
227
+ }
228
+
229
+
230
+ def _same_email(left: str | None, right: str | None) -> bool:
231
+ return bool(left and right and left.strip().lower() == right.strip().lower())
232
+
233
+
234
+ def _assert_auth_same_user(account: dict[str, Any], jwt: dict[str, Any], mcp: dict[str, Any]) -> None:
235
+ if not (jwt.get("available") and mcp.get("available")):
236
+ return
237
+ jwt_email = account.get("email")
238
+ mcp_email = mcp.get("email")
239
+ if not _same_email(str(jwt_email) if jwt_email else None, str(mcp_email) if mcp_email else None):
240
+ remove_config_keys("mcp_key")
241
+ raise HevnError(f"JWT and MCP key belong to different users: jwt={jwt_email or '-'} mcp={mcp_email or '-'}")
242
+
243
+
244
+ def _account_with_auth(
245
+ account: dict[str, Any],
246
+ jwt: dict[str, Any],
247
+ mcp: dict[str, Any],
248
+ balance: dict[str, Any],
249
+ *,
250
+ main: bool,
251
+ ) -> dict[str, Any]:
252
+ summary = _account_summary(account)
253
+ summary["main"] = main
254
+ summary["auth"] = _auth_status(jwt, mcp)
255
+ summary["balanceUsd"] = balance.get("totalUsd")
256
+ summary["mcpAllowance"] = mcp.get("allowance")
257
+ summary["mcpBalance"] = mcp.get("balance")
258
+ summary["kyc"] = _kyc_summary_from_account(account)
259
+ return summary
260
+
261
+
262
+ def _status_label(status: dict[str, Any]) -> str:
263
+ if status.get("available"):
264
+ return "ok"
265
+ if status.get("configured"):
266
+ return "configured"
267
+ return "missing"
268
+
269
+
270
+ def _print_accounts_table(accounts: list[dict[str, Any]]) -> None:
271
+ table = Table(title=f"Accounts ({len(accounts)})", box=box.SIMPLE_HEAVY)
272
+ table.add_column("ID", overflow="fold")
273
+ table.add_column("Main")
274
+ table.add_column("Email")
275
+ table.add_column("Name")
276
+ table.add_column("Type")
277
+ table.add_column("Plan")
278
+ table.add_column("Country")
279
+ table.add_column("JWT")
280
+ table.add_column("MCP")
281
+ table.add_column("Balance")
282
+ table.add_column("Allowance")
283
+ for account in accounts:
284
+ auth = account.get("auth") or {}
285
+ jwt = auth.get("jwt") or {}
286
+ mcp = auth.get("mcp") or {}
287
+ table.add_row(
288
+ str(account.get("id") or "-"),
289
+ "✓" if account.get("main") else "",
290
+ str(account.get("email") or "-"),
291
+ str(account.get("name") or _display_name(account)),
292
+ "business" if account.get("isBusiness") else "personal",
293
+ str(account.get("plan") or "-"),
294
+ str(account.get("country") or "-"),
295
+ _status_label(jwt),
296
+ _status_label(mcp),
297
+ money(account.get("balanceUsd"), "USD") if account.get("balanceUsd") is not None else "-",
298
+ money(mcp.get("allowance"), "USDC") if mcp.get("allowance") is not None else "-",
299
+ )
300
+ console.print(table)
301
+
302
+
303
+ def _print_account_panel(data: dict[str, Any]) -> None:
304
+ auth = data.get("auth") or {}
305
+ jwt = auth.get("jwt") or {}
306
+ mcp = auth.get("mcp") or {}
307
+ kyc = data.get("kyc") or {}
308
+ lines = [
309
+ f"[bold]{data.get('name') or '-'}[/bold]",
310
+ f"ID: {data.get('id') or '-'}",
311
+ f"Email: {data.get('email') or '-'}",
312
+ f"Type: {'business' if data.get('isBusiness') else 'personal'}",
313
+ f"Plan: {data.get('plan') or '-'}",
314
+ f"Country: {data.get('country') or '-'}",
315
+ f"Jurisdiction: {data.get('jurisdiction') or '-'}",
316
+ f"KYC: {kyc.get('status') or '-'}",
317
+ f"Balance: {money(data.get('balanceUsd'), 'USD')}",
318
+ f"JWT: {_status_label(jwt)}",
319
+ f"MCP: {_status_label(mcp)}",
320
+ ]
321
+ console.print(Panel.fit("\n".join(lines), title="Active Account", border_style="cyan"))
322
+
323
+
324
+ def _print_kyc_status(data: dict[str, Any]) -> None:
325
+ kyc_link = data.get("kyc_link") or data.get("kycLink") or data.get("kycUrl")
326
+ support_link = data.get("contact_support_link") or data.get("contactSupportLink") or f"{site_url()}/chat"
327
+ lines = [
328
+ f"KYC link: {kyc_link or '-'}",
329
+ f"Status: {data.get('status') or data.get('kycStatus') or '-'}",
330
+ f"Contact support link: {support_link}",
331
+ ]
332
+ console.print(Panel.fit("\n".join(lines), title="KYC Status", border_style="cyan"))
333
+
334
+
335
+ def _kyc_output(data: dict[str, Any]) -> dict[str, Any]:
336
+ return {
337
+ "kyc_link": data.get("kycLink") or data.get("kycUrl"),
338
+ "status": data.get("status") or data.get("kycStatus"),
339
+ "contact_support_link": (data.get("contactSupportLink") or f"{site_url()}/chat"),
340
+ }
341
+
342
+
343
+ @app.command("set")
344
+ def set_profile(
345
+ street_address: str | None = typer.Option(None, "--street-address", "--street_address"),
346
+ address_line_2: str | None = typer.Option(None, "--address-line-2", "--address_line_2"),
347
+ city: str | None = typer.Option(None, "--city"),
348
+ state: str | None = typer.Option(None, "--state"),
349
+ country: str | None = typer.Option(None, "--country"),
350
+ zip_code: str | None = typer.Option(None, "--zip"),
351
+ first_name: str | None = typer.Option(None, "--first-name", "--first_name"),
352
+ last_name: str | None = typer.Option(None, "--last-name", "--last_name"),
353
+ entity_name: str | None = typer.Option(None, "--entity-name", "--entity_name"),
354
+ raw_json: bool = typer.Option(False, "--json", help="Print raw JSON."),
355
+ yaml_output: bool = typer.Option(False, "--yaml", help="Print YAML."),
356
+ ) -> None:
357
+ address = {
358
+ "streetAddress": street_address,
359
+ "addressLine2": address_line_2,
360
+ "city": city,
361
+ "state": state,
362
+ "country": country,
363
+ "zip": zip_code,
364
+ }
365
+ clean_address = {key: value for key, value in address.items() if value is not None}
366
+ payload = {
367
+ "firstName": first_name,
368
+ "lastName": last_name,
369
+ "entityName": entity_name,
370
+ "address": clean_address or None,
371
+ }
372
+ clean_payload = {key: value for key, value in payload.items() if value is not None}
373
+ if not clean_payload:
374
+ raise HevnError("Provide at least one profile field to update.")
375
+ data = AppApi().update_profile(clean_payload)
376
+ emit(
377
+ data,
378
+ OutputOptions(raw_json, yaml_output),
379
+ lambda _: console.print("[green]Profile updated[/green]"),
380
+ )
381
+
382
+
383
+ @app.command("list")
384
+ def list_accounts(
385
+ raw_json: bool = typer.Option(False, "--json", help="Print raw JSON."),
386
+ yaml_output: bool = typer.Option(False, "--yaml", help="Print YAML."),
387
+ ) -> None:
388
+ account, jwt = _app_account_status()
389
+ mcp = _mcp_status()
390
+ _assert_auth_same_user(account, jwt, mcp)
391
+ balance = _app_balance_status(jwt)
392
+ accounts = [_account_with_auth(account, jwt, mcp, balance, main=True)]
393
+ raw_accounts = [
394
+ {
395
+ **account,
396
+ "main": True,
397
+ "auth": _auth_status(jwt, mcp),
398
+ "balance": balance,
399
+ "balanceUsd": balance.get("totalUsd"),
400
+ "mcpAllowance": mcp.get("allowance"),
401
+ "mcpBalance": mcp.get("balance"),
402
+ }
403
+ ]
404
+ data = {"accounts": raw_accounts, "total": len(raw_accounts)}
405
+ ai_data = {
406
+ "accounts": accounts,
407
+ "total": len(accounts),
408
+ }
409
+ emit(
410
+ data if raw_json else ai_data,
411
+ OutputOptions(raw_json, yaml_output),
412
+ lambda _: _print_accounts_table(accounts),
413
+ )
414
+
415
+
416
+ @app.command("get")
417
+ def get_account(
418
+ raw_json: bool = typer.Option(False, "--json", help="Print raw JSON."),
419
+ yaml_output: bool = typer.Option(False, "--yaml", help="Print YAML."),
420
+ ) -> None:
421
+ account, jwt = _app_account_status()
422
+ mcp = _mcp_status()
423
+ _assert_auth_same_user(account, jwt, mcp)
424
+ balance = _app_balance_status(jwt)
425
+ data = _account_with_auth(account, jwt, mcp, balance, main=True)
426
+ emit(
427
+ data,
428
+ OutputOptions(raw_json, yaml_output),
429
+ _print_account_panel,
430
+ )
431
+
432
+
433
+ @app.command("kyc")
434
+ def account_kyc(
435
+ provider: str = typer.Option("swipelux", "--provider", help="KYC provider: swipelux or align."),
436
+ status_only: bool = typer.Option(False, "--status", help="Only check verification status."),
437
+ raw_json: bool = typer.Option(False, "--json", help="Print raw JSON."),
438
+ yaml_output: bool = typer.Option(False, "--yaml", help="Print YAML."),
439
+ ) -> None:
440
+ api = AppApi()
441
+ normalized_provider = provider.strip().lower()
442
+ if status_only or normalized_provider != "swipelux":
443
+ data = api.kyc_status(provider=normalized_provider)
444
+ else:
445
+ data = api.kyc_link()
446
+ data = _kyc_output(data)
447
+ emit(
448
+ data,
449
+ OutputOptions(raw_json, yaml_output),
450
+ _print_kyc_status,
451
+ )
@@ -0,0 +1,217 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import sys
5
+ import threading
6
+ import webbrowser
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ from importlib.resources import files
9
+ from typing import Any
10
+ from urllib.parse import parse_qs, urlencode, urlparse
11
+
12
+ import typer
13
+
14
+ from hevn_cli.config import remove_config_keys, save_config
15
+ from hevn_cli.env import api_url, site_url
16
+ from hevn_cli.render import console, print_json, print_yaml
17
+
18
+ _LOGIN_COMPLETE_HTML = files("hevn_cli.res").joinpath("login_complete.html").read_bytes()
19
+
20
+
21
+ def _first(params: dict[str, list[str]], *names: str) -> str | None:
22
+ for name in names:
23
+ values = params.get(name)
24
+ if values and values[0]:
25
+ return values[0]
26
+ return None
27
+
28
+
29
+ def _mask(value: str | None) -> str:
30
+ if not value:
31
+ return "-"
32
+ if len(value) <= 12:
33
+ return value[:2] + "..."
34
+ return value[:8] + "..." + value[-4:]
35
+
36
+
37
+ def _normalize_api_base_url(value: str) -> str:
38
+ parsed = urlparse(value.strip())
39
+ if not (parsed.scheme and parsed.netloc):
40
+ raise typer.BadParameter("Callback baseUrl must be an absolute URL.")
41
+ if parsed.scheme != "https" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
42
+ raise typer.BadParameter("Callback baseUrl must use HTTPS outside localhost.")
43
+ path = parsed.path.rstrip("/")
44
+ if path in {"", "/docs", "/redoc", "/openapi.json"}:
45
+ path = "/api/v1"
46
+ elif path in {"/api/v1/docs", "/api/v1/redoc", "/api/v1/openapi.json"}:
47
+ path = "/api/v1"
48
+ return f"{parsed.scheme}://{parsed.netloc}{path}"
49
+
50
+
51
+ def _trusted_callback_base_url(value: str | None) -> str | None:
52
+ if not value:
53
+ return None
54
+ normalized = _normalize_api_base_url(value)
55
+ expected = _normalize_api_base_url(api_url())
56
+ if normalized != expected:
57
+ raise typer.BadParameter(
58
+ f"Callback baseUrl is not allowed for this environment: {normalized}"
59
+ )
60
+ return normalized
61
+
62
+
63
+ def _print_login_banner() -> None:
64
+ console.print(
65
+ "[bold cyan]"
66
+ " _ _ ________ ___ _\n"
67
+ " | | | | ____\\ \\ / / \\ | |\n"
68
+ " | |__| | |__ \\ \\ / /| \\| |\n"
69
+ " | __ | __| \\ \\/ / | . ` |\n"
70
+ " | | | | |____ \\ / | |\\ |\n"
71
+ " |_| |_|______| \\/ |_| \\_|"
72
+ "[/bold cyan]"
73
+ )
74
+ console.print("\n\n\n\n\n", end="")
75
+
76
+
77
+ def login(
78
+ port: int = typer.Option(0, "--port", min=0, max=65535, help="Local callback port. 0 picks a free port."),
79
+ timeout: int = typer.Option(180, "--timeout", min=10, help="Seconds to wait for browser callback."),
80
+ no_open: bool = typer.Option(False, "--no-open", help="Print auth URL without opening the browser."),
81
+ raw_json: bool = typer.Option(False, "--json", help="Print raw JSON."),
82
+ yaml_output: bool = typer.Option(False, "--yaml", help="Print YAML."),
83
+ ) -> None:
84
+ if not raw_json and not yaml_output:
85
+ _print_login_banner()
86
+
87
+ state = secrets.token_urlsafe(24)
88
+ received: dict[str, Any] = {}
89
+ done = threading.Event()
90
+
91
+ class CallbackHandler(BaseHTTPRequestHandler):
92
+ def log_message(self, format: str, *args: Any) -> None:
93
+ return
94
+
95
+ def do_GET(self) -> None:
96
+ parsed = urlparse(self.path)
97
+ params = parse_qs(parsed.query)
98
+ if _first(params, "state") != state:
99
+ self.send_response(400)
100
+ self.end_headers()
101
+ self.wfile.write(b"Invalid state")
102
+ return
103
+
104
+ jwt = _first(params, "jwt", "token", "accessToken", "access_token")
105
+ mcp_key = _first(params, "mcp", "mcpKey", "mcp_key", "mcpToken", "mcp_token")
106
+ try:
107
+ api_base_url = _trusted_callback_base_url(_first(params, "baseUrl", "base_url"))
108
+ except typer.BadParameter as exc:
109
+ self.send_response(400)
110
+ self.end_headers()
111
+ self.wfile.write(str(exc).encode("utf-8"))
112
+ return
113
+
114
+ if not jwt and not mcp_key:
115
+ self.send_response(400)
116
+ self.end_headers()
117
+ self.wfile.write(b"Missing jwt or mcp token")
118
+ return
119
+
120
+ received.update(
121
+ {
122
+ "api_key": jwt,
123
+ "mcp_key": mcp_key,
124
+ "base_url": api_base_url,
125
+ }
126
+ )
127
+ self.send_response(200)
128
+ self.send_header("Content-Type", "text/html; charset=utf-8")
129
+ self.send_header("Cache-Control", "no-store")
130
+ self.end_headers()
131
+ self.wfile.write(_LOGIN_COMPLETE_HTML)
132
+ done.set()
133
+
134
+ server = ThreadingHTTPServer(("127.0.0.1", port), CallbackHandler)
135
+ actual_port = server.server_address[1]
136
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
137
+ thread.start()
138
+
139
+ query = urlencode({"port": actual_port, "state": state})
140
+ auth_url = f"{site_url()}/cli-auth?{query}"
141
+ if raw_json or yaml_output:
142
+ print(f"Open this URL to authorize HEVN CLI: {auth_url}", file=sys.stderr)
143
+ else:
144
+ console.print(f"Open this URL to authorize HEVN CLI:\n[bold cyan]{auth_url}[/bold cyan]")
145
+
146
+ if not no_open:
147
+ webbrowser.open(auth_url)
148
+
149
+ try:
150
+ if not done.wait(timeout):
151
+ raise typer.BadParameter(f"Timed out waiting for callback on port {actual_port}")
152
+ finally:
153
+ server.shutdown()
154
+ server.server_close()
155
+ thread.join(timeout=2)
156
+
157
+ saved = save_config(
158
+ {
159
+ "api_key": received.get("api_key"),
160
+ "mcp_key": received.get("mcp_key"),
161
+ "base_url": received.get("base_url"),
162
+ }
163
+ )
164
+ result = {
165
+ "ok": True,
166
+ "authUrl": auth_url,
167
+ "configPath": str(saved),
168
+ "apiKey": _mask(received.get("api_key")),
169
+ "mcpKey": _mask(received.get("mcp_key")),
170
+ "baseUrl": received.get("base_url"),
171
+ }
172
+ if raw_json:
173
+ print_json(result)
174
+ elif yaml_output:
175
+ print_yaml(result)
176
+ else:
177
+ console.print(f"Login complete\nConfig: [bold]{saved}[/bold]\nJWT: {result['apiKey']}\nMCP: {result['mcpKey']}")
178
+
179
+
180
+ def set_mcp_key(
181
+ key: str | None = typer.Argument(None, help="MCP key. If omitted, prompts securely."),
182
+ raw_json: bool = typer.Option(False, "--json", help="Print raw JSON."),
183
+ yaml_output: bool = typer.Option(False, "--yaml", help="Print YAML."),
184
+ ) -> None:
185
+ key = key or typer.prompt("MCP key", hide_input=True).strip()
186
+ if not key:
187
+ raise typer.BadParameter("MCP key is required.")
188
+ saved = save_config({"mcp_key": key})
189
+ result = {
190
+ "ok": True,
191
+ "configPath": str(saved),
192
+ "mcpKey": _mask(key),
193
+ }
194
+ if raw_json:
195
+ print_json(result)
196
+ elif yaml_output:
197
+ print_yaml(result)
198
+ else:
199
+ console.print(f"MCP key saved\nConfig: [bold]{saved}[/bold]\nMCP: {result['mcpKey']}")
200
+
201
+
202
+ def logout(
203
+ raw_json: bool = typer.Option(False, "--json", help="Print raw JSON."),
204
+ yaml_output: bool = typer.Option(False, "--yaml", help="Print YAML."),
205
+ ) -> None:
206
+ saved = remove_config_keys("api_key", "mcp_key", "base_url", "device_id")
207
+ result = {
208
+ "ok": True,
209
+ "configPath": str(saved),
210
+ "removed": ["api_key", "mcp_key", "base_url", "device_id"],
211
+ }
212
+ if raw_json:
213
+ print_json(result)
214
+ elif yaml_output:
215
+ print_yaml(result)
216
+ else:
217
+ console.print(f"Logged out\nConfig: [bold]{saved}[/bold]")