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.
- nepher_cli/__init__.py +10 -0
- nepher_cli/__main__.py +4 -0
- nepher_cli/cli.py +64 -0
- nepher_cli/commands/__init__.py +1 -0
- nepher_cli/commands/account.py +646 -0
- nepher_cli/commands/envhub.py +481 -0
- nepher_cli/commands/hackathon.py +760 -0
- nepher_cli/commands/highlight.py +307 -0
- nepher_cli/commands/simstore.py +49 -0
- nepher_cli/commands/tournament.py +656 -0
- nepher_cli/config.py +47 -0
- nepher_cli/core/__init__.py +1 -0
- nepher_cli/core/credentials.py +243 -0
- nepher_cli/core/http.py +90 -0
- nepher_cli/envhub/__init__.py +1 -0
- nepher_cli/envhub/cache.py +56 -0
- nepher_cli/envhub/config.py +180 -0
- nepher_cli/py.typed +0 -0
- nepher_cli/tournament/__init__.py +1 -0
- nepher_cli/tournament/agent_check.py +60 -0
- nepher_cli/tournament/api.py +100 -0
- nepher_cli/tournament/packer.py +50 -0
- nepher_cli/tournament/wallet.py +89 -0
- tmp_nepher_cli-0.2.5.dist-info/METADATA +200 -0
- tmp_nepher_cli-0.2.5.dist-info/RECORD +27 -0
- tmp_nepher_cli-0.2.5.dist-info/WHEEL +4 -0
- tmp_nepher_cli-0.2.5.dist-info/entry_points.txt +3 -0
|
@@ -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))
|