nepher-cli 0.2.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.
- nepher_cli/__init__.py +3 -0
- nepher_cli/__main__.py +4 -0
- nepher_cli/cli.py +59 -0
- nepher_cli/commands/__init__.py +1 -0
- nepher_cli/commands/account.py +527 -0
- nepher_cli/commands/envhub.py +466 -0
- nepher_cli/commands/hackathon.py +760 -0
- nepher_cli/commands/simstore.py +49 -0
- nepher_cli/commands/tournament.py +651 -0
- nepher_cli/config.py +25 -0
- nepher_cli/core/__init__.py +1 -0
- nepher_cli/core/credentials.py +243 -0
- nepher_cli/core/http.py +76 -0
- nepher_cli/envhub/__init__.py +1 -0
- nepher_cli/envhub/cache.py +56 -0
- nepher_cli/envhub/config.py +176 -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
- nepher_cli-0.2.0.dist-info/METADATA +193 -0
- nepher_cli-0.2.0.dist-info/RECORD +26 -0
- nepher_cli-0.2.0.dist-info/WHEEL +4 -0
- nepher_cli-0.2.0.dist-info/entry_points.txt +3 -0
nepher_cli/__init__.py
ADDED
nepher_cli/__main__.py
ADDED
nepher_cli/cli.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""npcli — unified Nepher command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from nepher_cli import __version__
|
|
8
|
+
from nepher_cli.commands.account import account, cmd_login, cmd_logout, cmd_whoami
|
|
9
|
+
from nepher_cli.commands.envhub import envhub
|
|
10
|
+
from nepher_cli.commands.hackathon import hackathon
|
|
11
|
+
from nepher_cli.commands.simstore import simstore
|
|
12
|
+
from nepher_cli.commands.tournament import tournament
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group(
|
|
16
|
+
context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 100},
|
|
17
|
+
)
|
|
18
|
+
@click.version_option(version=__version__, prog_name="npcli")
|
|
19
|
+
def main() -> None:
|
|
20
|
+
"""npcli — the unified Nepher command-line interface.
|
|
21
|
+
|
|
22
|
+
Provides centralized access to all Nepher sub-platforms:
|
|
23
|
+
|
|
24
|
+
\b
|
|
25
|
+
login Log in with a Nepher API key
|
|
26
|
+
whoami Show the currently authenticated user
|
|
27
|
+
logout Clear locally stored credentials
|
|
28
|
+
account API keys and coldkey registration
|
|
29
|
+
tournament Browse tournaments, submit agents, leaderboards
|
|
30
|
+
envhub Manage Isaac Lab environment bundles
|
|
31
|
+
hackathon Browse and submit to hackathons
|
|
32
|
+
simstore SimStore marketplace (coming soon)
|
|
33
|
+
|
|
34
|
+
\b
|
|
35
|
+
Quick start:
|
|
36
|
+
npcli login --api-key nepher_xxxxxxxx
|
|
37
|
+
npcli whoami
|
|
38
|
+
npcli hackathon list
|
|
39
|
+
npcli envhub list
|
|
40
|
+
npcli tournament list
|
|
41
|
+
|
|
42
|
+
Run any sub-command with --help for details and examples.
|
|
43
|
+
|
|
44
|
+
API keys are created at https://account.nepher.ai (Account > API Keys).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
main.add_command(cmd_login, name="login")
|
|
49
|
+
main.add_command(cmd_logout, name="logout")
|
|
50
|
+
main.add_command(cmd_whoami, name="whoami")
|
|
51
|
+
main.add_command(account)
|
|
52
|
+
main.add_command(tournament)
|
|
53
|
+
main.add_command(envhub)
|
|
54
|
+
main.add_command(hackathon)
|
|
55
|
+
main.add_command(simstore)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""npcli command groups."""
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""account command group — login, API keys, and coldkey registration.
|
|
2
|
+
|
|
3
|
+
All commands talk to the account backend (account-api.nepher.ai).
|
|
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
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Internal helpers
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _require_auth(api_key: str | None) -> dict[str, str]:
|
|
44
|
+
headers = get_auth_headers(api_key)
|
|
45
|
+
if not headers:
|
|
46
|
+
console.print("[yellow]Not logged in.[/yellow] Run [bold]npcli account login[/bold] first.")
|
|
47
|
+
raise SystemExit(1)
|
|
48
|
+
return headers
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _print_user(user: dict[str, Any]) -> None:
|
|
52
|
+
for label, key in [("Name", "fullname"), ("Email", "email"), ("Role", "role"), ("Status", "status")]:
|
|
53
|
+
val = user.get(key)
|
|
54
|
+
if val:
|
|
55
|
+
console.print(f" {label}: {val}")
|
|
56
|
+
coldkey = user.get("coldkey")
|
|
57
|
+
if coldkey:
|
|
58
|
+
console.print(f" Coldkey: {coldkey}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Coldkey core logic
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _api_paths(base: str) -> tuple[str, str]:
|
|
67
|
+
b = base.rstrip("/")
|
|
68
|
+
return (
|
|
69
|
+
f"{b}/api/v1/account/coldkey/challenge",
|
|
70
|
+
f"{b}/api/v1/account/coldkey/verify",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_api_key_format(api_key: str) -> None:
|
|
75
|
+
if not api_key.startswith("nepher_"):
|
|
76
|
+
console.print(
|
|
77
|
+
"[red]invalid api key format[/red] — keys must start with [bold]nepher_[/bold]. "
|
|
78
|
+
"Copy the key from your Nepher account settings."
|
|
79
|
+
)
|
|
80
|
+
raise SystemExit(1)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _extract_btcli_payload(stdout_text: str, original_message: str) -> dict[str, Any] | None:
|
|
84
|
+
"""Parse btcli output across json/dict variants and normalize keys."""
|
|
85
|
+
data: dict[str, Any] | None = None
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
parsed = json.loads(stdout_text or "{}")
|
|
89
|
+
if isinstance(parsed, dict):
|
|
90
|
+
data = parsed
|
|
91
|
+
except json.JSONDecodeError:
|
|
92
|
+
data = None
|
|
93
|
+
|
|
94
|
+
if data is None:
|
|
95
|
+
try:
|
|
96
|
+
parsed = ast.literal_eval(stdout_text.strip())
|
|
97
|
+
if isinstance(parsed, dict):
|
|
98
|
+
data = parsed
|
|
99
|
+
except (SyntaxError, ValueError):
|
|
100
|
+
data = None
|
|
101
|
+
|
|
102
|
+
if data is None:
|
|
103
|
+
matches = list(re.finditer(r"\{.*?\}", stdout_text, flags=re.DOTALL))
|
|
104
|
+
for m in reversed(matches):
|
|
105
|
+
blob = m.group(0)
|
|
106
|
+
try:
|
|
107
|
+
parsed = json.loads(blob)
|
|
108
|
+
except json.JSONDecodeError:
|
|
109
|
+
try:
|
|
110
|
+
parsed = ast.literal_eval(blob)
|
|
111
|
+
except (SyntaxError, ValueError):
|
|
112
|
+
continue
|
|
113
|
+
if isinstance(parsed, dict):
|
|
114
|
+
data = parsed
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
if data is None:
|
|
118
|
+
# Fallback for btcli text that mixes prompts and wraps values across lines.
|
|
119
|
+
sig_m = re.search(
|
|
120
|
+
r"""['"]signed_message['"]\s*:\s*['"]([0-9a-fA-F\s]+)['"]""",
|
|
121
|
+
stdout_text,
|
|
122
|
+
flags=re.DOTALL,
|
|
123
|
+
)
|
|
124
|
+
addr_m = re.search(
|
|
125
|
+
r"""['"]signer_address['"]\s*:\s*['"]([1-9A-HJ-NP-Za-km-z]+)['"]""",
|
|
126
|
+
stdout_text,
|
|
127
|
+
flags=re.DOTALL,
|
|
128
|
+
)
|
|
129
|
+
if sig_m and addr_m:
|
|
130
|
+
# Some terminal outputs insert hard wraps in long hex payloads.
|
|
131
|
+
signed_message = re.sub(r"\s+", "", sig_m.group(1))
|
|
132
|
+
data = {
|
|
133
|
+
"signed_message": signed_message,
|
|
134
|
+
"signer_address": addr_m.group(1).strip(),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if not data:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
if "signature" not in data and "signed_message" in data:
|
|
141
|
+
data["signature"] = data["signed_message"]
|
|
142
|
+
if "address" not in data and "signer_address" in data:
|
|
143
|
+
data["address"] = data["signer_address"]
|
|
144
|
+
if "message" not in data:
|
|
145
|
+
data["message"] = original_message
|
|
146
|
+
return data
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def run_btcli_sign(wallet_name: str, message: str) -> dict[str, Any]:
|
|
150
|
+
"""Run btcli wallet sign; inherit stdin/stderr so password prompts work."""
|
|
151
|
+
btcli = shutil.which("btcli")
|
|
152
|
+
if not btcli:
|
|
153
|
+
console.print(
|
|
154
|
+
"[red]btcli not found[/red] — install Bittensor ([code]pip install bittensor[/code]) "
|
|
155
|
+
"and ensure [bold]btcli[/bold] is on your PATH."
|
|
156
|
+
)
|
|
157
|
+
raise SystemExit(1)
|
|
158
|
+
|
|
159
|
+
cmd = [btcli, "wallet", "sign", "--wallet-name", wallet_name, "--message", message, "--json-output"]
|
|
160
|
+
try:
|
|
161
|
+
proc = subprocess.Popen(cmd, stdin=sys.stdin, stderr=subprocess.PIPE, stdout=subprocess.PIPE, text=False)
|
|
162
|
+
except OSError as e:
|
|
163
|
+
console.print(f"[red]btcli signing failed[/red] — could not run btcli: {e}")
|
|
164
|
+
raise SystemExit(1) from e
|
|
165
|
+
|
|
166
|
+
stdout_chunks: list[bytes] = []
|
|
167
|
+
stderr_chunks: list[bytes] = []
|
|
168
|
+
|
|
169
|
+
def _pump(pipe: Any, sink: list[bytes], echo: bool) -> None:
|
|
170
|
+
if pipe is None:
|
|
171
|
+
return
|
|
172
|
+
try:
|
|
173
|
+
fd = pipe.fileno()
|
|
174
|
+
while True:
|
|
175
|
+
chunk = os.read(fd, 1024)
|
|
176
|
+
if not chunk:
|
|
177
|
+
break
|
|
178
|
+
sink.append(chunk)
|
|
179
|
+
if echo:
|
|
180
|
+
sys.stderr.buffer.write(chunk)
|
|
181
|
+
sys.stderr.buffer.flush()
|
|
182
|
+
finally:
|
|
183
|
+
pipe.close()
|
|
184
|
+
|
|
185
|
+
t_out = threading.Thread(target=_pump, args=(proc.stdout, stdout_chunks, True), daemon=True)
|
|
186
|
+
t_err = threading.Thread(target=_pump, args=(proc.stderr, stderr_chunks, True), daemon=True)
|
|
187
|
+
t_out.start()
|
|
188
|
+
t_err.start()
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
proc.wait(timeout=BTCLI_SIGN_TIMEOUT_SECONDS)
|
|
192
|
+
except subprocess.TimeoutExpired:
|
|
193
|
+
proc.kill()
|
|
194
|
+
proc.wait()
|
|
195
|
+
t_out.join(timeout=1)
|
|
196
|
+
t_err.join(timeout=1)
|
|
197
|
+
raise SystemExit(1)
|
|
198
|
+
|
|
199
|
+
t_out.join(timeout=2)
|
|
200
|
+
t_err.join(timeout=2)
|
|
201
|
+
out = b"".join(stdout_chunks).decode("utf-8", errors="replace")
|
|
202
|
+
|
|
203
|
+
if proc.returncode != 0:
|
|
204
|
+
raise SystemExit(1)
|
|
205
|
+
|
|
206
|
+
data = _extract_btcli_payload(out, message)
|
|
207
|
+
if data is None:
|
|
208
|
+
raise SystemExit(1)
|
|
209
|
+
|
|
210
|
+
for key in ("message", "address", "signature"):
|
|
211
|
+
if key not in data:
|
|
212
|
+
raise SystemExit(1)
|
|
213
|
+
return data
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def register_coldkey(wallet: str, api_key: str, base_url: str) -> int:
|
|
217
|
+
"""Execute the coldkey challenge/sign/verify flow. Returns exit code."""
|
|
218
|
+
validate_api_key_format(api_key)
|
|
219
|
+
challenge_url, verify_url = _api_paths(base_url)
|
|
220
|
+
|
|
221
|
+
console.print("Checking your API key and registration status...")
|
|
222
|
+
with httpx.Client() as client:
|
|
223
|
+
try:
|
|
224
|
+
r = request_json(client, "POST", challenge_url, json_body={"api_key": api_key})
|
|
225
|
+
except httpx.RequestError as e:
|
|
226
|
+
console.print(f"[red]Unable to reach the Nepher backend[/red]. Check your network connection. ({e})")
|
|
227
|
+
return 1
|
|
228
|
+
|
|
229
|
+
if r.status_code == 200:
|
|
230
|
+
try:
|
|
231
|
+
body = r.json()
|
|
232
|
+
except json.JSONDecodeError:
|
|
233
|
+
console.print("[red]Unexpected response from account backend[/red] (invalid JSON).")
|
|
234
|
+
return 1
|
|
235
|
+
msg = body.get("message") if isinstance(body, dict) else None
|
|
236
|
+
if not msg or not isinstance(msg, str):
|
|
237
|
+
console.print("[red]Unexpected challenge response[/red] (missing message).")
|
|
238
|
+
return 1
|
|
239
|
+
else:
|
|
240
|
+
err = parse_error_body(r.text) or r.text.strip() or f"HTTP {r.status_code}"
|
|
241
|
+
console.print(f"[red]{err}[/red]")
|
|
242
|
+
return 1
|
|
243
|
+
|
|
244
|
+
console.print(f"Signing with wallet [bold]{wallet}[/bold]...")
|
|
245
|
+
console.print(
|
|
246
|
+
"[dim]Passing through btcli output and prompts below. "
|
|
247
|
+
"Respond directly in this terminal when btcli asks for input.[/dim]"
|
|
248
|
+
)
|
|
249
|
+
try:
|
|
250
|
+
signed = run_btcli_sign(wallet, msg)
|
|
251
|
+
except KeyboardInterrupt:
|
|
252
|
+
console.print("\n[yellow]Interrupted — coldkey registration was not completed.[/yellow]")
|
|
253
|
+
return 130
|
|
254
|
+
|
|
255
|
+
console.print("Submitting to backend...")
|
|
256
|
+
payload = {"api_key": api_key, "signed_payload": signed}
|
|
257
|
+
with httpx.Client() as client:
|
|
258
|
+
try:
|
|
259
|
+
vr = request_json(client, "POST", verify_url, json_body=payload)
|
|
260
|
+
except httpx.RequestError as e:
|
|
261
|
+
console.print(f"[red]Unable to reach the Nepher backend[/red]. Check your network connection. ({e})")
|
|
262
|
+
return 1
|
|
263
|
+
|
|
264
|
+
if vr.status_code == 200:
|
|
265
|
+
try:
|
|
266
|
+
vb = vr.json()
|
|
267
|
+
except json.JSONDecodeError:
|
|
268
|
+
console.print("[red]Unexpected response[/red] from verify (invalid JSON).")
|
|
269
|
+
return 1
|
|
270
|
+
if isinstance(vb, dict):
|
|
271
|
+
st = vb.get("status")
|
|
272
|
+
ck = vb.get("coldkey", "?")
|
|
273
|
+
replaced = vb.get("replaced") is True
|
|
274
|
+
if st == "registered":
|
|
275
|
+
console.print("[green]Coldkey updated successfully.[/green]" if replaced else "[green]Coldkey registered successfully.[/green]")
|
|
276
|
+
console.print(f" Coldkey: [bold]{ck}[/bold]")
|
|
277
|
+
return 0
|
|
278
|
+
if st == "already_registered":
|
|
279
|
+
console.print(f"[green]This coldkey is already registered on your account.[/green]\n Coldkey: [bold]{ck}[/bold]")
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
err = parse_error_body(vr.text) or vr.text.strip() or f"HTTP {vr.status_code}"
|
|
283
|
+
low = err.lower()
|
|
284
|
+
if "already" in low and "registered" in low:
|
|
285
|
+
console.print(f"[green]{err}[/green]")
|
|
286
|
+
return 0
|
|
287
|
+
console.print(f"[red]{err}[/red]")
|
|
288
|
+
return 1
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
# Click command group
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@click.group("account")
|
|
297
|
+
def account() -> None:
|
|
298
|
+
"""Manage your Nepher account — login, API keys, and coldkey registration."""
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ── Auth ────────────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@account.command("login")
|
|
305
|
+
@click.option(
|
|
306
|
+
"--api-key", "api_key",
|
|
307
|
+
default=None, envvar="NEPHER_API_KEY",
|
|
308
|
+
help="Nepher API key (nepher_...). Prompted if omitted.",
|
|
309
|
+
)
|
|
310
|
+
def cmd_login(api_key: str | None) -> None:
|
|
311
|
+
"""Log in with a Nepher API key and store credentials locally.
|
|
312
|
+
|
|
313
|
+
Credentials are saved to ~/.nepher/credentials.json (tokens are stored in
|
|
314
|
+
the system keyring when available). After login, all npcli commands
|
|
315
|
+
authenticate automatically without requiring --api-key on every call.
|
|
316
|
+
"""
|
|
317
|
+
if not api_key:
|
|
318
|
+
api_key = click.prompt("Nepher API key", hide_input=True)
|
|
319
|
+
api_key = (api_key or "").strip()
|
|
320
|
+
|
|
321
|
+
if not api_key.startswith("nepher_"):
|
|
322
|
+
console.print("[red]Invalid API key format[/red] — keys must start with [bold]nepher_[/bold].")
|
|
323
|
+
raise SystemExit(1)
|
|
324
|
+
|
|
325
|
+
console.print("Authenticating...")
|
|
326
|
+
url = f"{ACCOUNT_BACKEND.rstrip('/')}/api/v1/auth/cli-login"
|
|
327
|
+
try:
|
|
328
|
+
r = httpx.post(url, json={"api_key": api_key}, timeout=30.0)
|
|
329
|
+
except httpx.RequestError as e:
|
|
330
|
+
console.print(f"[red]Unable to reach the Nepher backend[/red] ({e}).")
|
|
331
|
+
raise SystemExit(1) from e
|
|
332
|
+
|
|
333
|
+
if r.status_code != 200:
|
|
334
|
+
err = parse_error_body(r.text) or r.text.strip() or f"HTTP {r.status_code}"
|
|
335
|
+
console.print(f"[red]{err}[/red]")
|
|
336
|
+
raise SystemExit(1)
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
body = r.json()
|
|
340
|
+
except Exception:
|
|
341
|
+
console.print("[red]Unexpected response from account backend (invalid JSON).[/red]")
|
|
342
|
+
raise SystemExit(1)
|
|
343
|
+
|
|
344
|
+
save_credentials(
|
|
345
|
+
api_key=api_key,
|
|
346
|
+
access_token=body["access_token"],
|
|
347
|
+
refresh_token=body["refresh_token"],
|
|
348
|
+
expires_in=body.get("expires_in", 86400),
|
|
349
|
+
user=body.get("user", {}),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
user = body.get("user", {})
|
|
353
|
+
console.print("[green]Logged in successfully.[/green]")
|
|
354
|
+
if user.get("fullname"):
|
|
355
|
+
console.print(f" Name: [bold]{user['fullname']}[/bold]")
|
|
356
|
+
if user.get("email"):
|
|
357
|
+
console.print(f" Email: {user['email']}")
|
|
358
|
+
if user.get("role"):
|
|
359
|
+
console.print(f" Role: {user['role']}")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@account.command("logout")
|
|
363
|
+
def cmd_logout() -> None:
|
|
364
|
+
"""Clear locally stored credentials."""
|
|
365
|
+
clear_credentials()
|
|
366
|
+
console.print("[green]Logged out — credentials cleared.[/green]")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@account.command("whoami")
|
|
370
|
+
@click.option("--api-key", "api_key", default=None, envvar="NEPHER_API_KEY", help="Override stored credentials.")
|
|
371
|
+
def cmd_whoami(api_key: str | None) -> None:
|
|
372
|
+
"""Show the currently authenticated user.
|
|
373
|
+
|
|
374
|
+
Uses cached user data when available; falls back to a live API call.
|
|
375
|
+
"""
|
|
376
|
+
if not api_key:
|
|
377
|
+
cached = whoami_from_cache()
|
|
378
|
+
if cached:
|
|
379
|
+
console.print("[bold]Current user (cached)[/bold]")
|
|
380
|
+
_print_user(cached)
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
headers = _require_auth(api_key)
|
|
384
|
+
url = f"{ACCOUNT_BACKEND.rstrip('/')}/api/v1/users/me"
|
|
385
|
+
try:
|
|
386
|
+
r = httpx.get(url, headers=headers, timeout=30.0)
|
|
387
|
+
except httpx.RequestError as e:
|
|
388
|
+
console.print(f"[red]Unable to reach the Nepher backend[/red] ({e}).")
|
|
389
|
+
raise SystemExit(1) from e
|
|
390
|
+
|
|
391
|
+
if r.status_code != 200:
|
|
392
|
+
console.print(f"[red]{parse_error_body(r.text) or f'HTTP {r.status_code}'}[/red]")
|
|
393
|
+
raise SystemExit(1)
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
user = r.json()
|
|
397
|
+
except Exception:
|
|
398
|
+
console.print("[red]Unexpected response (invalid JSON).[/red]")
|
|
399
|
+
raise SystemExit(1)
|
|
400
|
+
|
|
401
|
+
console.print("[bold]Current user[/bold]")
|
|
402
|
+
_print_user(user)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# ── API keys ─────────────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@account.group("api-keys")
|
|
409
|
+
def api_keys() -> None:
|
|
410
|
+
"""Manage Nepher API keys."""
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@api_keys.command("list")
|
|
414
|
+
@click.option("--api-key", "api_key", default=None, envvar="NEPHER_API_KEY")
|
|
415
|
+
def api_keys_list(api_key: str | None) -> None:
|
|
416
|
+
"""List your API keys."""
|
|
417
|
+
headers = _require_auth(api_key)
|
|
418
|
+
url = f"{ACCOUNT_BACKEND.rstrip('/')}/api/v1/api-keys"
|
|
419
|
+
try:
|
|
420
|
+
r = httpx.get(url, headers=headers, timeout=30.0)
|
|
421
|
+
except httpx.RequestError as e:
|
|
422
|
+
console.print(f"[red]Network error[/red]: {e}")
|
|
423
|
+
raise SystemExit(1) from e
|
|
424
|
+
|
|
425
|
+
if r.status_code != 200:
|
|
426
|
+
console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
|
|
427
|
+
raise SystemExit(1)
|
|
428
|
+
|
|
429
|
+
data = r.json()
|
|
430
|
+
keys: list[dict[str, Any]] = data if isinstance(data, list) else data.get("api_keys", [])
|
|
431
|
+
|
|
432
|
+
if not keys:
|
|
433
|
+
console.print("[dim]No API keys found.[/dim]")
|
|
434
|
+
return
|
|
435
|
+
|
|
436
|
+
table = Table(show_header=True, header_style="bold")
|
|
437
|
+
table.add_column("ID")
|
|
438
|
+
table.add_column("Name")
|
|
439
|
+
table.add_column("Platforms")
|
|
440
|
+
table.add_column("Expires")
|
|
441
|
+
table.add_column("Active")
|
|
442
|
+
|
|
443
|
+
for k in keys:
|
|
444
|
+
platforms = ", ".join(k.get("platforms") or []) or "all"
|
|
445
|
+
active = "[green]yes[/green]" if k.get("is_active") else "[red]no[/red]"
|
|
446
|
+
table.add_row(str(k.get("id", "")), k.get("name") or "", platforms, str(k.get("expires_at") or "never"), active)
|
|
447
|
+
|
|
448
|
+
from rich import print as rprint
|
|
449
|
+
rprint(table)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@api_keys.command("create")
|
|
453
|
+
@click.option("--name", required=True, help="Human-readable label for the key.")
|
|
454
|
+
@click.option(
|
|
455
|
+
"--platform", "platforms", multiple=True,
|
|
456
|
+
help="Platform access to grant (envhub, tournament, hackertone, simstore). Repeat for multiple. Omit for all.",
|
|
457
|
+
)
|
|
458
|
+
@click.option("--expires-at", default=None, help="Expiry in ISO 8601 (e.g. 2027-01-01T00:00:00Z).")
|
|
459
|
+
@click.option("--api-key", "api_key", default=None, envvar="NEPHER_API_KEY")
|
|
460
|
+
def api_keys_create(name: str, platforms: tuple[str, ...], expires_at: str | None, api_key: str | None) -> None:
|
|
461
|
+
"""Create a new API key."""
|
|
462
|
+
headers = _require_auth(api_key)
|
|
463
|
+
payload: dict[str, Any] = {"name": name}
|
|
464
|
+
if platforms:
|
|
465
|
+
payload["platforms"] = list(platforms)
|
|
466
|
+
if expires_at:
|
|
467
|
+
payload["expires_at"] = expires_at
|
|
468
|
+
|
|
469
|
+
url = f"{ACCOUNT_BACKEND.rstrip('/')}/api/v1/api-keys"
|
|
470
|
+
try:
|
|
471
|
+
r = httpx.post(url, headers=headers, json=payload, timeout=30.0)
|
|
472
|
+
except httpx.RequestError as e:
|
|
473
|
+
console.print(f"[red]Network error[/red]: {e}")
|
|
474
|
+
raise SystemExit(1) from e
|
|
475
|
+
|
|
476
|
+
if r.status_code not in (200, 201):
|
|
477
|
+
console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
|
|
478
|
+
raise SystemExit(1)
|
|
479
|
+
|
|
480
|
+
body = r.json()
|
|
481
|
+
console.print("[green]API key created.[/green]")
|
|
482
|
+
console.print(f" Key: [bold]{body.get('api_key') or body.get('key', '?')}[/bold]")
|
|
483
|
+
console.print(" [dim]Copy this key — it will not be shown again.[/dim]")
|
|
484
|
+
if body.get("id"):
|
|
485
|
+
console.print(f" ID: {body['id']}")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@api_keys.command("revoke")
|
|
489
|
+
@click.argument("key_id")
|
|
490
|
+
@click.option("--api-key", "api_key", default=None, envvar="NEPHER_API_KEY")
|
|
491
|
+
def api_keys_revoke(key_id: str, api_key: str | None) -> None:
|
|
492
|
+
"""Revoke (delete) an API key by its ID."""
|
|
493
|
+
headers = _require_auth(api_key)
|
|
494
|
+
url = f"{ACCOUNT_BACKEND.rstrip('/')}/api/v1/api-keys/{key_id}"
|
|
495
|
+
try:
|
|
496
|
+
r = httpx.delete(url, headers=headers, timeout=30.0)
|
|
497
|
+
except httpx.RequestError as e:
|
|
498
|
+
console.print(f"[red]Network error[/red]: {e}")
|
|
499
|
+
raise SystemExit(1) from e
|
|
500
|
+
|
|
501
|
+
if r.status_code in (200, 204):
|
|
502
|
+
console.print("[green]API key revoked.[/green]")
|
|
503
|
+
else:
|
|
504
|
+
console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
|
|
505
|
+
raise SystemExit(1)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ── Coldkey ──────────────────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
@account.command("register-coldkey")
|
|
512
|
+
@click.option("--wallet", required=True, metavar="NAME", help="Bittensor wallet name. Must exist in your local btcli wallet.")
|
|
513
|
+
@click.option(
|
|
514
|
+
"--api-key", "--apikey", "api_key",
|
|
515
|
+
default=None, envvar="NEPHER_API_KEY", metavar="KEY",
|
|
516
|
+
help="Nepher API key (nepher_...). Falls back to stored credentials.",
|
|
517
|
+
)
|
|
518
|
+
def cmd_register_coldkey(wallet: str, api_key: str | None) -> None:
|
|
519
|
+
"""Bind or replace the Bittensor coldkey on your Nepher account.
|
|
520
|
+
|
|
521
|
+
Requires btcli to be installed and on your PATH. Run the same command
|
|
522
|
+
with a different --wallet to replace an existing coldkey.
|
|
523
|
+
"""
|
|
524
|
+
resolved_key = api_key or get_stored_api_key()
|
|
525
|
+
if not resolved_key:
|
|
526
|
+
raise SystemExit("No API key available. Pass --api-key or run 'npcli account login' first.")
|
|
527
|
+
raise SystemExit(register_coldkey(wallet, resolved_key, ACCOUNT_BACKEND))
|