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.
@@ -0,0 +1,651 @@
1
+ """tournament command group — list, status, leaderboard, validate, submit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import tempfile
9
+ import time
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import click
15
+ import httpx
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+ from rich.table import Table
19
+
20
+ from nepher_cli.config import TOURNAMENT_BACKEND
21
+ from nepher_cli.core.credentials import get_auth_headers, get_stored_api_key
22
+ from nepher_cli.core.http import parse_error_body
23
+ from nepher_cli.tournament.agent_check import check_agent_structure
24
+ from nepher_cli.tournament import api as tournament_api
25
+ from nepher_cli.tournament.packer import compute_checksum, get_file_size, zip_directory
26
+ from nepher_cli.tournament.wallet import prepare_submission_credentials
27
+
28
+ console = Console(stderr=True)
29
+
30
+
31
+ def _resolve_api_key(api_key: str | None) -> str:
32
+ resolved = api_key or os.environ.get("NEPHER_API_KEY") or get_stored_api_key()
33
+ if not resolved:
34
+ console.print(
35
+ "[red]No API key available.[/red] "
36
+ "Pass [bold]--api-key[/bold] or set [bold]NEPHER_API_KEY[/bold] "
37
+ "or run [bold]npcli account login[/bold] first."
38
+ )
39
+ raise SystemExit(1)
40
+ return resolved
41
+
42
+
43
+ def _base() -> str:
44
+ return TOURNAMENT_BACKEND.rstrip("/")
45
+
46
+
47
+ def _optional_headers() -> dict[str, str]:
48
+ """Use stored credentials when logged in; public endpoints work without auth."""
49
+ h = get_auth_headers(None)
50
+ if not h:
51
+ ak = get_stored_api_key()
52
+ if ak:
53
+ h = {"X-API-Key": ak}
54
+ return h
55
+
56
+
57
+ def _tournament_timestamp(t: dict[str, Any], key: str) -> int | None:
58
+ val = t.get(key)
59
+ return int(val) if val is not None else None
60
+
61
+
62
+ def _is_submittable(t: dict[str, Any], now: int | None = None) -> bool:
63
+ """True when contest_start_time <= now < contest_end_time."""
64
+ if now is None:
65
+ now = int(time.time())
66
+ start = _tournament_timestamp(t, "contest_start_time")
67
+ end = _tournament_timestamp(t, "contest_end_time")
68
+ if start is None or end is None:
69
+ return False
70
+ return start <= now < end
71
+
72
+
73
+ def _describe_stage(t: dict[str, Any], now: int | None = None) -> str:
74
+ """Human-readable current stage for a tournament."""
75
+ if now is None:
76
+ now = int(time.time())
77
+ cs = _tournament_timestamp(t, "contest_start_time")
78
+ ce = _tournament_timestamp(t, "contest_end_time")
79
+ es = _tournament_timestamp(t, "evaluation_start_time")
80
+ ee = _tournament_timestamp(t, "evaluation_end_time")
81
+ rs = _tournament_timestamp(t, "reward_start_time")
82
+ re_ = _tournament_timestamp(t, "reward_end_time")
83
+ sw = _tournament_timestamp(t, "submit_window_start_time")
84
+ if cs is None or now < cs:
85
+ return "upcoming"
86
+ if re_ is not None and now >= re_:
87
+ return "completed"
88
+ if rs is not None and now >= rs:
89
+ return "reward"
90
+ if ee is not None and now >= ee:
91
+ return "review"
92
+ if es is not None and now >= es:
93
+ return "evaluation"
94
+ if ce is not None and now >= ce:
95
+ return "evaluation"
96
+ if sw is not None and now >= sw:
97
+ return "submit"
98
+ return "contest"
99
+
100
+
101
+ def _tournament_title(t: dict[str, Any]) -> str:
102
+ return t.get("title") or t.get("subtitle") or t.get("name") or "—"
103
+
104
+
105
+ def _tournament_task_name(t: dict[str, Any]) -> str:
106
+ return t.get("task_name") or "—"
107
+
108
+
109
+ def _tournament_versions(t: dict[str, Any]) -> str:
110
+ task_v = t.get("task_version")
111
+ tourn_v = t.get("tournament_version")
112
+ if task_v is not None and tourn_v is not None:
113
+ return f"{task_v}.{tourn_v}"
114
+ if task_v is not None:
115
+ return str(task_v)
116
+ if tourn_v is not None:
117
+ return str(tourn_v)
118
+ return "—"
119
+
120
+
121
+ _EMPTY = "-"
122
+
123
+
124
+ def _fmt_unix(ts: int | None) -> str:
125
+ if ts is None:
126
+ return _EMPTY
127
+ return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
128
+
129
+
130
+ def _fmt_time_range(start: int | None, end: int | None) -> str:
131
+ if start is None and end is None:
132
+ return _EMPTY
133
+ if start is not None and end is not None:
134
+ return f"{_fmt_unix(start)} to {_fmt_unix(end)}"
135
+ return _fmt_unix(start if start is not None else end)
136
+
137
+
138
+ def _fmt_block_range(start: int | None, end: int | None) -> str:
139
+ if start is None and end is None:
140
+ return _EMPTY
141
+ if start is not None and end is not None:
142
+ return f"{start:,} - {end:,}"
143
+ return f"{start or end:,}"
144
+
145
+
146
+ def _status_markup(status: str | None) -> str:
147
+ if not status:
148
+ return _EMPTY
149
+ colors = {"active": "green", "approved": "blue", "cancelled": "red", "completed": "dim"}
150
+ color = colors.get(status.lower(), "white")
151
+ return f"[{color}]{status}[/{color}]"
152
+
153
+
154
+ def _subnet_field(data: dict[str, Any], key: str) -> Any:
155
+ cfg = data.get("subnet_config") or {}
156
+ return data.get(key) if data.get(key) is not None else cfg.get(key)
157
+
158
+
159
+ def _render_tournament_status(data: dict[str, Any]) -> None:
160
+ from rich import print as rprint
161
+
162
+ title = _tournament_title(data)
163
+ tid = str(data.get("id", "?"))
164
+ header_lines = [f"[bold]{title}[/bold]", f"[cyan]{tid}[/cyan]"]
165
+ if data.get("subtitle"):
166
+ header_lines.append(f"[dim]{data['subtitle']}[/dim]")
167
+ rprint(Panel("\n".join(header_lines), border_style="cyan", padding=(0, 1)))
168
+
169
+ overview = Table(show_header=False, box=None, padding=(0, 2))
170
+ overview.add_column(style="dim", no_wrap=True)
171
+ overview.add_column()
172
+ overview.add_row("Status", _status_markup(data.get("status")))
173
+ overview.add_row("Stage", str(data.get("stage") or _EMPTY))
174
+ if data.get("is_active") is not None:
175
+ active = "[green]yes[/green]" if data.get("is_active") else "[dim]no[/dim]"
176
+ overview.add_row("Active", active)
177
+ overview.add_row("Task", _tournament_task_name(data))
178
+ overview.add_row("Versions", _tournament_versions(data))
179
+ if data.get("difficulty"):
180
+ overview.add_row("Difficulty", str(data["difficulty"]))
181
+ if data.get("tags"):
182
+ overview.add_row("Tags", ", ".join(data["tags"]))
183
+ if data.get("is_featured"):
184
+ overview.add_row("Featured", "[yellow]yes[/yellow]")
185
+ network = _subnet_field(data, "network")
186
+ subnet_uid = _subnet_field(data, "subnet_uid")
187
+ if network is not None:
188
+ overview.add_row("Network", str(network))
189
+ if subnet_uid is not None:
190
+ overview.add_row("Subnet UID", str(subnet_uid))
191
+ if data.get("is_test"):
192
+ overview.add_row("Test mode", "[yellow]yes[/yellow]")
193
+ if data.get("has_public_eval"):
194
+ phase = data.get("current_eval_phase") or _EMPTY
195
+ overview.add_row("Eval phase", str(phase))
196
+ rprint(overview)
197
+
198
+ phases = Table(title="Schedule", show_header=True, header_style="bold", box=None, padding=(0, 1))
199
+ phases.add_column("Phase", style="dim", no_wrap=True)
200
+ phases.add_column("Time (UTC)", no_wrap=True)
201
+ phases.add_column("Blocks", justify="right", no_wrap=True)
202
+
203
+ phase_rows: list[tuple[str, int | None, int | None, int | None, int | None]] = [
204
+ ("Contest", data.get("contest_start_time"), data.get("contest_end_time"),
205
+ data.get("contest_start_block"), data.get("contest_end_block")),
206
+ ("Submit window", data.get("submit_window_start_time"), data.get("contest_end_time"),
207
+ data.get("submit_window_start_block"), data.get("contest_end_block")),
208
+ ("Evaluation", data.get("evaluation_start_time"), data.get("evaluation_end_time"),
209
+ data.get("evaluation_start_block"), data.get("evaluation_end_block")),
210
+ ("Reward", data.get("reward_start_time"), data.get("reward_end_time"),
211
+ data.get("reward_start_block"), data.get("reward_end_block")),
212
+ ]
213
+ if data.get("has_public_eval") and data.get("public_eval_end_time"):
214
+ phase_rows.insert(2, (
215
+ "Public eval ends",
216
+ None,
217
+ data.get("public_eval_end_time"),
218
+ None,
219
+ None,
220
+ ))
221
+
222
+ for label, t_start, t_end, b_start, b_end in phase_rows:
223
+ if t_start is None and t_end is None and b_start is None and b_end is None:
224
+ continue
225
+ time_col = _fmt_unix(t_end) if label == "Public eval ends" else _fmt_time_range(t_start, t_end)
226
+ phases.add_row(label, time_col, _fmt_block_range(b_start, b_end))
227
+
228
+ rprint(phases)
229
+
230
+ stats = data.get("statistics") or {}
231
+ if stats:
232
+ stats_table = Table(title="Statistics", show_header=False, box=None, padding=(0, 2))
233
+ stats_table.add_column(style="dim", no_wrap=True)
234
+ stats_table.add_column(justify="right")
235
+ for label, key in [
236
+ ("Agents submitted", "agents_count"),
237
+ ("Participants", "participants_count"),
238
+ ("Eligible miners", "eligible_count"),
239
+ ("Validators", "validator_count"),
240
+ ]:
241
+ val = stats.get(key)
242
+ if val is not None:
243
+ stats_table.add_row(label, str(val))
244
+ if stats.get("top_score") is not None:
245
+ stats_table.add_row("Top score", f"{float(stats['top_score']):.4f}")
246
+ if stats.get("average_score") is not None:
247
+ stats_table.add_row("Average score", f"{float(stats['average_score']):.4f}")
248
+ if stats.get("score_phase"):
249
+ stats_table.add_row("Score phase", str(stats["score_phase"]))
250
+ rprint(stats_table)
251
+
252
+ eval_cfg = data.get("eval_config") or {}
253
+ if eval_cfg:
254
+ eval_table = Table(title="Evaluation", show_header=False, box=None, padding=(0, 2))
255
+ eval_table.add_column(style="dim", no_wrap=True)
256
+ eval_table.add_column()
257
+ if eval_cfg.get("task_name"):
258
+ eval_table.add_row("Eval task", str(eval_cfg["task_name"]))
259
+ if eval_cfg.get("category"):
260
+ eval_table.add_row("Category", str(eval_cfg["category"]))
261
+ scenes = eval_cfg.get("env_scenes") or []
262
+ if scenes:
263
+ scene_parts = [
264
+ f"{s.get('env_id', '?')}" + (f" (scene {s['scene']})" if s.get("scene") is not None else "")
265
+ for s in scenes
266
+ ]
267
+ eval_table.add_row("Environments", ", ".join(scene_parts))
268
+ for label, key in [
269
+ ("Episodes", "num_episodes"),
270
+ ("Max steps", "max_episode_steps"),
271
+ ("Parallel envs", "num_envs"),
272
+ ]:
273
+ if eval_cfg.get(key) is not None:
274
+ eval_table.add_row(label, str(eval_cfg[key]))
275
+ rprint(eval_table)
276
+
277
+ links = Table(title="Repositories", show_header=False, box=None, padding=(0, 2))
278
+ links.add_column(style="dim", no_wrap=True)
279
+ links.add_column()
280
+ has_links = False
281
+ if data.get("task_gh"):
282
+ links.add_row("Task repo", str(data["task_gh"]))
283
+ has_links = True
284
+ if data.get("eval_gh"):
285
+ links.add_row("Eval repo", str(data["eval_gh"]))
286
+ has_links = True
287
+ if has_links:
288
+ rprint(links)
289
+
290
+ if data.get("winner_hotkey"):
291
+ winner = Table(title="Winner", show_header=False, box=None, padding=(0, 2))
292
+ winner.add_column(style="dim", no_wrap=True)
293
+ winner.add_column()
294
+ winner.add_row("Hotkey", str(data["winner_hotkey"]))
295
+ if data.get("winner_score") is not None:
296
+ winner.add_row("Score", f"{float(data['winner_score']):.4f}")
297
+ if data.get("winner_agent_id"):
298
+ winner.add_row("Agent ID", str(data["winner_agent_id"]))
299
+ approved = "[green]yes[/green]" if data.get("winner_approved") else "[dim]pending[/dim]"
300
+ winner.add_row("Approved", approved)
301
+ rprint(winner)
302
+
303
+ meta_parts: list[str] = []
304
+ if data.get("created_at"):
305
+ meta_parts.append(f"Created {data['created_at']}")
306
+ if data.get("updated_at"):
307
+ meta_parts.append(f"Updated {data['updated_at']}")
308
+ if meta_parts:
309
+ rprint(f"[dim]{' | '.join(meta_parts)}[/dim]")
310
+
311
+
312
+ @click.group("tournament")
313
+ def tournament() -> None:
314
+ """Browse tournaments, check leaderboards, validate agents, and submit to Subnet 49."""
315
+
316
+
317
+ @tournament.command("list")
318
+ @click.option("--active-only", is_flag=True, help="Show only active tournaments.")
319
+ @click.option("--limit", type=int, default=50, show_default=True, help="Maximum tournaments to return.")
320
+ @click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
321
+ def tournament_list(active_only: bool, limit: int, output_json: bool) -> None:
322
+ """List tournaments.
323
+
324
+ Public endpoint — no login required. Stored credentials are used automatically
325
+ when present (e.g. to include admin-only tournaments).
326
+ """
327
+ headers = _optional_headers()
328
+ params: dict[str, Any] = {"limit": limit}
329
+ if active_only:
330
+ params["status"] = "active"
331
+ else:
332
+ params["include_active"] = "true"
333
+
334
+ url = f"{_base()}/api/v1/tournaments/list"
335
+ try:
336
+ r = httpx.get(url, headers=headers, params=params, timeout=30.0)
337
+ except httpx.RequestError as e:
338
+ console.print(f"[red]Network error[/red]: {e}")
339
+ raise SystemExit(1) from e
340
+
341
+ if r.status_code != 200:
342
+ console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
343
+ raise SystemExit(1)
344
+
345
+ try:
346
+ data = r.json()
347
+ except Exception:
348
+ console.print("[red]Invalid JSON response.[/red]")
349
+ raise SystemExit(1)
350
+
351
+ if output_json:
352
+ click.echo(json.dumps(data, indent=2))
353
+ return
354
+
355
+ items: list[dict[str, Any]] = (
356
+ data if isinstance(data, list) else data.get("tournaments", data.get("results", []))
357
+ )
358
+
359
+ if not items:
360
+ console.print("[dim]No tournaments found.[/dim]")
361
+ return
362
+
363
+ table = Table(show_header=True, header_style="bold", box=None)
364
+ table.add_column("ID", style="cyan", no_wrap=True)
365
+ table.add_column("Title")
366
+ table.add_column("Task", style="dim", no_wrap=True)
367
+ table.add_column("Versions", justify="right", no_wrap=True)
368
+ table.add_column("Status")
369
+
370
+ for t in items:
371
+ tid = str(t.get("id", ""))
372
+ table.add_row(
373
+ tid,
374
+ _tournament_title(t),
375
+ _tournament_task_name(t),
376
+ _tournament_versions(t),
377
+ t.get("status") or "—",
378
+ )
379
+
380
+ from rich import print as rprint
381
+ rprint(table)
382
+ console.print(f"\n[dim]{len(items)} tournament(s) listed.[/dim]")
383
+
384
+
385
+ @tournament.command("status")
386
+ @click.argument("tournament_id")
387
+ @click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
388
+ def tournament_status(tournament_id: str, output_json: bool) -> None:
389
+ """Show the current status and configuration of a tournament."""
390
+ headers = _optional_headers()
391
+ url = f"{_base()}/api/v1/tournaments/{tournament_id}"
392
+ try:
393
+ r = httpx.get(url, headers=headers, timeout=30.0)
394
+ except httpx.RequestError as e:
395
+ console.print(f"[red]Network error[/red]: {e}")
396
+ raise SystemExit(1) from e
397
+
398
+ if r.status_code != 200:
399
+ console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
400
+ raise SystemExit(1)
401
+
402
+ try:
403
+ data = r.json()
404
+ except Exception:
405
+ console.print("[red]Invalid JSON response.[/red]")
406
+ raise SystemExit(1)
407
+
408
+ if output_json:
409
+ click.echo(json.dumps(data, indent=2))
410
+ return
411
+
412
+ _render_tournament_status(data)
413
+
414
+
415
+ @tournament.command("leaderboard")
416
+ @click.argument("tournament_id")
417
+ @click.option("--limit", type=int, default=20, show_default=True, help="Number of entries to show.")
418
+ @click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
419
+ def tournament_leaderboard(tournament_id: str, limit: int, output_json: bool) -> None:
420
+ """Show the score leaderboard for a tournament."""
421
+ headers = _optional_headers()
422
+ url = f"{_base()}/api/v1/scores/leaderboard/{tournament_id}"
423
+ params: dict[str, Any] = {"limit": limit}
424
+ try:
425
+ r = httpx.get(url, headers=headers, params=params, timeout=30.0)
426
+ except httpx.RequestError as e:
427
+ console.print(f"[red]Network error[/red]: {e}")
428
+ raise SystemExit(1) from e
429
+
430
+ if r.status_code != 200:
431
+ console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
432
+ raise SystemExit(1)
433
+
434
+ try:
435
+ data = r.json()
436
+ except Exception:
437
+ console.print("[red]Invalid JSON response.[/red]")
438
+ raise SystemExit(1)
439
+
440
+ if output_json:
441
+ click.echo(json.dumps(data, indent=2))
442
+ return
443
+
444
+ scores: list[dict[str, Any]] = (
445
+ data if isinstance(data, list)
446
+ else data.get("entries", data.get("scores", data.get("results", [])))
447
+ )
448
+
449
+ if not scores:
450
+ console.print("[dim]No scores found for this tournament.[/dim]")
451
+ return
452
+
453
+ table = Table(show_header=True, header_style="bold", box=None)
454
+ table.add_column("Rank", justify="right")
455
+ table.add_column("Miner Hotkey")
456
+ table.add_column("Score", justify="right")
457
+ table.add_column("Agent ID")
458
+
459
+ for s in scores[:limit]:
460
+ rank = s.get("rank", "—")
461
+ hotkey = s.get("miner_hotkey") or s.get("hotkey") or "—"
462
+ raw_score = s.get("aggregated_score", s.get("score", 0))
463
+ score = str(round(float(raw_score or 0), 4))
464
+ agent_id = str(s.get("agent_id", "—"))
465
+ table.add_row(str(rank), hotkey, score, agent_id)
466
+
467
+ from rich import print as rprint
468
+ rprint(table)
469
+
470
+
471
+ @tournament.command("check")
472
+ @click.option("--path", "agent_path", type=click.Path(), required=True, help="Path to agent directory.")
473
+ @click.option("--verbose", "-v", is_flag=True, help="Show warnings for missing recommended files.")
474
+ def tournament_check(agent_path: str, verbose: bool) -> None:
475
+ """Check local agent directory structure without submitting.
476
+
477
+ Verifies required files (best_policy/best_policy.pt, source/) and warns
478
+ about missing recommended files (scripts/rsl_rl/play.py, etc.).
479
+
480
+ No extra dependencies required — runs entirely offline.
481
+ """
482
+ path = Path(agent_path)
483
+ console.print(f"Validating agent at [bold]{path}[/bold]...")
484
+
485
+ is_valid, errors, warnings = check_agent_structure(path)
486
+
487
+ if warnings and verbose:
488
+ for w in warnings:
489
+ console.print(f" [yellow]warning:[/yellow] {w}")
490
+
491
+ if is_valid:
492
+ console.print("[green]Agent structure is valid.[/green]")
493
+ raise SystemExit(0)
494
+ else:
495
+ console.print("[red]Agent validation failed:[/red]")
496
+ for err in errors:
497
+ console.print(f" • {err}")
498
+ raise SystemExit(1)
499
+
500
+
501
+ @tournament.command("submit")
502
+ @click.option("--path", "agent_path", type=click.Path(), required=True, help="Path to agent directory.")
503
+ @click.option("--wallet-name", default="miner", show_default=True, help="Bittensor wallet name.")
504
+ @click.option("--wallet-hotkey", default="default", show_default=True, help="Bittensor wallet hotkey.")
505
+ @click.option(
506
+ "--api-key", "--apikey", "api_key",
507
+ default=None, envvar="NEPHER_API_KEY", metavar="KEY",
508
+ help="Nepher API key (nepher_...). Identifies your account; falls back to stored credentials.",
509
+ )
510
+ @click.option("--api-url", default=None, help=f"Tournament API URL (default: {TOURNAMENT_BACKEND}).")
511
+ @click.option("--tournament-id", default=None, help="Target tournament ID (required when multiple are active).")
512
+ @click.option("--verbose", "-v", is_flag=True)
513
+ def tournament_submit(
514
+ agent_path: str,
515
+ wallet_name: str,
516
+ wallet_hotkey: str,
517
+ api_key: str | None,
518
+ api_url: str | None,
519
+ tournament_id: str | None,
520
+ verbose: bool,
521
+ ) -> None:
522
+ """Submit a trained agent to Bittensor Subnet 49 tournaments.
523
+
524
+ Your Nepher account is identified by the API key (--api-key, NEPHER_API_KEY,
525
+ or credentials from npcli account login). The wallet hotkey signs the archive.
526
+
527
+ Requires [bold]bittensor[/bold] for wallet signing:
528
+ pip install bittensor
529
+ """
530
+ resolved_key = _resolve_api_key(api_key)
531
+ resolved_url = api_url or TOURNAMENT_BACKEND
532
+ path = Path(agent_path)
533
+
534
+ console.print("Checking agent structure...")
535
+ is_valid, errors, warnings = check_agent_structure(path)
536
+ if warnings and verbose:
537
+ for w in warnings:
538
+ console.print(f" [yellow]warning:[/yellow] {w}")
539
+ if not is_valid:
540
+ console.print("[red]Agent validation failed:[/red]")
541
+ for err in errors:
542
+ console.print(f" • {err}")
543
+ raise SystemExit(1)
544
+ console.print("[green]Agent structure valid.[/green]")
545
+
546
+ async def _run() -> int:
547
+ try:
548
+ with tempfile.TemporaryDirectory() as tmpdir:
549
+ archive = Path(tmpdir) / "agent.zip"
550
+
551
+ console.print("Creating submission archive...")
552
+ zip_directory(path, archive)
553
+ content_hash = compute_checksum(archive)
554
+ file_size = get_file_size(archive)
555
+ if verbose:
556
+ console.print(
557
+ f" [dim]Archive: {file_size:,} bytes, "
558
+ f"sha256: {content_hash[:16]}...[/dim]"
559
+ )
560
+
561
+ console.print("Signing with wallet...")
562
+ miner_hotkey, public_key, file_info, signature = (
563
+ prepare_submission_credentials(wallet_name, wallet_hotkey, content_hash)
564
+ )
565
+
566
+ console.print("Requesting upload token...")
567
+ token_data = await tournament_api.request_upload_token(
568
+ api_key=resolved_key,
569
+ api_url=resolved_url,
570
+ miner_hotkey=miner_hotkey,
571
+ public_key=public_key,
572
+ file_info=file_info,
573
+ signature=signature,
574
+ file_size=file_size,
575
+ tournament_id=tournament_id,
576
+ )
577
+ resolved_tournament_id = token_data["tournament_id"]
578
+ upload_token = token_data["upload_token"]
579
+
580
+ console.print("Uploading agent...")
581
+ agent_id = await tournament_api.upload_agent(
582
+ api_key=resolved_key,
583
+ api_url=resolved_url,
584
+ tournament_id=resolved_tournament_id,
585
+ upload_token=upload_token,
586
+ miner_hotkey=miner_hotkey,
587
+ content_hash=content_hash,
588
+ file_path=archive,
589
+ )
590
+
591
+ t_title = _tournament_title(token_data)
592
+ t_task = _tournament_task_name(token_data)
593
+ t_version = _tournament_versions(token_data)
594
+
595
+ console.print("[green]Agent submitted successfully.[/green]")
596
+ console.print(f" Agent ID : [bold]{agent_id}[/bold]")
597
+ console.print(f" Tournament ID: [bold]{resolved_tournament_id}[/bold]")
598
+ if t_title and t_title != "—":
599
+ console.print(f" Title : {t_title}")
600
+ if t_task and t_task != "—":
601
+ console.print(f" Task : {t_task}")
602
+ if t_version and t_version != "—":
603
+ console.print(f" Version : {t_version}")
604
+ return 0
605
+ except Exception as e:
606
+ console.print(f"[red]Submission failed:[/red] {e}")
607
+ return 1
608
+
609
+ raise SystemExit(asyncio.run(_run()))
610
+
611
+
612
+ @tournament.command("list-active")
613
+ @click.option("--api-url", default=None, help=f"Tournament API URL (default: {TOURNAMENT_BACKEND}).")
614
+ def tournament_list_active(api_url: str | None) -> None:
615
+ """List active tournaments and whether they accept submissions.
616
+
617
+ Public endpoint — no login required.
618
+ """
619
+ base = (api_url or TOURNAMENT_BACKEND).rstrip("/")
620
+ url = f"{base}/api/v1/tournaments/active/list"
621
+ try:
622
+ r = httpx.get(url, headers=_optional_headers(), params={"subnet": "true"}, timeout=30.0)
623
+ except httpx.RequestError as e:
624
+ console.print(f"[red]Network error[/red]: {e}")
625
+ raise SystemExit(1) from e
626
+
627
+ if r.status_code != 200:
628
+ console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
629
+ raise SystemExit(1)
630
+
631
+ try:
632
+ data = r.json()
633
+ except Exception:
634
+ console.print("[red]Invalid JSON response.[/red]")
635
+ raise SystemExit(1)
636
+
637
+ tournaments: list[dict[str, Any]] = (
638
+ data.get("tournaments", []) if isinstance(data, dict) else data
639
+ )
640
+
641
+ if not tournaments:
642
+ console.print("[dim]No active tournaments.[/dim]")
643
+ return
644
+
645
+ console.print(f"[bold]{len(tournaments)} active tournament(s):[/bold]")
646
+ for t in tournaments:
647
+ accepting = "[green]yes[/green]" if _is_submittable(t) else "[red]no[/red]"
648
+ console.print(
649
+ f" {t.get('id', '?')} | {t.get('task_name', '?')} | "
650
+ f"stage={_describe_stage(t)} | accepts_submissions={accepting}"
651
+ )
nepher_cli/config.py ADDED
@@ -0,0 +1,25 @@
1
+ """Production API base URLs for Nepher services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ ACCOUNT_BACKEND = "https://account-api.nepher.ai"
6
+ HACKATHON_BACKEND = "https://api.hackathon.nepher.ai"
7
+ ENVHUB_BACKEND = "https://envhub-api.nepher.ai"
8
+ TOURNAMENT_BACKEND = "https://tournament-api.nepher.ai"
9
+ SIMSTORE_BACKEND = "https://api.simstore.nepher.ai" # future
10
+
11
+ # Backwards-compatible aliases for tests and imports.
12
+ DEFAULT_ACCOUNT_BACKEND = ACCOUNT_BACKEND
13
+ DEFAULT_HACKATHON_BACKEND = HACKATHON_BACKEND
14
+
15
+
16
+ def resolve_backend_base(service: str) -> str:
17
+ """Return the API base URL for a given service name (backwards compat)."""
18
+ mapping = {
19
+ "account": ACCOUNT_BACKEND,
20
+ "hackathon": HACKATHON_BACKEND,
21
+ "envhub": ENVHUB_BACKEND,
22
+ "tournament": TOURNAMENT_BACKEND,
23
+ "simstore": SIMSTORE_BACKEND,
24
+ }
25
+ return mapping.get(service, ACCOUNT_BACKEND)
@@ -0,0 +1 @@
1
+ """Shared CLI infrastructure (auth, HTTP helpers)."""