tmp-nepher-cli 0.2.5__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,646 @@
1
+ """account command group — login, API keys, and coldkey registration.
2
+
3
+ All commands talk to the account backend (api.nepher.ai/account).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ import json
10
+ import os
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ from typing import Any
17
+
18
+ import click
19
+ import httpx
20
+ from rich.console import Console
21
+ from rich.table import Table
22
+
23
+ from nepher_cli.config import ACCOUNT_BACKEND
24
+ from nepher_cli.core.credentials import (
25
+ clear_credentials,
26
+ get_auth_headers,
27
+ get_stored_api_key,
28
+ load_credentials,
29
+ save_credentials,
30
+ whoami_from_cache,
31
+ )
32
+ from nepher_cli.core.http import parse_error_body, request_json
33
+
34
+ console = Console(stderr=True)
35
+
36
+ BTCLI_SIGN_TIMEOUT_SECONDS = 120
37
+
38
+ _WALLET_INSTALL_HINT = (
39
+ "Install the wallet library:\n"
40
+ " [bold]pip install bittensor-wallet[/bold]\n"
41
+ " [bold]pip install \"nepher-cli[bittensor]\"[/bold]"
42
+ )
43
+
44
+ _SCALECODEC_CONFLICT_HINT = (
45
+ "[red]btcli cannot start[/red] — [bold]scalecodec[/bold] (py-scale-codec) and "
46
+ "[bold]cyscale[/bold] are both installed and share the same Python namespace.\n\n"
47
+ "This is a known Bittensor CLI conflict. Fix it in this venv:\n\n"
48
+ " [bold]pip uninstall scalecodec cyscale bt-decode -y[/bold]\n"
49
+ " [bold]pip install -U cyscale --force-reinstall[/bold]\n\n"
50
+ "If [bold]btcli --version[/bold] still fails:\n\n"
51
+ " [bold]pip uninstall scalecodec cyscale bt-decode bittensor bittensor-cli -y[/bold]\n"
52
+ " [bold]pip install -U \"bittensor[cli]\" --force-reinstall[/bold]\n\n"
53
+ "npcli itself only needs [bold]bittensor-wallet[/bold] to sign. Prefer:\n"
54
+ " [bold]pip install bittensor-wallet[/bold]"
55
+ )
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Internal helpers
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ def _require_auth(api_key: str | None) -> dict[str, str]:
63
+ headers = get_auth_headers(api_key)
64
+ if not headers:
65
+ console.print("[yellow]Not logged in.[/yellow] Run [bold]npcli account login[/bold] first.")
66
+ raise SystemExit(1)
67
+ return headers
68
+
69
+
70
+ def _print_user(user: dict[str, Any]) -> None:
71
+ for label, key in [("Name", "fullname"), ("Email", "email"), ("Role", "role"), ("Status", "status")]:
72
+ val = user.get(key)
73
+ if val:
74
+ console.print(f" {label}: {val}")
75
+ coldkey = user.get("coldkey")
76
+ if coldkey:
77
+ console.print(f" Coldkey: {coldkey}")
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Coldkey core logic
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ def _api_paths(base: str) -> tuple[str, str]:
86
+ b = base.rstrip("/")
87
+ return (
88
+ f"{b}/api/v1/account/coldkey/challenge",
89
+ f"{b}/api/v1/account/coldkey/verify",
90
+ )
91
+
92
+
93
+ def validate_api_key_format(api_key: str) -> None:
94
+ if not api_key.startswith("nepher_"):
95
+ console.print(
96
+ "[red]invalid api key format[/red] — keys must start with [bold]nepher_[/bold]. "
97
+ "Copy the key from your Nepher account settings."
98
+ )
99
+ raise SystemExit(1)
100
+
101
+
102
+ def _extract_btcli_payload(stdout_text: str, original_message: str) -> dict[str, Any] | None:
103
+ """Parse btcli output across json/dict variants and normalize keys."""
104
+ data: dict[str, Any] | None = None
105
+
106
+ try:
107
+ parsed = json.loads(stdout_text or "{}")
108
+ if isinstance(parsed, dict):
109
+ data = parsed
110
+ except json.JSONDecodeError:
111
+ data = None
112
+
113
+ if data is None:
114
+ try:
115
+ parsed = ast.literal_eval(stdout_text.strip())
116
+ if isinstance(parsed, dict):
117
+ data = parsed
118
+ except (SyntaxError, ValueError):
119
+ data = None
120
+
121
+ if data is None:
122
+ matches = list(re.finditer(r"\{.*?\}", stdout_text, flags=re.DOTALL))
123
+ for m in reversed(matches):
124
+ blob = m.group(0)
125
+ try:
126
+ parsed = json.loads(blob)
127
+ except json.JSONDecodeError:
128
+ try:
129
+ parsed = ast.literal_eval(blob)
130
+ except (SyntaxError, ValueError):
131
+ continue
132
+ if isinstance(parsed, dict):
133
+ data = parsed
134
+ break
135
+
136
+ if data is None:
137
+ # Fallback for btcli text that mixes prompts and wraps values across lines.
138
+ sig_m = re.search(
139
+ r"""['"]signed_message['"]\s*:\s*['"]([0-9a-fA-F\s]+)['"]""",
140
+ stdout_text,
141
+ flags=re.DOTALL,
142
+ )
143
+ addr_m = re.search(
144
+ r"""['"]signer_address['"]\s*:\s*['"]([1-9A-HJ-NP-Za-km-z]+)['"]""",
145
+ stdout_text,
146
+ flags=re.DOTALL,
147
+ )
148
+ if sig_m and addr_m:
149
+ # Some terminal outputs insert hard wraps in long hex payloads.
150
+ signed_message = re.sub(r"\s+", "", sig_m.group(1))
151
+ data = {
152
+ "signed_message": signed_message,
153
+ "signer_address": addr_m.group(1).strip(),
154
+ }
155
+
156
+ if not data:
157
+ return None
158
+
159
+ if "signature" not in data and "signed_message" in data:
160
+ data["signature"] = data["signed_message"]
161
+ if "address" not in data and "signer_address" in data:
162
+ data["address"] = data["signer_address"]
163
+ if "message" not in data:
164
+ data["message"] = original_message
165
+ return data
166
+
167
+
168
+ def _installed_distribution_names() -> set[str]:
169
+ try:
170
+ from importlib.metadata import distributions
171
+ except ImportError: # pragma: no cover
172
+ return set()
173
+ names: set[str] = set()
174
+ for dist in distributions():
175
+ raw = dist.metadata.get("Name") or dist.metadata.get("name") or ""
176
+ if raw:
177
+ names.add(raw.lower().replace("_", "-"))
178
+ return names
179
+
180
+
181
+ def has_scalecodec_cyscale_conflict(names: set[str] | None = None) -> bool:
182
+ """True when both SCALE codec packages occupy the ``scalecodec`` namespace."""
183
+ installed = names if names is not None else _installed_distribution_names()
184
+ has_legacy = "scalecodec" in installed or "py-scale-codec" in installed
185
+ return has_legacy and "cyscale" in installed
186
+
187
+
188
+ def _looks_like_scalecodec_conflict(text: str) -> bool:
189
+ low = text.lower()
190
+ return "conflict detected" in low and "scalecodec" in low and "cyscale" in low
191
+
192
+
193
+ def _signature_to_hex(sig: Any) -> str:
194
+ if isinstance(sig, (bytes, bytearray)):
195
+ return bytes(sig).hex()
196
+ text = str(sig).strip()
197
+ if text.startswith(("0x", "0X")):
198
+ return text[2:]
199
+ return text
200
+
201
+
202
+ def _sign_with_bittensor_wallet(wallet_name: str, message: str) -> dict[str, Any] | None:
203
+ """Sign with ``bittensor_wallet`` (no btcli / ASI import). None if not installed."""
204
+ try:
205
+ from bittensor_wallet import Wallet # type: ignore[import-untyped]
206
+ except ImportError:
207
+ return None
208
+ except RuntimeError as e:
209
+ if _looks_like_scalecodec_conflict(str(e)):
210
+ console.print(_SCALECODEC_CONFLICT_HINT)
211
+ raise SystemExit(1) from e
212
+ raise
213
+
214
+ wallet = Wallet(name=wallet_name)
215
+ if not wallet.coldkey_file.exists_on_device():
216
+ console.print(
217
+ f"[red]Coldkey not found[/red] for wallet [bold]{wallet_name}[/bold]. "
218
+ "Check the name with [bold]btcli wallet list[/bold]."
219
+ )
220
+ raise SystemExit(1)
221
+
222
+ console.print("[dim]Enter the coldkey password if prompted.[/dim]")
223
+ try:
224
+ keypair = wallet.coldkey
225
+ except Exception as e:
226
+ console.print(
227
+ f"[red]Could not unlock coldkey[/red] for wallet [bold]{wallet_name}[/bold]: {e}"
228
+ )
229
+ raise SystemExit(1) from e
230
+
231
+ sig_hex = _signature_to_hex(keypair.sign(message.encode("utf-8")))
232
+ return {
233
+ "message": message,
234
+ "address": keypair.ss58_address,
235
+ "signature": sig_hex,
236
+ }
237
+
238
+
239
+ def sign_coldkey_challenge(wallet_name: str, message: str) -> dict[str, Any]:
240
+ """Sign a coldkey challenge with bittensor_wallet, falling back to btcli."""
241
+ signed = _sign_with_bittensor_wallet(wallet_name, message)
242
+ if signed is not None:
243
+ return signed
244
+ return run_btcli_sign(wallet_name, message)
245
+
246
+
247
+ def run_btcli_sign(wallet_name: str, message: str) -> dict[str, Any]:
248
+ """Run btcli wallet sign; inherit stdin/stderr so password prompts work."""
249
+ btcli = shutil.which("btcli")
250
+ if not btcli:
251
+ console.print(
252
+ "[red]bittensor-wallet not installed[/red] and [bold]btcli[/bold] is not on PATH.\n\n"
253
+ f"{_WALLET_INSTALL_HINT}"
254
+ )
255
+ raise SystemExit(1)
256
+
257
+ if has_scalecodec_cyscale_conflict():
258
+ console.print(_SCALECODEC_CONFLICT_HINT)
259
+ raise SystemExit(1)
260
+
261
+ cmd = [btcli, "wallet", "sign", "--wallet-name", wallet_name, "--message", message, "--json-output"]
262
+ try:
263
+ proc = subprocess.Popen(cmd, stdin=sys.stdin, stderr=subprocess.PIPE, stdout=subprocess.PIPE, text=False)
264
+ except OSError as e:
265
+ console.print(f"[red]btcli signing failed[/red] — could not run btcli: {e}")
266
+ raise SystemExit(1) from e
267
+
268
+ stdout_chunks: list[bytes] = []
269
+ stderr_chunks: list[bytes] = []
270
+
271
+ def _pump(pipe: Any, sink: list[bytes], echo: bool) -> None:
272
+ if pipe is None:
273
+ return
274
+ try:
275
+ fd = pipe.fileno()
276
+ while True:
277
+ chunk = os.read(fd, 1024)
278
+ if not chunk:
279
+ break
280
+ sink.append(chunk)
281
+ if echo:
282
+ sys.stderr.buffer.write(chunk)
283
+ sys.stderr.buffer.flush()
284
+ finally:
285
+ pipe.close()
286
+
287
+ t_out = threading.Thread(target=_pump, args=(proc.stdout, stdout_chunks, True), daemon=True)
288
+ t_err = threading.Thread(target=_pump, args=(proc.stderr, stderr_chunks, True), daemon=True)
289
+ t_out.start()
290
+ t_err.start()
291
+
292
+ try:
293
+ proc.wait(timeout=BTCLI_SIGN_TIMEOUT_SECONDS)
294
+ except subprocess.TimeoutExpired:
295
+ proc.kill()
296
+ proc.wait()
297
+ t_out.join(timeout=1)
298
+ t_err.join(timeout=1)
299
+ raise SystemExit(1)
300
+
301
+ t_out.join(timeout=2)
302
+ t_err.join(timeout=2)
303
+ out = b"".join(stdout_chunks).decode("utf-8", errors="replace")
304
+
305
+ err = b"".join(stderr_chunks).decode("utf-8", errors="replace")
306
+ if proc.returncode != 0:
307
+ if _looks_like_scalecodec_conflict(out + err):
308
+ console.print(_SCALECODEC_CONFLICT_HINT)
309
+ raise SystemExit(1)
310
+
311
+ data = _extract_btcli_payload(out, message)
312
+ if data is None:
313
+ raise SystemExit(1)
314
+
315
+ for key in ("message", "address", "signature"):
316
+ if key not in data:
317
+ raise SystemExit(1)
318
+ return data
319
+
320
+
321
+ def register_coldkey(wallet: str, api_key: str, base_url: str) -> int:
322
+ """Execute the coldkey challenge/sign/verify flow. Returns exit code."""
323
+ validate_api_key_format(api_key)
324
+ challenge_url, verify_url = _api_paths(base_url)
325
+
326
+ console.print("Checking your API key and registration status...")
327
+ with httpx.Client() as client:
328
+ try:
329
+ r = request_json(client, "POST", challenge_url, json_body={"api_key": api_key})
330
+ except httpx.RequestError as e:
331
+ console.print(f"[red]Unable to reach the Nepher backend[/red]. Check your network connection. ({e})")
332
+ return 1
333
+
334
+ if r.status_code == 200:
335
+ try:
336
+ body = r.json()
337
+ except json.JSONDecodeError:
338
+ console.print("[red]Unexpected response from account backend[/red] (invalid JSON).")
339
+ return 1
340
+ msg = body.get("message") if isinstance(body, dict) else None
341
+ if not msg or not isinstance(msg, str):
342
+ console.print("[red]Unexpected challenge response[/red] (missing message).")
343
+ return 1
344
+ else:
345
+ err = parse_error_body(r.text) or r.text.strip() or f"HTTP {r.status_code}"
346
+ console.print(f"[red]{err}[/red]")
347
+ return 1
348
+
349
+ console.print(f"Signing with wallet [bold]{wallet}[/bold]...")
350
+ try:
351
+ signed = sign_coldkey_challenge(wallet, msg)
352
+ except KeyboardInterrupt:
353
+ console.print("\n[yellow]Interrupted — coldkey registration was not completed.[/yellow]")
354
+ return 130
355
+
356
+ console.print("Submitting to backend...")
357
+ payload = {"api_key": api_key, "signed_payload": signed}
358
+ with httpx.Client() as client:
359
+ try:
360
+ vr = request_json(client, "POST", verify_url, json_body=payload)
361
+ except httpx.RequestError as e:
362
+ console.print(f"[red]Unable to reach the Nepher backend[/red]. Check your network connection. ({e})")
363
+ return 1
364
+
365
+ if vr.status_code == 200:
366
+ try:
367
+ vb = vr.json()
368
+ except json.JSONDecodeError:
369
+ console.print("[red]Unexpected response[/red] from verify (invalid JSON).")
370
+ return 1
371
+ if isinstance(vb, dict):
372
+ st = vb.get("status")
373
+ ck = vb.get("coldkey", "?")
374
+ replaced = vb.get("replaced") is True
375
+ if st == "registered":
376
+ console.print("[green]Coldkey updated successfully.[/green]" if replaced else "[green]Coldkey registered successfully.[/green]")
377
+ console.print(f" Coldkey: [bold]{ck}[/bold]")
378
+ return 0
379
+ if st == "already_registered":
380
+ console.print(f"[green]This coldkey is already registered on your account.[/green]\n Coldkey: [bold]{ck}[/bold]")
381
+ return 0
382
+
383
+ err = parse_error_body(vr.text) or vr.text.strip() or f"HTTP {vr.status_code}"
384
+ low = err.lower()
385
+ if "already" in low and "registered" in low:
386
+ console.print(f"[green]{err}[/green]")
387
+ return 0
388
+ console.print(f"[red]{err}[/red]")
389
+ return 1
390
+
391
+
392
+ # ---------------------------------------------------------------------------
393
+ # Click command group
394
+ # ---------------------------------------------------------------------------
395
+
396
+
397
+ @click.group("account")
398
+ def account() -> None:
399
+ """Manage your Nepher account — login, API keys, and coldkey registration."""
400
+
401
+
402
+ # ── Auth ────────────────────────────────────────────────────────────────────
403
+
404
+
405
+ @account.command("login")
406
+ @click.option(
407
+ "--api-key", "api_key",
408
+ default=None, envvar="NEPHER_API_KEY",
409
+ help="Nepher API key (nepher_...). Prompted if omitted.",
410
+ )
411
+ def cmd_login(api_key: str | None) -> None:
412
+ """Log in with a Nepher API key and store credentials locally.
413
+
414
+ Credentials are saved to ~/.nepher/credentials.json (tokens are stored in
415
+ the system keyring when available). After login, all npcli commands
416
+ authenticate automatically without requiring --api-key on every call.
417
+ """
418
+ if not api_key:
419
+ api_key = click.prompt("Nepher API key", hide_input=True)
420
+ api_key = (api_key or "").strip()
421
+
422
+ if not api_key.startswith("nepher_"):
423
+ console.print("[red]Invalid API key format[/red] — keys must start with [bold]nepher_[/bold].")
424
+ raise SystemExit(1)
425
+
426
+ console.print("Authenticating...")
427
+ url = f"{ACCOUNT_BACKEND.rstrip('/')}/api/v1/auth/cli-login"
428
+ try:
429
+ r = httpx.post(url, json={"api_key": api_key}, timeout=30.0)
430
+ except httpx.RequestError as e:
431
+ console.print(f"[red]Unable to reach the Nepher backend[/red] ({e}).")
432
+ raise SystemExit(1) from e
433
+
434
+ if r.status_code != 200:
435
+ err = parse_error_body(r.text) or r.text.strip() or f"HTTP {r.status_code}"
436
+ console.print(f"[red]{err}[/red]")
437
+ raise SystemExit(1)
438
+
439
+ try:
440
+ body = r.json()
441
+ except Exception:
442
+ console.print("[red]Unexpected response from account backend (invalid JSON).[/red]")
443
+ raise SystemExit(1)
444
+
445
+ save_credentials(
446
+ api_key=api_key,
447
+ access_token=body["access_token"],
448
+ refresh_token=body["refresh_token"],
449
+ expires_in=body.get("expires_in", 86400),
450
+ user=body.get("user", {}),
451
+ )
452
+
453
+ user = body.get("user", {})
454
+ console.print("[green]Logged in successfully.[/green]")
455
+ if user.get("fullname"):
456
+ console.print(f" Name: [bold]{user['fullname']}[/bold]")
457
+ if user.get("email"):
458
+ console.print(f" Email: {user['email']}")
459
+ if user.get("role"):
460
+ console.print(f" Role: {user['role']}")
461
+
462
+
463
+ @account.command("logout")
464
+ def cmd_logout() -> None:
465
+ """Clear locally stored credentials."""
466
+ clear_credentials()
467
+ console.print("[green]Logged out — credentials cleared.[/green]")
468
+
469
+
470
+ @account.command("whoami")
471
+ @click.option("--api-key", "api_key", default=None, envvar="NEPHER_API_KEY", help="Override stored credentials.")
472
+ def cmd_whoami(api_key: str | None) -> None:
473
+ """Show the currently authenticated user.
474
+
475
+ Uses cached user data when available; falls back to a live API call.
476
+ """
477
+ if not api_key:
478
+ cached = whoami_from_cache()
479
+ if cached:
480
+ console.print("[bold]Current user (cached)[/bold]")
481
+ _print_user(cached)
482
+ return
483
+
484
+ headers = _require_auth(api_key)
485
+ url = f"{ACCOUNT_BACKEND.rstrip('/')}/api/v1/users/me"
486
+ try:
487
+ r = httpx.get(url, headers=headers, timeout=30.0)
488
+ except httpx.RequestError as e:
489
+ console.print(f"[red]Unable to reach the Nepher backend[/red] ({e}).")
490
+ raise SystemExit(1) from e
491
+
492
+ if r.status_code != 200:
493
+ console.print(f"[red]{parse_error_body(r.text) or f'HTTP {r.status_code}'}[/red]")
494
+ raise SystemExit(1)
495
+
496
+ try:
497
+ user = r.json()
498
+ except Exception:
499
+ console.print("[red]Unexpected response (invalid JSON).[/red]")
500
+ raise SystemExit(1)
501
+
502
+ console.print("[bold]Current user[/bold]")
503
+ _print_user(user)
504
+
505
+
506
+ # ── API keys ─────────────────────────────────────────────────────────────────
507
+
508
+
509
+ @account.group("api-keys")
510
+ def api_keys() -> None:
511
+ """Manage Nepher API keys."""
512
+
513
+
514
+ @api_keys.command("list")
515
+ @click.option(
516
+ "--api-key",
517
+ "api_key",
518
+ default=None,
519
+ envvar="NEPHER_API_KEY",
520
+ help="Do not use for this command. Run 'npcli account login' first (list needs a JWT session).",
521
+ )
522
+ def api_keys_list(api_key: str | None) -> None:
523
+ """List your API keys."""
524
+ headers = _require_auth(api_key)
525
+ url = f"{ACCOUNT_BACKEND.rstrip('/')}/api/v1/api-keys"
526
+ try:
527
+ r = httpx.get(url, headers=headers, timeout=30.0)
528
+ except httpx.RequestError as e:
529
+ console.print(f"[red]Network error[/red]: {e}")
530
+ raise SystemExit(1) from e
531
+
532
+ if r.status_code != 200:
533
+ console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
534
+ raise SystemExit(1)
535
+
536
+ data = r.json()
537
+ keys: list[dict[str, Any]] = data if isinstance(data, list) else data.get("api_keys", [])
538
+
539
+ if not keys:
540
+ console.print("[dim]No API keys found.[/dim]")
541
+ return
542
+
543
+ table = Table(show_header=True, header_style="bold")
544
+ table.add_column("ID")
545
+ table.add_column("Name")
546
+ table.add_column("Platforms")
547
+ table.add_column("Expires")
548
+ table.add_column("Active")
549
+
550
+ for k in keys:
551
+ platforms = ", ".join(k.get("platforms") or []) or "all"
552
+ active = "[green]yes[/green]" if k.get("is_active") else "[red]no[/red]"
553
+ table.add_row(str(k.get("id", "")), k.get("name") or "", platforms, str(k.get("expires_at") or "never"), active)
554
+
555
+ from rich import print as rprint
556
+ rprint(table)
557
+
558
+
559
+ @api_keys.command("create")
560
+ @click.option("--name", required=True, help="Human-readable label for the key.")
561
+ @click.option(
562
+ "--platform", "platforms", multiple=True,
563
+ help="Platform access to grant (envhub, tournament, hackathon, simstore). Repeat for multiple. Omit for all.",
564
+ )
565
+ @click.option("--expires-at", default=None, help="Expiry in ISO 8601 (e.g. 2027-01-01T00:00:00Z).")
566
+ @click.option(
567
+ "--api-key",
568
+ "api_key",
569
+ default=None,
570
+ envvar="NEPHER_API_KEY",
571
+ help="Do not use for this command. Run 'npcli account login' first (create needs a JWT session).",
572
+ )
573
+ def api_keys_create(name: str, platforms: tuple[str, ...], expires_at: str | None, api_key: str | None) -> None:
574
+ """Create a new API key (requires prior 'npcli account login'; JWT session)."""
575
+ headers = _require_auth(api_key)
576
+ payload: dict[str, Any] = {"name": name}
577
+ if platforms:
578
+ payload["platforms"] = list(platforms)
579
+ if expires_at:
580
+ payload["expires_at"] = expires_at
581
+
582
+ url = f"{ACCOUNT_BACKEND.rstrip('/')}/api/v1/api-keys"
583
+ try:
584
+ r = httpx.post(url, headers=headers, json=payload, timeout=30.0)
585
+ except httpx.RequestError as e:
586
+ console.print(f"[red]Network error[/red]: {e}")
587
+ raise SystemExit(1) from e
588
+
589
+ if r.status_code not in (200, 201):
590
+ console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
591
+ raise SystemExit(1)
592
+
593
+ body = r.json()
594
+ console.print("[green]API key created.[/green]")
595
+ console.print(f" Key: [bold]{body.get('api_key') or body.get('key', '?')}[/bold]")
596
+ console.print(" [dim]Copy this key — it will not be shown again.[/dim]")
597
+ if body.get("id"):
598
+ console.print(f" ID: {body['id']}")
599
+
600
+
601
+ @api_keys.command("revoke")
602
+ @click.argument("key_id")
603
+ @click.option(
604
+ "--api-key",
605
+ "api_key",
606
+ default=None,
607
+ envvar="NEPHER_API_KEY",
608
+ help="Do not use for this command. Run 'npcli account login' first (revoke needs a JWT session).",
609
+ )
610
+ def api_keys_revoke(key_id: str, api_key: str | None) -> None:
611
+ """Revoke (delete) an API key by its ID."""
612
+ headers = _require_auth(api_key)
613
+ url = f"{ACCOUNT_BACKEND.rstrip('/')}/api/v1/api-keys/{key_id}"
614
+ try:
615
+ r = httpx.delete(url, headers=headers, timeout=30.0)
616
+ except httpx.RequestError as e:
617
+ console.print(f"[red]Network error[/red]: {e}")
618
+ raise SystemExit(1) from e
619
+
620
+ if r.status_code in (200, 204):
621
+ console.print("[green]API key revoked.[/green]")
622
+ else:
623
+ console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
624
+ raise SystemExit(1)
625
+
626
+
627
+ # ── Coldkey ──────────────────────────────────────────────────────────────────
628
+
629
+
630
+ @account.command("register-coldkey")
631
+ @click.option("--wallet", required=True, metavar="NAME", help="Bittensor wallet name. Must exist in your local btcli wallet.")
632
+ @click.option(
633
+ "--api-key", "--apikey", "api_key",
634
+ default=None, envvar="NEPHER_API_KEY", metavar="KEY",
635
+ help="Nepher API key (nepher_...). Falls back to stored credentials.",
636
+ )
637
+ def cmd_register_coldkey(wallet: str, api_key: str | None) -> None:
638
+ """Bind or replace the Bittensor coldkey on your Nepher account.
639
+
640
+ Requires bittensor-wallet (or btcli on PATH). Run the same command
641
+ with a different --wallet to replace an existing coldkey.
642
+ """
643
+ resolved_key = api_key or get_stored_api_key()
644
+ if not resolved_key:
645
+ raise SystemExit("No API key available. Pass --api-key or run 'npcli account login' first.")
646
+ raise SystemExit(register_coldkey(wallet, resolved_key, ACCOUNT_BACKEND))