nepher-cli 0.2.2__tar.gz → 0.2.4__tar.gz

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.
Files changed (28) hide show
  1. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/PKG-INFO +10 -7
  2. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/README.md +8 -5
  3. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/pyproject.toml +5 -3
  4. nepher_cli-0.2.4/src/nepher_cli/__init__.py +10 -0
  5. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/cli.py +6 -1
  6. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/commands/account.py +109 -8
  7. nepher_cli-0.2.4/src/nepher_cli/commands/highlight.py +307 -0
  8. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/commands/tournament.py +7 -2
  9. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/core/http.py +14 -0
  10. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/tournament/wallet.py +3 -3
  11. nepher_cli-0.2.2/src/nepher_cli/__init__.py +0 -3
  12. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/.gitignore +0 -0
  13. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/__main__.py +0 -0
  14. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/commands/__init__.py +0 -0
  15. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/commands/envhub.py +0 -0
  16. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/commands/hackathon.py +0 -0
  17. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/commands/simstore.py +0 -0
  18. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/config.py +0 -0
  19. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/core/__init__.py +0 -0
  20. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/core/credentials.py +0 -0
  21. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/envhub/__init__.py +0 -0
  22. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/envhub/cache.py +0 -0
  23. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/envhub/config.py +0 -0
  24. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/py.typed +0 -0
  25. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/tournament/__init__.py +0 -0
  26. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/tournament/agent_check.py +0 -0
  27. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/tournament/api.py +0 -0
  28. {nepher_cli-0.2.2 → nepher_cli-0.2.4}/src/nepher_cli/tournament/packer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nepher-cli
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: npcli — unified Nepher command-line interface for accounts, hackathons, EnvHub, tournaments, and SimStore
5
5
  Project-URL: Homepage, https://github.com/nepher-ai/nepher-cli
6
6
  Author: Nepher
@@ -20,7 +20,7 @@ Requires-Dist: keyring>=25.0.0
20
20
  Requires-Dist: platformdirs>=4.0.0
21
21
  Requires-Dist: rich>=13.7.0
22
22
  Provides-Extra: bittensor
23
- Requires-Dist: bittensor; extra == 'bittensor'
23
+ Requires-Dist: bittensor-wallet>=4.1.0; extra == 'bittensor'
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: pytest>=8.0.0; extra == 'dev'
26
26
  Requires-Dist: ruff>=0.4.0; extra == 'dev'
@@ -87,10 +87,10 @@ npcli tournament check --path ./my-agent
87
87
  npcli tournament check --path ./my-agent --verbose # also show recommended-file warnings
88
88
  ```
89
89
 
90
- Submit an agent (requires `bittensor` for wallet signing):
90
+ Submit an agent (requires `bittensor-wallet` for wallet signing):
91
91
 
92
92
  ```bash
93
- pip install bittensor
93
+ pip install bittensor-wallet
94
94
  # or: pip install "nepher-cli[bittensor]"
95
95
  ```
96
96
 
@@ -154,13 +154,15 @@ npcli account api-keys revoke <key_id>
154
154
 
155
155
  ### Coldkey Registration
156
156
 
157
- Bind a Bittensor coldkey to your account (requires `bittensor` + `btcli` on PATH):
157
+ Bind a Bittensor coldkey to your account (requires `bittensor-wallet`; `btcli` is optional fallback):
158
158
 
159
159
  ```bash
160
- pip install bittensor
160
+ pip install bittensor-wallet
161
161
  npcli account register-coldkey --wallet <wallet_name>
162
162
  ```
163
163
 
164
+ Signing uses the local coldkey via `bittensor-wallet` and does not import `btcli`. If you still have a mixed Bittensor venv (`scalecodec` + `cyscale`), `register-coldkey` keeps working as long as `bittensor-wallet` is installed.
165
+
164
166
  ## API Key Requirements
165
167
 
166
168
  - Must start with `nepher_`
@@ -177,8 +179,9 @@ npcli account register-coldkey --wallet <wallet_name>
177
179
  | `api key does not have hackathon access` | Enable Hackathon scope or use an unrestricted key |
178
180
  | `api key expired` | Run `npcli account api-keys create` |
179
181
  | `Several hackathons are accepting submissions` | Re-run with `--hackathon-id` |
180
- | `bittensor not installed` | `pip install bittensor` (only needed for `tournament submit`) |
182
+ | `bittensor-wallet not installed` | `pip install bittensor-wallet` (needed for `tournament submit` and `account register-coldkey`) |
181
183
  | `Not logged in` | `npcli login --api-key nepher_...` |
184
+ | `Conflict detected: 'scalecodec' ... conflicts with 'cyscale'` | Leftover `scalecodec` after a Bittensor upgrade. Prefer `pip install bittensor-wallet` so npcli never needs `btcli`. To repair `btcli` itself: `pip uninstall scalecodec cyscale -y && pip install -U cyscale --force-reinstall` |
182
185
 
183
186
  Run any command with `--help` for full flag details:
184
187
 
@@ -59,10 +59,10 @@ npcli tournament check --path ./my-agent
59
59
  npcli tournament check --path ./my-agent --verbose # also show recommended-file warnings
60
60
  ```
61
61
 
62
- Submit an agent (requires `bittensor` for wallet signing):
62
+ Submit an agent (requires `bittensor-wallet` for wallet signing):
63
63
 
64
64
  ```bash
65
- pip install bittensor
65
+ pip install bittensor-wallet
66
66
  # or: pip install "nepher-cli[bittensor]"
67
67
  ```
68
68
 
@@ -126,13 +126,15 @@ npcli account api-keys revoke <key_id>
126
126
 
127
127
  ### Coldkey Registration
128
128
 
129
- Bind a Bittensor coldkey to your account (requires `bittensor` + `btcli` on PATH):
129
+ Bind a Bittensor coldkey to your account (requires `bittensor-wallet`; `btcli` is optional fallback):
130
130
 
131
131
  ```bash
132
- pip install bittensor
132
+ pip install bittensor-wallet
133
133
  npcli account register-coldkey --wallet <wallet_name>
134
134
  ```
135
135
 
136
+ Signing uses the local coldkey via `bittensor-wallet` and does not import `btcli`. If you still have a mixed Bittensor venv (`scalecodec` + `cyscale`), `register-coldkey` keeps working as long as `bittensor-wallet` is installed.
137
+
136
138
  ## API Key Requirements
137
139
 
138
140
  - Must start with `nepher_`
@@ -149,8 +151,9 @@ npcli account register-coldkey --wallet <wallet_name>
149
151
  | `api key does not have hackathon access` | Enable Hackathon scope or use an unrestricted key |
150
152
  | `api key expired` | Run `npcli account api-keys create` |
151
153
  | `Several hackathons are accepting submissions` | Re-run with `--hackathon-id` |
152
- | `bittensor not installed` | `pip install bittensor` (only needed for `tournament submit`) |
154
+ | `bittensor-wallet not installed` | `pip install bittensor-wallet` (needed for `tournament submit` and `account register-coldkey`) |
153
155
  | `Not logged in` | `npcli login --api-key nepher_...` |
156
+ | `Conflict detected: 'scalecodec' ... conflicts with 'cyscale'` | Leftover `scalecodec` after a Bittensor upgrade. Prefer `pip install bittensor-wallet` so npcli never needs `btcli`. To repair `btcli` itself: `pip uninstall scalecodec cyscale -y && pip install -U cyscale --force-reinstall` |
154
157
 
155
158
  Run any command with `--help` for full flag details:
156
159
 
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["hatchling"]
2
+ requires = ["hatchling>=1.27,<1.32"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nepher-cli"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "npcli — unified Nepher command-line interface for accounts, hackathons, EnvHub, tournaments, and SimStore"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -29,7 +29,7 @@ dependencies = [
29
29
  ]
30
30
 
31
31
  [project.optional-dependencies]
32
- bittensor = ["bittensor"]
32
+ bittensor = ["bittensor-wallet>=4.1.0"]
33
33
  dev = [
34
34
  "pytest>=8.0.0",
35
35
  "ruff>=0.4.0",
@@ -44,9 +44,11 @@ Homepage = "https://github.com/nepher-ai/nepher-cli"
44
44
 
45
45
  [tool.hatch.build.targets.wheel]
46
46
  packages = ["src/nepher_cli"]
47
+ core-metadata-version = "2.4"
47
48
 
48
49
  [tool.hatch.build.targets.sdist]
49
50
  include = ["src/nepher_cli", "README.md", "pyproject.toml"]
51
+ core-metadata-version = "2.4"
50
52
 
51
53
  [tool.ruff]
52
54
  line-length = 100
@@ -0,0 +1,10 @@
1
+ """npcli — unified Nepher command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ __version__ = version("nepher-cli")
9
+ except PackageNotFoundError: # pragma: no cover
10
+ __version__ = "0.0.0"
@@ -15,7 +15,7 @@ from nepher_cli.commands.tournament import tournament
15
15
  @click.group(
16
16
  context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 100},
17
17
  )
18
- @click.version_option(version=__version__, prog_name="npcli")
18
+ @click.version_option(version=__version__, package_name="nepher-cli", prog_name="npcli")
19
19
  def main() -> None:
20
20
  """npcli — the unified Nepher command-line interface.
21
21
 
@@ -39,6 +39,11 @@ def main() -> None:
39
39
  npcli envhub list
40
40
  npcli tournament list
41
41
 
42
+ \b
43
+ Tournament submit requires bittensor-wallet for wallet signing:
44
+ pip install bittensor-wallet
45
+ # or: pip install "nepher-cli[bittensor]"
46
+
42
47
  Run any sub-command with --help for details and examples.
43
48
 
44
49
  API keys are created at https://account.nepher.ai (Account > API Keys).
@@ -35,6 +35,25 @@ console = Console(stderr=True)
35
35
 
36
36
  BTCLI_SIGN_TIMEOUT_SECONDS = 120
37
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
+
38
57
  # ---------------------------------------------------------------------------
39
58
  # Internal helpers
40
59
  # ---------------------------------------------------------------------------
@@ -146,16 +165,99 @@ def _extract_btcli_payload(stdout_text: str, original_message: str) -> dict[str,
146
165
  return data
147
166
 
148
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
+
149
247
  def run_btcli_sign(wallet_name: str, message: str) -> dict[str, Any]:
150
248
  """Run btcli wallet sign; inherit stdin/stderr so password prompts work."""
151
249
  btcli = shutil.which("btcli")
152
250
  if not btcli:
153
251
  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."
252
+ "[red]bittensor-wallet not installed[/red] and [bold]btcli[/bold] is not on PATH.\n\n"
253
+ f"{_WALLET_INSTALL_HINT}"
156
254
  )
157
255
  raise SystemExit(1)
158
256
 
257
+ if has_scalecodec_cyscale_conflict():
258
+ console.print(_SCALECODEC_CONFLICT_HINT)
259
+ raise SystemExit(1)
260
+
159
261
  cmd = [btcli, "wallet", "sign", "--wallet-name", wallet_name, "--message", message, "--json-output"]
160
262
  try:
161
263
  proc = subprocess.Popen(cmd, stdin=sys.stdin, stderr=subprocess.PIPE, stdout=subprocess.PIPE, text=False)
@@ -200,7 +302,10 @@ def run_btcli_sign(wallet_name: str, message: str) -> dict[str, Any]:
200
302
  t_err.join(timeout=2)
201
303
  out = b"".join(stdout_chunks).decode("utf-8", errors="replace")
202
304
 
305
+ err = b"".join(stderr_chunks).decode("utf-8", errors="replace")
203
306
  if proc.returncode != 0:
307
+ if _looks_like_scalecodec_conflict(out + err):
308
+ console.print(_SCALECODEC_CONFLICT_HINT)
204
309
  raise SystemExit(1)
205
310
 
206
311
  data = _extract_btcli_payload(out, message)
@@ -242,12 +347,8 @@ def register_coldkey(wallet: str, api_key: str, base_url: str) -> int:
242
347
  return 1
243
348
 
244
349
  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
350
  try:
250
- signed = run_btcli_sign(wallet, msg)
351
+ signed = sign_coldkey_challenge(wallet, msg)
251
352
  except KeyboardInterrupt:
252
353
  console.print("\n[yellow]Interrupted — coldkey registration was not completed.[/yellow]")
253
354
  return 130
@@ -518,7 +619,7 @@ def api_keys_revoke(key_id: str, api_key: str | None) -> None:
518
619
  def cmd_register_coldkey(wallet: str, api_key: str | None) -> None:
519
620
  """Bind or replace the Bittensor coldkey on your Nepher account.
520
621
 
521
- Requires btcli to be installed and on your PATH. Run the same command
622
+ Requires bittensor-wallet (or btcli on PATH). Run the same command
522
623
  with a different --wallet to replace an existing coldkey.
523
624
  """
524
625
  resolved_key = api_key or get_stored_api_key()
@@ -0,0 +1,307 @@
1
+ """tournament highlight subgroup — manage verified play videos.
2
+
3
+ Highlights are admin-uploaded videos bound to a submission (``--agent-id``) and an
4
+ eval phase. Placement is never supplied here: the platform reads rank and score
5
+ from the tournament leaderboard when the highlight is served.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json as jsonlib
11
+ import mimetypes
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import click
16
+ import httpx
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from nepher_cli.config import TOURNAMENT_BACKEND
21
+ from nepher_cli.core.http import (
22
+ authed_delete,
23
+ authed_get,
24
+ authed_patch,
25
+ authed_post,
26
+ parse_error_body,
27
+ )
28
+
29
+ console = Console(stderr=True)
30
+
31
+ PHASES = ("public", "private")
32
+ STATUSES = ("draft", "published")
33
+
34
+
35
+ def _admin_url(api_url: str | None, path: str = "") -> str:
36
+ return f"{(api_url or TOURNAMENT_BACKEND).rstrip('/')}/api/v1/admin/highlights{path}"
37
+
38
+
39
+ def _handle(response: httpx.Response) -> Any:
40
+ """Raise a friendly SystemExit on failure, else return the decoded body."""
41
+ if response.status_code >= 400:
42
+ console.print(
43
+ f"[red]{parse_error_body(response.text) or response.text.strip() or f'HTTP {response.status_code}'}[/red]"
44
+ )
45
+ raise SystemExit(1)
46
+ if response.status_code == 204 or not response.content:
47
+ return None
48
+ try:
49
+ return response.json()
50
+ except ValueError:
51
+ console.print("[red]Invalid JSON response.[/red]")
52
+ raise SystemExit(1)
53
+
54
+
55
+ def _call(fn, *args, **kwargs) -> Any:
56
+ try:
57
+ return _handle(fn(*args, **kwargs))
58
+ except httpx.RequestError as e:
59
+ console.print(f"[red]Network error[/red]: {e}")
60
+ raise SystemExit(1) from e
61
+
62
+
63
+ def _print_highlight(data: dict[str, Any], heading: str) -> None:
64
+ console.print(f"[green]{heading}[/green]")
65
+ for label, key in [
66
+ ("Highlight ID", "id"),
67
+ ("Tournament", "tournament_name"),
68
+ ("Submission", "agent_id"),
69
+ ("Hotkey", "miner_hotkey"),
70
+ ("Phase", "phase"),
71
+ ("Status", "status"),
72
+ ("Rank", "rank"),
73
+ ]:
74
+ value = data.get(key)
75
+ if value is not None:
76
+ console.print(f" {label:<13}: [bold]{value}[/bold]")
77
+
78
+
79
+ @click.group("highlight")
80
+ def highlight() -> None:
81
+ """Manage verified play videos (admin only).
82
+
83
+ \b
84
+ list List highlights, including drafts
85
+ upload Upload a play video for a submission
86
+ publish Make a highlight public
87
+ unpublish Return a highlight to draft
88
+ delete Remove a highlight and its video
89
+ """
90
+
91
+
92
+ _api_url_option = click.option(
93
+ "--api-url", default=None, help=f"Tournament API URL (default: {TOURNAMENT_BACKEND})."
94
+ )
95
+ _api_key_option = click.option(
96
+ "--api-key", "--apikey", "api_key",
97
+ default=None, envvar="NEPHER_API_KEY", metavar="KEY",
98
+ help="Nepher API key. Falls back to stored credentials.",
99
+ )
100
+ _json_option = click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
101
+
102
+
103
+ @highlight.command("list")
104
+ @click.option("--tournament-id", default=None, help="Filter by tournament.")
105
+ @click.option("--phase", type=click.Choice(PHASES), default=None, help="Filter by eval phase.")
106
+ @click.option("--status", type=click.Choice(STATUSES), default=None, help="Filter by publish state.")
107
+ @click.option("--limit", type=int, default=24, show_default=True, help="Highlights per page.")
108
+ @_api_key_option
109
+ @_api_url_option
110
+ @_json_option
111
+ def highlight_list(
112
+ tournament_id: str | None,
113
+ phase: str | None,
114
+ status: str | None,
115
+ limit: int,
116
+ api_key: str | None,
117
+ api_url: str | None,
118
+ output_json: bool,
119
+ ) -> None:
120
+ """List highlights with their live leaderboard placement."""
121
+ params = {
122
+ k: v
123
+ for k, v in {
124
+ "tournament_id": tournament_id,
125
+ "phase": phase,
126
+ "status": status,
127
+ "page_size": limit,
128
+ }.items()
129
+ if v is not None
130
+ }
131
+ data = _call(authed_get, _admin_url(api_url), api_key=api_key, params=params)
132
+
133
+ if output_json:
134
+ click.echo(jsonlib.dumps(data, indent=2))
135
+ return
136
+
137
+ items: list[dict[str, Any]] = data.get("items", [])
138
+ if not items:
139
+ console.print("[dim]No highlights found.[/dim]")
140
+ return
141
+
142
+ table = Table(show_header=True, header_style="bold", box=None)
143
+ table.add_column("ID", style="cyan", no_wrap=True)
144
+ table.add_column("Rank", justify="right")
145
+ table.add_column("Phase", no_wrap=True)
146
+ table.add_column("Status", no_wrap=True)
147
+ table.add_column("Hotkey")
148
+ table.add_column("Tournament", style="dim")
149
+
150
+ for item in items:
151
+ rank = "winner" if item.get("is_winner") else (item.get("rank") or "—")
152
+ table.add_row(
153
+ str(item.get("id", "")),
154
+ str(rank),
155
+ str(item.get("phase", "—")),
156
+ str(item.get("status", "—")),
157
+ str(item.get("miner_hotkey", "—")),
158
+ str(item.get("tournament_name") or "—"),
159
+ )
160
+
161
+ from rich import print as rprint
162
+
163
+ rprint(table)
164
+ console.print(f"\n[dim]{len(items)} of {data.get('total', len(items))} highlight(s).[/dim]")
165
+
166
+
167
+ @highlight.command("upload")
168
+ @click.option("--tournament-id", required=True, help="Tournament the run belongs to.")
169
+ @click.option("--phase", type=click.Choice(PHASES), required=True, help="Eval phase of the run.")
170
+ @click.option("--file", "video_path", type=click.Path(exists=True, dir_okay=False), required=True,
171
+ help="Play video (MP4 or WebM, max 100 MB).")
172
+ @click.option("--agent-id", default=None, help="Submission the video shows.")
173
+ @click.option("--rank", type=int, default=None,
174
+ help="Resolve the submission by its current rank in this phase instead of --agent-id.")
175
+ @click.option("--title", default=None, help="Display title.")
176
+ @click.option("--poster", "poster_path", type=click.Path(exists=True, dir_okay=False), default=None,
177
+ help="Optional poster image (JPG, PNG, WebP, max 10 MB).")
178
+ @click.option("--publish", is_flag=True, help="Publish immediately instead of saving a draft.")
179
+ @_api_key_option
180
+ @_api_url_option
181
+ @_json_option
182
+ def highlight_upload(
183
+ tournament_id: str,
184
+ phase: str,
185
+ video_path: str,
186
+ agent_id: str | None,
187
+ rank: int | None,
188
+ title: str | None,
189
+ poster_path: str | None,
190
+ publish: bool,
191
+ api_key: str | None,
192
+ api_url: str | None,
193
+ output_json: bool,
194
+ ) -> None:
195
+ """Upload a verified play video and bind it to a submission.
196
+
197
+ Provide either --agent-id or --rank; the platform stores the resolved
198
+ submission so the card's placement always tracks the leaderboard.
199
+ """
200
+ if not agent_id and rank is None:
201
+ console.print("[red]Provide --agent-id or --rank.[/red]")
202
+ raise SystemExit(1)
203
+
204
+ form: dict[str, str] = {
205
+ "tournament_id": tournament_id,
206
+ "phase": phase,
207
+ "publish": str(publish).lower(),
208
+ }
209
+ if agent_id:
210
+ form["agent_id"] = agent_id
211
+ if rank is not None:
212
+ form["rank"] = str(rank)
213
+ if title:
214
+ form["title"] = title
215
+
216
+ video = Path(video_path)
217
+ files: dict[str, Any] = {
218
+ "file": (video.name, video.read_bytes(), mimetypes.guess_type(video.name)[0] or "video/mp4"),
219
+ }
220
+ if poster_path:
221
+ poster = Path(poster_path)
222
+ files["poster"] = (
223
+ poster.name,
224
+ poster.read_bytes(),
225
+ mimetypes.guess_type(poster.name)[0] or "image/jpeg",
226
+ )
227
+
228
+ console.print("Uploading play video...")
229
+ data = _call(
230
+ authed_post,
231
+ _admin_url(api_url, "/upload"),
232
+ api_key=api_key,
233
+ data=form,
234
+ files=files,
235
+ timeout=600.0,
236
+ )
237
+
238
+ if output_json:
239
+ click.echo(jsonlib.dumps(data, indent=2))
240
+ return
241
+
242
+ _print_highlight(data, "Highlight published." if publish else "Highlight saved as draft.")
243
+
244
+
245
+ def _set_status(
246
+ highlight_id: str,
247
+ status: str,
248
+ api_key: str | None,
249
+ api_url: str | None,
250
+ output_json: bool,
251
+ ) -> None:
252
+ data = _call(
253
+ authed_patch,
254
+ _admin_url(api_url, f"/{highlight_id}"),
255
+ api_key=api_key,
256
+ json_body={"status": status},
257
+ )
258
+ if output_json:
259
+ click.echo(jsonlib.dumps(data, indent=2))
260
+ return
261
+ _print_highlight(data, f"Highlight marked {status}.")
262
+
263
+
264
+ @highlight.command("publish")
265
+ @click.argument("highlight_id")
266
+ @_api_key_option
267
+ @_api_url_option
268
+ @_json_option
269
+ def highlight_publish(
270
+ highlight_id: str, api_key: str | None, api_url: str | None, output_json: bool
271
+ ) -> None:
272
+ """Publish a draft highlight to the public feed."""
273
+ _set_status(highlight_id, "published", api_key, api_url, output_json)
274
+
275
+
276
+ @highlight.command("unpublish")
277
+ @click.argument("highlight_id")
278
+ @_api_key_option
279
+ @_api_url_option
280
+ @_json_option
281
+ def highlight_unpublish(
282
+ highlight_id: str, api_key: str | None, api_url: str | None, output_json: bool
283
+ ) -> None:
284
+ """Return a published highlight to draft."""
285
+ _set_status(highlight_id, "draft", api_key, api_url, output_json)
286
+
287
+
288
+ @highlight.command("delete")
289
+ @click.argument("highlight_id")
290
+ @click.option("--keep-media", is_flag=True, help="Keep the video in the Media Library.")
291
+ @click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.")
292
+ @_api_key_option
293
+ @_api_url_option
294
+ def highlight_delete(
295
+ highlight_id: str,
296
+ keep_media: bool,
297
+ yes: bool,
298
+ api_key: str | None,
299
+ api_url: str | None,
300
+ ) -> None:
301
+ """Delete a highlight and, unless --keep-media, its video."""
302
+ if not yes:
303
+ click.confirm(f"Delete highlight {highlight_id}?", abort=True)
304
+
305
+ query = "?keep_media=true" if keep_media else ""
306
+ _call(authed_delete, _admin_url(api_url, f"/{highlight_id}{query}"), api_key=api_key)
307
+ console.print("[green]Highlight deleted.[/green]")
@@ -17,6 +17,7 @@ from rich.console import Console
17
17
  from rich.panel import Panel
18
18
  from rich.table import Table
19
19
 
20
+ from nepher_cli.commands.highlight import highlight as highlight_group
20
21
  from nepher_cli.config import TOURNAMENT_BACKEND
21
22
  from nepher_cli.core.credentials import get_auth_headers, get_stored_api_key
22
23
  from nepher_cli.core.http import parse_error_body
@@ -314,6 +315,9 @@ def tournament() -> None:
314
315
  """Browse tournaments, check leaderboards, validate agents, and submit to Subnet 49."""
315
316
 
316
317
 
318
+ tournament.add_command(highlight_group)
319
+
320
+
317
321
  @tournament.command("list")
318
322
  @click.option("--active-only", is_flag=True, help="Show only active tournaments.")
319
323
  @click.option("--limit", type=int, default=50, show_default=True, help="Maximum tournaments to return.")
@@ -524,8 +528,9 @@ def tournament_submit(
524
528
  Your Nepher account is identified by the API key (--api-key, NEPHER_API_KEY,
525
529
  or credentials from npcli account login). The wallet hotkey signs the archive.
526
530
 
527
- Requires [bold]bittensor[/bold] for wallet signing:
528
- pip install bittensor
531
+ Requires [bold]bittensor-wallet[/bold] for wallet signing:
532
+ pip install bittensor-wallet
533
+ # or: pip install "nepher-cli[bittensor]"
529
534
  """
530
535
  resolved_key = _resolve_api_key(api_key)
531
536
  resolved_url = api_url or TOURNAMENT_BACKEND
@@ -67,6 +67,20 @@ def authed_post(
67
67
  return client.post(url, headers=headers, json=json_body, timeout=timeout)
68
68
 
69
69
 
70
+ def authed_patch(
71
+ url: str,
72
+ *,
73
+ api_key: str | None = None,
74
+ json_body: dict[str, Any] | None = None,
75
+ ) -> httpx.Response:
76
+ """PATCH with auth headers resolved from credential store or override."""
77
+ from nepher_cli.core.credentials import get_auth_headers
78
+
79
+ headers = get_auth_headers(api_key)
80
+ with httpx.Client() as client:
81
+ return client.patch(url, headers=headers, json=json_body, timeout=60.0)
82
+
83
+
70
84
  def authed_delete(url: str, *, api_key: str | None = None) -> httpx.Response:
71
85
  """DELETE with auth headers resolved from credential store or override."""
72
86
  from nepher_cli.core.credentials import get_auth_headers
@@ -5,9 +5,9 @@ from __future__ import annotations
5
5
  import time
6
6
 
7
7
  _BITTENSOR_HINT = (
8
- "Agent submission requires [bold]bittensor[/bold] for wallet signing.\n\n"
8
+ "Agent submission requires [bold]bittensor-wallet[/bold] for wallet signing.\n\n"
9
9
  "Install it with:\n"
10
- " [bold]pip install bittensor[/bold]\n\n"
10
+ " [bold]pip install bittensor-wallet[/bold]\n\n"
11
11
  "Or if you installed nepher-cli via pip:\n"
12
12
  " [bold]pip install \"nepher-cli[bittensor]\"[/bold]"
13
13
  )
@@ -26,7 +26,7 @@ def _require_wallet_class():
26
26
  except ImportError:
27
27
  from rich.console import Console
28
28
  Console(stderr=True).print(
29
- f"[red]bittensor not installed.[/red]\n\n{_BITTENSOR_HINT}"
29
+ f"[red]bittensor-wallet not installed.[/red]\n\n{_BITTENSOR_HINT}"
30
30
  )
31
31
  raise SystemExit(1)
32
32
 
@@ -1,3 +0,0 @@
1
- """npcli — unified Nepher command-line interface."""
2
-
3
- __version__ = "0.2.0"
File without changes