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 ADDED
@@ -0,0 +1,3 @@
1
+ """npcli — unified Nepher command-line interface."""
2
+
3
+ __version__ = "0.2.0"
nepher_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from nepher_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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))