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.
- hevn_cli/__init__.py +8 -0
- hevn_cli/api/__init__.py +6 -0
- hevn_cli/api/app.py +163 -0
- hevn_cli/api/base.py +40 -0
- hevn_cli/api/mcp.py +50 -0
- hevn_cli/api/public.py +16 -0
- hevn_cli/client.py +123 -0
- hevn_cli/commands/__init__.py +1 -0
- hevn_cli/commands/account.py +451 -0
- hevn_cli/commands/auth.py +217 -0
- hevn_cli/commands/balance.py +237 -0
- hevn_cli/commands/banks.py +233 -0
- hevn_cli/commands/cards.py +442 -0
- hevn_cli/commands/contacts.py +444 -0
- hevn_cli/commands/contractors.py +224 -0
- hevn_cli/commands/contracts.py +528 -0
- hevn_cli/commands/invoices.py +676 -0
- hevn_cli/commands/status.py +180 -0
- hevn_cli/commands/transfer.py +494 -0
- hevn_cli/config.py +73 -0
- hevn_cli/env.py +75 -0
- hevn_cli/formatters/__init__.py +1 -0
- hevn_cli/formatters/invoices.py +61 -0
- hevn_cli/main.py +167 -0
- hevn_cli/normalize.py +24 -0
- hevn_cli/output.py +26 -0
- hevn_cli/parsing.py +57 -0
- hevn_cli/progress.py +52 -0
- hevn_cli/prompts.py +54 -0
- hevn_cli/render.py +54 -0
- hevn_cli/res/__init__.py +0 -0
- hevn_cli/res/login_complete.html +11 -0
- hevn_cli-0.1.0.dist-info/METADATA +189 -0
- hevn_cli-0.1.0.dist-info/RECORD +37 -0
- hevn_cli-0.1.0.dist-info/WHEEL +4 -0
- hevn_cli-0.1.0.dist-info/entry_points.txt +3 -0
- hevn_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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]")
|