hypercli-cli 0.8.13__tar.gz → 0.9.0__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 (22) hide show
  1. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/PKG-INFO +1 -1
  2. hypercli_cli-0.9.0/hypercli_cli/agents.py +559 -0
  3. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/claw.py +144 -2
  4. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/cli.py +2 -1
  5. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/instances.py +12 -0
  6. hypercli_cli-0.9.0/hypercli_cli/voice.py +155 -0
  7. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/pyproject.toml +1 -1
  8. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/.gitignore +0 -0
  9. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/README.md +0 -0
  10. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/__init__.py +0 -0
  11. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/billing.py +0 -0
  12. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/comfyui.py +0 -0
  13. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/flow.py +0 -0
  14. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/jobs.py +0 -0
  15. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/keys.py +0 -0
  16. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/onboard.py +0 -0
  17. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/output.py +0 -0
  18. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/renders.py +0 -0
  19. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/tui/__init__.py +0 -0
  20. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/tui/job_monitor.py +0 -0
  21. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/user.py +0 -0
  22. {hypercli_cli-0.8.13 → hypercli_cli-0.9.0}/hypercli_cli/wallet.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypercli-cli
3
- Version: 0.8.13
3
+ Version: 0.9.0
4
4
  Summary: CLI for HyperCLI - GPU orchestration and LLM API
5
5
  Project-URL: Homepage, https://hypercli.com
6
6
  Project-URL: Documentation, https://docs.hypercli.com
@@ -0,0 +1,559 @@
1
+ """HyperClaw Agents — Reef Pod Management CLI"""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from hypercli.agents import Agents, ReefPod
15
+
16
+ app = typer.Typer(help="Manage OpenClaw agent pods (reef containers)")
17
+ console = Console()
18
+
19
+ # Config — uses HyperClaw API key (sk-...) for backend auth
20
+ CLAW_KEY_PATH = Path.home() / ".hypercli" / "claw-key.json"
21
+ STATE_DIR = Path.home() / ".hypercli"
22
+ AGENTS_STATE = STATE_DIR / "agents.json"
23
+
24
+
25
+ def _get_claw_api_key() -> str:
26
+ """Resolve HyperClaw API key from env or saved key file."""
27
+ key = os.environ.get("HYPERCLAW_API_KEY", "")
28
+ if key:
29
+ return key
30
+ if CLAW_KEY_PATH.exists():
31
+ with open(CLAW_KEY_PATH) as f:
32
+ data = json.load(f)
33
+ key = data.get("key", "")
34
+ if key:
35
+ return key
36
+ console.print("[red]❌ No HyperClaw API key found.[/red]")
37
+ console.print("Set HYPERCLAW_API_KEY or subscribe: [bold]hyper claw subscribe 1aiu[/bold]")
38
+ raise typer.Exit(1)
39
+
40
+
41
+ def _get_agents_client() -> Agents:
42
+ """Create an Agents client using the HyperClaw API key."""
43
+ from hypercli.http import HTTPClient
44
+ api_key = _get_claw_api_key()
45
+ api_base = os.environ.get("HYPERCLAW_API_BASE", "https://api.hyperclaw.app")
46
+ http = HTTPClient(api_base, api_key)
47
+ return Agents(http, claw_api_key=api_key, claw_api_base=api_base)
48
+
49
+
50
+ def _save_pod_state(pod: ReefPod):
51
+ """Save pod info locally for quick reference."""
52
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
53
+ state = _load_state()
54
+ state[pod.id] = {
55
+ "id": pod.id,
56
+ "pod_id": pod.pod_id,
57
+ "pod_name": pod.pod_name,
58
+ "user_id": pod.user_id,
59
+ "hostname": pod.hostname,
60
+ "jwt_token": pod.jwt_token,
61
+ "state": pod.state,
62
+ }
63
+ with open(AGENTS_STATE, "w") as f:
64
+ json.dump(state, f, indent=2, default=str)
65
+
66
+
67
+ def _load_state() -> dict:
68
+ if AGENTS_STATE.exists():
69
+ with open(AGENTS_STATE) as f:
70
+ return json.load(f)
71
+ return {}
72
+
73
+
74
+ def _remove_pod_state(agent_id: str):
75
+ state = _load_state()
76
+ state.pop(agent_id, None)
77
+ with open(AGENTS_STATE, "w") as f:
78
+ json.dump(state, f, indent=2, default=str)
79
+
80
+
81
+ def _resolve_agent(agent_id: str) -> str:
82
+ """Resolve agent_id with prefix matching from local state."""
83
+ state = _load_state()
84
+ if agent_id in state:
85
+ return agent_id
86
+ matches = [k for k in state if k.startswith(agent_id)]
87
+ if len(matches) == 1:
88
+ return matches[0]
89
+ if len(matches) > 1:
90
+ console.print(f"[yellow]Ambiguous ID prefix '{agent_id}'. Matches:[/yellow]")
91
+ for m in matches:
92
+ s = state[m]
93
+ console.print(f" {m[:12]} {s.get('pod_name', '')} {s.get('state', '')}")
94
+ raise typer.Exit(1)
95
+ return agent_id
96
+
97
+
98
+ def _get_pod_with_token(agent_id: str) -> ReefPod:
99
+ """Get a ReefPod, filling JWT from local state if needed."""
100
+ agents = _get_agents_client()
101
+ pod = agents.get(agent_id)
102
+ if not pod.jwt_token:
103
+ state = _load_state()
104
+ local = state.get(agent_id, {})
105
+ if local.get("jwt_token"):
106
+ pod.jwt_token = local["jwt_token"]
107
+ return pod
108
+
109
+
110
+ @app.command("create")
111
+ def create(
112
+ wait: bool = typer.Option(True, "--wait/--no-wait", help="Wait for pod to be running"),
113
+ ):
114
+ """Create a new OpenClaw agent pod."""
115
+ agents = _get_agents_client()
116
+
117
+ console.print("\n[bold]Creating agent pod...[/bold]")
118
+
119
+ try:
120
+ pod = agents.create()
121
+ except Exception as e:
122
+ console.print(f"[red]❌ Create failed: {e}[/red]")
123
+ raise typer.Exit(1)
124
+
125
+ _save_pod_state(pod)
126
+
127
+ console.print(f"[green]✓[/green] Agent created: [bold]{pod.id[:12]}[/bold]")
128
+ console.print(f" Pod: {pod.pod_name}")
129
+ console.print(f" State: {pod.state}")
130
+ console.print(f" Desktop: {pod.vnc_url}")
131
+ console.print(f" Shell: {pod.shell_url}")
132
+
133
+ if wait:
134
+ console.print("\n[dim]Waiting for pod to start...[/dim]")
135
+ for i in range(60):
136
+ time.sleep(5)
137
+ try:
138
+ pod = agents.get(pod.id)
139
+ _save_pod_state(pod)
140
+ if pod.is_running:
141
+ console.print(f"[green]✅ Agent is running![/green]")
142
+ break
143
+ elif pod.state in ("failed", "stopped"):
144
+ console.print(f"[red]❌ Agent failed: {pod.state}[/red]")
145
+ if pod.last_error:
146
+ console.print(f" Error: {pod.last_error}")
147
+ raise typer.Exit(1)
148
+ else:
149
+ console.print(f" [{i*5}s] State: {pod.state}")
150
+ except typer.Exit:
151
+ raise
152
+ except Exception as e:
153
+ console.print(f" [{i*5}s] Checking... ({e})")
154
+ else:
155
+ console.print("[yellow]⚠ Timed out (5 min). Pod may still be starting.[/yellow]")
156
+
157
+ console.print(f"\nExec: [bold]hyper agents exec {pod.id[:8]} 'echo hello'[/bold]")
158
+ console.print(f"Shell: [bold]hyper agents shell {pod.id[:8]}[/bold]")
159
+ console.print(f"Desktop: {pod.vnc_url}")
160
+
161
+
162
+ @app.command("list")
163
+ def list_agents(
164
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
165
+ ):
166
+ """List all agent pods."""
167
+ agents = _get_agents_client()
168
+
169
+ try:
170
+ pods = agents.list()
171
+ except Exception as e:
172
+ console.print(f"[red]❌ Failed to list agents: {e}[/red]")
173
+ raise typer.Exit(1)
174
+
175
+ if json_output:
176
+ console.print_json(json.dumps([{
177
+ "id": p.id, "pod_name": p.pod_name, "state": p.state,
178
+ "hostname": p.hostname, "vnc_url": p.vnc_url,
179
+ } for p in pods], indent=2, default=str))
180
+ return
181
+
182
+ if not pods:
183
+ console.print("[dim]No agents found.[/dim]")
184
+ console.print("Create one: [bold]hyper agents create[/bold]")
185
+ return
186
+
187
+ table = Table(title="Agents")
188
+ table.add_column("ID", style="cyan", no_wrap=True)
189
+ table.add_column("Pod", style="blue")
190
+ table.add_column("State")
191
+ table.add_column("Desktop URL")
192
+ table.add_column("Created")
193
+
194
+ for pod in pods:
195
+ style = {"running": "green", "pending": "yellow", "starting": "yellow"}.get(pod.state, "red")
196
+ created = pod.created_at.strftime("%Y-%m-%d %H:%M") if pod.created_at else ""
197
+ table.add_row(
198
+ pod.id[:12],
199
+ pod.pod_name or "",
200
+ f"[{style}]{pod.state}[/{style}]",
201
+ pod.vnc_url or "",
202
+ created,
203
+ )
204
+ _save_pod_state(pod)
205
+
206
+ console.print()
207
+ console.print(table)
208
+ console.print()
209
+
210
+
211
+ @app.command("status")
212
+ def status(
213
+ agent_id: str = typer.Argument(..., help="Agent ID (or prefix)"),
214
+ ):
215
+ """Get detailed status of an agent."""
216
+ agent_id = _resolve_agent(agent_id)
217
+ agents = _get_agents_client()
218
+
219
+ try:
220
+ pod = agents.get(agent_id)
221
+ except Exception as e:
222
+ console.print(f"[red]❌ Failed to get agent: {e}[/red]")
223
+ raise typer.Exit(1)
224
+
225
+ _save_pod_state(pod)
226
+
227
+ console.print(f"\n[bold]Agent {pod.id[:12]}[/bold]")
228
+ console.print(f" Pod: {pod.pod_name}")
229
+ console.print(f" State: {pod.state}")
230
+ console.print(f" Desktop: {pod.vnc_url}")
231
+ console.print(f" Shell: {pod.shell_url}")
232
+ console.print(f" Created: {pod.created_at}")
233
+ if pod.started_at:
234
+ console.print(f" Started: {pod.started_at}")
235
+ if pod.stopped_at:
236
+ console.print(f" Stopped: {pod.stopped_at}")
237
+ if pod.jwt_expires_at:
238
+ console.print(f" JWT Expires: {pod.jwt_expires_at}")
239
+ if pod.last_error:
240
+ console.print(f" Error: [red]{pod.last_error}[/red]")
241
+
242
+ if pod.is_running and pod.executor_url:
243
+ try:
244
+ health = agents.health(pod)
245
+ console.print(f"\n[bold]Executor:[/bold]")
246
+ console.print(f" Status: {health.get('status', 'unknown')}")
247
+ console.print(f" Disk Free: {health.get('disk_free_mb', '?')} MB")
248
+ except Exception as e:
249
+ console.print(f"\n[dim]Executor not reachable: {e}[/dim]")
250
+
251
+
252
+ @app.command("start")
253
+ def start(
254
+ agent_id: str = typer.Argument(..., help="Agent ID (or prefix)"),
255
+ ):
256
+ """Start a previously stopped agent."""
257
+ agent_id = _resolve_agent(agent_id)
258
+ agents = _get_agents_client()
259
+
260
+ try:
261
+ pod = agents.start(agent_id)
262
+ except Exception as e:
263
+ console.print(f"[red]❌ Failed to start agent: {e}[/red]")
264
+ raise typer.Exit(1)
265
+
266
+ _save_pod_state(pod)
267
+ console.print(f"[green]✓[/green] Agent starting: {pod.pod_name}")
268
+ console.print(f" Desktop: {pod.vnc_url}")
269
+
270
+
271
+ @app.command("stop")
272
+ def stop(
273
+ agent_id: str = typer.Argument(..., help="Agent ID (or prefix)"),
274
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
275
+ ):
276
+ """Stop an agent (keeps DB record, destroys pod)."""
277
+ agent_id = _resolve_agent(agent_id)
278
+
279
+ if not force:
280
+ confirm = typer.confirm(f"Stop agent {agent_id[:12]}?")
281
+ if not confirm:
282
+ raise typer.Exit(0)
283
+
284
+ agents = _get_agents_client()
285
+
286
+ try:
287
+ pod = agents.stop(agent_id)
288
+ except Exception as e:
289
+ console.print(f"[red]❌ Failed to stop agent: {e}[/red]")
290
+ raise typer.Exit(1)
291
+
292
+ _save_pod_state(pod)
293
+ console.print(f"[green]✅ Agent stopped[/green]")
294
+ console.print(f"Restart with: [bold]hyper agents start {agent_id[:8]}[/bold]")
295
+
296
+
297
+ @app.command("delete")
298
+ def delete(
299
+ agent_id: str = typer.Argument(..., help="Agent ID (or prefix)"),
300
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
301
+ ):
302
+ """Delete an agent entirely (pod + record)."""
303
+ agent_id = _resolve_agent(agent_id)
304
+
305
+ if not force:
306
+ confirm = typer.confirm(f"Permanently delete agent {agent_id[:12]}?")
307
+ if not confirm:
308
+ raise typer.Exit(0)
309
+
310
+ agents = _get_agents_client()
311
+
312
+ try:
313
+ agents.delete(agent_id)
314
+ except Exception as e:
315
+ console.print(f"[red]❌ Failed to delete agent: {e}[/red]")
316
+ raise typer.Exit(1)
317
+
318
+ _remove_pod_state(agent_id)
319
+ console.print(f"[green]✅ Agent {agent_id[:12]} deleted[/green]")
320
+
321
+
322
+ @app.command("exec")
323
+ def exec_cmd(
324
+ agent_id: str = typer.Argument(..., help="Agent ID (or prefix)"),
325
+ command: str = typer.Argument(..., help="Command to execute"),
326
+ timeout: int = typer.Option(30, "--timeout", "-t", help="Command timeout (seconds)"),
327
+ ):
328
+ """Execute a command on an agent pod."""
329
+ agent_id = _resolve_agent(agent_id)
330
+
331
+ try:
332
+ pod = _get_pod_with_token(agent_id)
333
+ except Exception as e:
334
+ console.print(f"[red]❌ Failed to get agent: {e}[/red]")
335
+ raise typer.Exit(1)
336
+
337
+ agents = _get_agents_client()
338
+
339
+ try:
340
+ result = agents.exec(pod, command, timeout=timeout)
341
+ except Exception as e:
342
+ console.print(f"[red]❌ Exec failed: {e}[/red]")
343
+ raise typer.Exit(1)
344
+
345
+ if result.stdout:
346
+ sys.stdout.write(result.stdout)
347
+ if not result.stdout.endswith("\n"):
348
+ sys.stdout.write("\n")
349
+ if result.stderr:
350
+ sys.stderr.write(result.stderr)
351
+ if not result.stderr.endswith("\n"):
352
+ sys.stderr.write("\n")
353
+
354
+ raise typer.Exit(result.exit_code)
355
+
356
+
357
+ @app.command("shell")
358
+ def shell(
359
+ agent_id: str = typer.Argument(..., help="Agent ID (or prefix)"),
360
+ ):
361
+ """Open an interactive shell on an agent pod (WebSocket PTY).
362
+
363
+ Connects to the executor's /shell endpoint. Press Ctrl+] to disconnect.
364
+ """
365
+ agent_id = _resolve_agent(agent_id)
366
+
367
+ try:
368
+ pod = _get_pod_with_token(agent_id)
369
+ except Exception as e:
370
+ console.print(f"[red]❌ Failed to get agent: {e}[/red]")
371
+ raise typer.Exit(1)
372
+
373
+ if not pod.executor_url:
374
+ console.print("[red]❌ Agent has no executor URL[/red]")
375
+ raise typer.Exit(1)
376
+
377
+ ws_url = pod.executor_url.replace("https://", "wss://").replace("http://", "ws://")
378
+ ws_url = f"{ws_url}/shell"
379
+
380
+ console.print(f"[dim]Connecting to {ws_url}...[/dim]")
381
+
382
+ try:
383
+ import websockets
384
+ import asyncio
385
+ import termios
386
+ import tty
387
+ except ImportError:
388
+ console.print("[red]❌ 'websockets' required: pip install websockets[/red]")
389
+ raise typer.Exit(1)
390
+
391
+ async def _run_shell():
392
+ headers = {}
393
+ if pod.jwt_token:
394
+ headers["Authorization"] = f"Bearer {pod.jwt_token}"
395
+ headers["Cookie"] = f"{pod.pod_name}-token={pod.jwt_token}"
396
+
397
+ async with websockets.connect(ws_url, additional_headers=headers) as ws:
398
+ console.print("[green]Connected.[/green] Ctrl+] to disconnect.\n")
399
+
400
+ old_settings = termios.tcgetattr(sys.stdin)
401
+ try:
402
+ tty.setraw(sys.stdin.fileno())
403
+
404
+ import shutil
405
+ cols, rows = shutil.get_terminal_size()
406
+ await ws.send(f"\x1b[8;{rows};{cols}t")
407
+
408
+ async def read_ws():
409
+ try:
410
+ async for msg in ws:
411
+ if isinstance(msg, str):
412
+ sys.stdout.write(msg)
413
+ sys.stdout.flush()
414
+ elif isinstance(msg, bytes):
415
+ sys.stdout.buffer.write(msg)
416
+ sys.stdout.buffer.flush()
417
+ except websockets.ConnectionClosed:
418
+ pass
419
+
420
+ async def read_stdin():
421
+ loop = asyncio.get_event_loop()
422
+ try:
423
+ while True:
424
+ data = await loop.run_in_executor(None, lambda: os.read(sys.stdin.fileno(), 1024))
425
+ if not data:
426
+ break
427
+ if b"\x1d" in data:
428
+ break
429
+ await ws.send(data.decode(errors="replace"))
430
+ except (websockets.ConnectionClosed, OSError):
431
+ pass
432
+
433
+ done, pending = await asyncio.wait(
434
+ [asyncio.create_task(read_ws()), asyncio.create_task(read_stdin())],
435
+ return_when=asyncio.FIRST_COMPLETED,
436
+ )
437
+ for t in pending:
438
+ t.cancel()
439
+ finally:
440
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
441
+ console.print("\n[dim]Disconnected.[/dim]")
442
+
443
+ try:
444
+ asyncio.run(_run_shell())
445
+ except KeyboardInterrupt:
446
+ console.print("\n[dim]Disconnected.[/dim]")
447
+ except Exception as e:
448
+ console.print(f"[red]❌ Shell failed: {e}[/red]")
449
+ raise typer.Exit(1)
450
+
451
+
452
+ @app.command("logs")
453
+ def logs(
454
+ agent_id: str = typer.Argument(..., help="Agent ID (or prefix)"),
455
+ lines: int = typer.Option(100, "-n", "--lines", help="Number of lines to show"),
456
+ follow: bool = typer.Option(True, "-f/--no-follow", help="Follow log output"),
457
+ ):
458
+ """Stream logs from an agent pod."""
459
+ agent_id = _resolve_agent(agent_id)
460
+
461
+ try:
462
+ pod = _get_pod_with_token(agent_id)
463
+ except Exception as e:
464
+ console.print(f"[red]❌ Failed to get agent: {e}[/red]")
465
+ raise typer.Exit(1)
466
+
467
+ agents = _get_agents_client()
468
+
469
+ try:
470
+ for line in agents.logs_stream(pod, lines=lines, follow=follow):
471
+ console.print(line)
472
+ except KeyboardInterrupt:
473
+ pass
474
+ except Exception as e:
475
+ console.print(f"[red]❌ Logs failed: {e}[/red]")
476
+ raise typer.Exit(1)
477
+
478
+
479
+ @app.command("chat")
480
+ def chat(
481
+ agent_id: str = typer.Argument(..., help="Agent ID (or prefix)"),
482
+ model: str = typer.Option("hyperclaw/kimi-k2.5", "--model", "-m", help="Model to use"),
483
+ ):
484
+ """Interactive chat with an agent's OpenClaw instance.
485
+
486
+ Connects to the OpenClaw gateway running inside the agent pod.
487
+ Type your messages, get streaming responses. Ctrl+C or 'exit' to quit.
488
+ """
489
+ agent_id = _resolve_agent(agent_id)
490
+
491
+ try:
492
+ pod = _get_pod_with_token(agent_id)
493
+ except Exception as e:
494
+ console.print(f"[red]❌ Failed to get agent: {e}[/red]")
495
+ raise typer.Exit(1)
496
+
497
+ agents = _get_agents_client()
498
+ messages = []
499
+
500
+ console.print(f"\n[bold]Chat with agent {pod.pod_name}[/bold] (model: {model})")
501
+ console.print("[dim]Type your message. 'exit' or Ctrl+C to quit.[/dim]\n")
502
+
503
+ while True:
504
+ try:
505
+ user_input = console.input("[bold cyan]> [/bold cyan]")
506
+ except (EOFError, KeyboardInterrupt):
507
+ console.print("\n[dim]Bye.[/dim]")
508
+ break
509
+
510
+ user_input = user_input.strip()
511
+ if not user_input:
512
+ continue
513
+ if user_input.lower() in ("exit", "quit", "/exit", "/quit"):
514
+ console.print("[dim]Bye.[/dim]")
515
+ break
516
+
517
+ messages.append({"role": "user", "content": user_input})
518
+
519
+ try:
520
+ full_response = ""
521
+ for chunk in agents.chat_stream(pod, messages, model=model):
522
+ sys.stdout.write(chunk)
523
+ sys.stdout.flush()
524
+ full_response += chunk
525
+ sys.stdout.write("\n\n")
526
+ sys.stdout.flush()
527
+
528
+ messages.append({"role": "assistant", "content": full_response})
529
+ except KeyboardInterrupt:
530
+ sys.stdout.write("\n")
531
+ continue
532
+ except Exception as e:
533
+ console.print(f"\n[red]Error: {e}[/red]\n")
534
+ # Remove failed user message
535
+ messages.pop()
536
+
537
+
538
+ @app.command("token")
539
+ def token(
540
+ agent_id: str = typer.Argument(..., help="Agent ID (or prefix)"),
541
+ ):
542
+ """Refresh the JWT token for an agent."""
543
+ agent_id = _resolve_agent(agent_id)
544
+ agents = _get_agents_client()
545
+
546
+ try:
547
+ result = agents.refresh_token(agent_id)
548
+ except Exception as e:
549
+ console.print(f"[red]❌ Failed to refresh token: {e}[/red]")
550
+ raise typer.Exit(1)
551
+
552
+ state = _load_state()
553
+ if agent_id in state:
554
+ state[agent_id]["jwt_token"] = result.get("token", "")
555
+ with open(AGENTS_STATE, "w") as f:
556
+ json.dump(state, f, indent=2, default=str)
557
+
558
+ console.print(f"[green]✅ Token refreshed[/green]")
559
+ console.print(f" Expires: {result.get('expires_at', 'unknown')}")
@@ -8,12 +8,14 @@ from rich.console import Console
8
8
  from rich.table import Table
9
9
 
10
10
  from .onboard import onboard as _onboard_fn
11
+ from .voice import app as voice_app
11
12
 
12
13
  app = typer.Typer(help="HyperClaw inference commands")
13
14
  console = Console()
14
15
 
15
- # Register onboard as a subcommand
16
+ # Register subcommands
16
17
  app.command("onboard")(_onboard_fn)
18
+ app.add_typer(voice_app, name="voice")
17
19
 
18
20
  # Check if wallet dependencies are available
19
21
  try:
@@ -194,7 +196,19 @@ async def _subscribe_async(account, plan_id: str, api_base: str, amount: str = N
194
196
  payment_headers = http_client.encode_payment_signature_header(payment_payload)
195
197
  console.print(f"[green]✓[/green] Payment signed")
196
198
 
197
- # Step 6: Retry with payment
199
+ # Step 6: Retry with payment (include JWT if available from claw login)
200
+ jwt_path = HYPERCLI_DIR / "claw-jwt.json"
201
+ if jwt_path.exists():
202
+ try:
203
+ with open(jwt_path) as f:
204
+ jwt_data = json.load(f)
205
+ jwt_token = jwt_data.get("token", "")
206
+ if jwt_token:
207
+ payment_headers["Authorization"] = f"Bearer {jwt_token}"
208
+ console.print("[green]✓[/green] Attaching user auth (from claw login)")
209
+ except Exception:
210
+ pass
211
+
198
212
  console.print("[bold]Sending payment...[/bold]")
199
213
  retry_response = await http.post(url, headers=payment_headers)
200
214
 
@@ -355,6 +369,134 @@ def models(
355
369
  console.print(f"Source: {url}")
356
370
 
357
371
 
372
+ @app.command("login")
373
+ def login(
374
+ api_url: str = typer.Option(None, "--api-url", help="API base URL override"),
375
+ ):
376
+ """Login to HyperClaw with your wallet.
377
+
378
+ Signs a challenge message with your wallet key to authenticate,
379
+ then creates a user-bound API key for agent management.
380
+
381
+ Prerequisite: hyper wallet create (if you don't have a wallet yet)
382
+
383
+ Flow:
384
+ 1. Signs a challenge with your wallet private key
385
+ 2. Backend verifies signature, creates/finds your user
386
+ 3. Creates an API key bound to your user account
387
+ 4. Saves the key to ~/.hypercli/claw-key.json
388
+
389
+ After login, you can use:
390
+ hyper agents create Launch an OpenClaw agent pod
391
+ hyper agents list List your agents
392
+ hyper claw config Generate provider configs
393
+ """
394
+ try:
395
+ from eth_account.messages import encode_defunct
396
+ from eth_account import Account
397
+ except ImportError:
398
+ console.print("[red]❌ Wallet dependencies required[/red]")
399
+ console.print("Install with: [bold]pip install 'hypercli-cli[wallet]'[/bold]")
400
+ raise typer.Exit(1)
401
+
402
+ import httpx
403
+
404
+ # Import wallet loader from wallet module
405
+ from .wallet import load_wallet
406
+
407
+ base_url = (api_url or PROD_API_BASE).rstrip("/")
408
+
409
+ # Step 1: Load wallet
410
+ account = load_wallet()
411
+ console.print(f"\n[green]✓[/green] Wallet: [bold]{account.address}[/bold]\n")
412
+
413
+ # Step 2: Get challenge
414
+ console.print("[bold]Requesting challenge...[/bold]")
415
+ with httpx.Client(timeout=15) as client:
416
+ resp = client.post(
417
+ f"{base_url}/api/auth/wallet/challenge",
418
+ json={"wallet": account.address},
419
+ )
420
+ if resp.status_code != 200:
421
+ console.print(f"[red]❌ Challenge failed: {resp.text}[/red]")
422
+ raise typer.Exit(1)
423
+ challenge = resp.json()
424
+
425
+ # Step 3: Sign
426
+ console.print("[bold]Signing...[/bold]")
427
+ message = encode_defunct(text=challenge["message"])
428
+ signed = account.sign_message(message)
429
+
430
+ # Step 4: Verify signature and login
431
+ console.print("[bold]Authenticating...[/bold]")
432
+ with httpx.Client(timeout=15) as client:
433
+ resp = client.post(
434
+ f"{base_url}/api/auth/wallet/login",
435
+ json={
436
+ "wallet": account.address,
437
+ "signature": signed.signature.hex(),
438
+ "timestamp": challenge["timestamp"],
439
+ },
440
+ )
441
+ if resp.status_code != 200:
442
+ console.print(f"[red]❌ Login failed: {resp.text}[/red]")
443
+ raise typer.Exit(1)
444
+ login_data = resp.json()
445
+ jwt_token = login_data["token"]
446
+
447
+ console.print("[green]✓[/green] Authenticated\n")
448
+
449
+ user_id = login_data.get("user_id", "")
450
+ team_id = login_data.get("team_id", "")
451
+ wallet_addr = login_data.get("wallet_address", account.address)
452
+
453
+ # Step 5: Create a claw API key using the JWT
454
+ console.print("[bold]Creating API key...[/bold]")
455
+ with httpx.Client(timeout=15) as client:
456
+ resp = client.post(
457
+ f"{base_url}/api/keys",
458
+ json={"name": "claw-cli"},
459
+ headers={"Authorization": f"Bearer {jwt_token}"},
460
+ )
461
+ if resp.status_code != 200:
462
+ # Save JWT anyway so user can still auth
463
+ jwt_path = HYPERCLI_DIR / "claw-jwt.json"
464
+ HYPERCLI_DIR.mkdir(parents=True, exist_ok=True)
465
+ with open(jwt_path, "w") as f:
466
+ json.dump({"token": jwt_token, "user_id": user_id, "team_id": team_id}, f, indent=2)
467
+ console.print(f"[yellow]⚠ Key creation failed: {resp.text}[/yellow]")
468
+ console.print(f"[green]✓[/green] JWT saved to {jwt_path} (use for direct auth)")
469
+ raise typer.Exit(1)
470
+
471
+ key_data = resp.json()
472
+
473
+ api_key = key_data.get("api_key", key_data.get("key", ""))
474
+
475
+ # Step 6: Save as claw key
476
+ HYPERCLI_DIR.mkdir(parents=True, exist_ok=True)
477
+ claw_key_data = {
478
+ "key": api_key,
479
+ "plan_id": login_data.get("plan_id", "free"),
480
+ "user_id": user_id,
481
+ "team_id": team_id,
482
+ "wallet_address": wallet_addr,
483
+ "tpm_limit": 0,
484
+ "rpm_limit": 0,
485
+ "expires_at": "",
486
+ }
487
+ with open(CLAW_KEY_PATH, "w") as f:
488
+ json.dump(claw_key_data, f, indent=2)
489
+
490
+ console.print(f"[green]✓[/green] API key saved to [bold]{CLAW_KEY_PATH}[/bold]\n")
491
+ console.print(f" User: {user_id[:12]}...")
492
+ console.print(f" Team: {team_id[:12]}...")
493
+ console.print(f" Key: {api_key[:20]}...")
494
+ console.print(f" Wallet: {wallet_addr}")
495
+ console.print(f"\n[green]You're all set![/green]")
496
+ console.print(f" Launch agent: [bold]hyper agents create[/bold]")
497
+ console.print(f" Configure: [bold]hyper claw config openclaw --apply[/bold]")
498
+
499
+
358
500
  OPENCLAW_CONFIG_PATH = Path.home() / ".openclaw" / "openclaw.json"
359
501
 
360
502
 
@@ -7,7 +7,7 @@ from rich.prompt import Prompt
7
7
  from hypercli import HyperCLI, APIError, configure
8
8
  from hypercli.config import CONFIG_FILE
9
9
 
10
- from . import billing, claw, comfyui, flow, instances, jobs, keys, user, wallet
10
+ from . import agents, billing, claw, comfyui, flow, instances, jobs, keys, user, wallet
11
11
 
12
12
  console = Console()
13
13
 
@@ -56,6 +56,7 @@ app = typer.Typer(
56
56
  )
57
57
 
58
58
  # Register subcommands
59
+ app.add_typer(agents.app, name="agents")
59
60
  app.add_typer(billing.app, name="billing")
60
61
  app.add_typer(claw.app, name="claw")
61
62
  app.add_typer(comfyui.app, name="comfyui")
@@ -195,6 +195,7 @@ def launch(
195
195
  lb_auth: bool = typer.Option(False, "--lb-auth", help="Enable auth on load balancer"),
196
196
  registry_user: Optional[str] = typer.Option(None, "--registry-user", help="Private registry username"),
197
197
  registry_password: Optional[str] = typer.Option(None, "--registry-password", help="Private registry password"),
198
+ dockerfile: Optional[str] = typer.Option(None, "--dockerfile", "-d", help="Path to Dockerfile (built on GPU node, overrides image as base)"),
198
199
  x402: bool = typer.Option(False, "--x402", help="Pay per-use via embedded x402 wallet"),
199
200
  amount: Optional[float] = typer.Option(None, "--amount", help="USDC amount to spend with --x402"),
200
201
  follow: bool = typer.Option(False, "--follow", "-f", help="Follow logs after creation"),
@@ -234,6 +235,16 @@ def launch(
234
235
  if registry_user and registry_password:
235
236
  registry_auth = {"username": registry_user, "password": registry_password}
236
237
 
238
+ # Read and base64-encode Dockerfile if provided
239
+ dockerfile_b64 = None
240
+ if dockerfile:
241
+ import base64
242
+ from pathlib import Path
243
+ df_path = Path(dockerfile)
244
+ if not df_path.exists():
245
+ raise typer.BadParameter(f"Dockerfile not found: {dockerfile}")
246
+ dockerfile_b64 = base64.b64encode(df_path.read_bytes()).decode()
247
+
237
248
  # Auto-wrap command in sh -c if it contains shell operators
238
249
  if command and any(op in command for op in ["&&", "||", "|", ";", ">", "<", "$"]):
239
250
  command = f'sh -c "{command}"'
@@ -310,6 +321,7 @@ def launch(
310
321
  ports=ports_dict,
311
322
  auth=lb_auth,
312
323
  registry_auth=registry_auth,
324
+ dockerfile=dockerfile_b64,
313
325
  )
314
326
 
315
327
  if fmt == "json":
@@ -0,0 +1,155 @@
1
+ """HyperClaw Voice API commands — TTS, clone, design"""
2
+ import base64
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ app = typer.Typer(help="Voice API — text-to-speech, voice cloning, voice design")
12
+ console = Console()
13
+
14
+ HYPERCLI_DIR = Path.home() / ".hypercli"
15
+ CLAW_KEY_PATH = HYPERCLI_DIR / "claw-key.json"
16
+ PROD_API_BASE = "https://api.hyperclaw.app"
17
+ DEV_API_BASE = "https://dev-api.hyperclaw.app"
18
+
19
+
20
+ def _get_api_key(key: str | None) -> str:
21
+ """Resolve API key from flag or saved claw key."""
22
+ if key:
23
+ return key
24
+ if CLAW_KEY_PATH.exists():
25
+ with open(CLAW_KEY_PATH) as f:
26
+ k = json.load(f).get("key", "")
27
+ if k:
28
+ return k
29
+ console.print("[red]❌ No API key found.[/red]")
30
+ console.print("Pass [bold]--key sk-...[/bold] or run [bold]hyper claw subscribe[/bold]")
31
+ raise typer.Exit(1)
32
+
33
+
34
+ def _post_voice(
35
+ endpoint: str,
36
+ payload: dict,
37
+ api_key: str,
38
+ output: Path,
39
+ dev: bool = False,
40
+ ):
41
+ """POST to voice endpoint and save audio output."""
42
+ api_base = DEV_API_BASE if dev else PROD_API_BASE
43
+ url = f"{api_base}/voice/{endpoint}"
44
+
45
+ console.print(f"[dim]→ POST {url}[/dim]")
46
+
47
+ try:
48
+ with httpx.Client(timeout=600.0) as client:
49
+ resp = client.post(
50
+ url,
51
+ json=payload,
52
+ headers={
53
+ "Authorization": f"Bearer {api_key}",
54
+ "Content-Type": "application/json",
55
+ },
56
+ )
57
+
58
+ if resp.status_code != 200:
59
+ console.print(f"[red]❌ {resp.status_code}: {resp.text[:500]}[/red]")
60
+ raise typer.Exit(1)
61
+
62
+ output.parent.mkdir(parents=True, exist_ok=True)
63
+ with open(output, "wb") as f:
64
+ f.write(resp.content)
65
+
66
+ size_kb = len(resp.content) / 1024
67
+ console.print(f"[green]✅ Saved {output} ({size_kb:.1f} KB)[/green]")
68
+
69
+ except httpx.HTTPError as e:
70
+ console.print(f"[red]❌ Request failed: {e}[/red]")
71
+ raise typer.Exit(1)
72
+
73
+
74
+ @app.command("tts")
75
+ def tts(
76
+ text: str = typer.Argument(..., help="Text to synthesize"),
77
+ voice: str = typer.Option("Chelsie", "--voice", "-v", help="Voice name (CustomVoice preset)"),
78
+ language: str = typer.Option("auto", "--language", "-l", help="Language: auto, english, chinese, etc."),
79
+ output: Path = typer.Option("output.wav", "--output", "-o", help="Output audio file"),
80
+ key: str = typer.Option(None, "--key", "-k", help="API key (sk-...)"),
81
+ dev: bool = typer.Option(False, "--dev", help="Use dev API"),
82
+ ):
83
+ """Generate speech from text using a preset voice.
84
+
85
+ Examples:
86
+ hyper claw voice tts "Hello world"
87
+ hyper claw voice tts "Bonjour" -v Etienne -l french -o hello.wav
88
+ """
89
+ api_key = _get_api_key(key)
90
+ payload = {
91
+ "text": text,
92
+ "voice": voice,
93
+ "language": language,
94
+ }
95
+ _post_voice("tts", payload, api_key, output, dev)
96
+
97
+
98
+ @app.command("clone")
99
+ def clone(
100
+ text: str = typer.Argument(..., help="Text to synthesize"),
101
+ ref_audio: Path = typer.Option(..., "--ref", "-r", help="Reference audio file (wav/mp3/ogg)"),
102
+ language: str = typer.Option("auto", "--language", "-l", help="Language: auto, english, chinese, etc."),
103
+ x_vector_only: bool = typer.Option(True, "--x-vector-only/--full-clone", help="Use x_vector_only mode (recommended)"),
104
+ output: Path = typer.Option("output.wav", "--output", "-o", help="Output audio file"),
105
+ key: str = typer.Option(None, "--key", "-k", help="API key (sk-...)"),
106
+ dev: bool = typer.Option(False, "--dev", help="Use dev API"),
107
+ ):
108
+ """Clone a voice from reference audio.
109
+
110
+ Examples:
111
+ hyper claw voice clone "Hello" --ref voice.wav
112
+ hyper claw voice clone "Test" -r ref.wav -l english -o cloned.wav
113
+ """
114
+ api_key = _get_api_key(key)
115
+
116
+ if not ref_audio.exists():
117
+ console.print(f"[red]❌ Reference audio not found: {ref_audio}[/red]")
118
+ raise typer.Exit(1)
119
+
120
+ with open(ref_audio, "rb") as f:
121
+ ref_b64 = base64.b64encode(f.read()).decode()
122
+
123
+ console.print(f"[dim]Reference: {ref_audio} ({ref_audio.stat().st_size / 1024:.1f} KB)[/dim]")
124
+
125
+ payload = {
126
+ "text": text,
127
+ "ref_audio_base64": ref_b64,
128
+ "language": language,
129
+ "x_vector_only": x_vector_only,
130
+ }
131
+ _post_voice("clone", payload, api_key, output, dev)
132
+
133
+
134
+ @app.command("design")
135
+ def design(
136
+ text: str = typer.Argument(..., help="Text to synthesize"),
137
+ description: str = typer.Option(..., "--desc", "-d", help="Voice description (e.g. 'young female, warm, American accent')"),
138
+ language: str = typer.Option("auto", "--language", "-l", help="Language: auto, english, chinese, etc."),
139
+ output: Path = typer.Option("output.wav", "--output", "-o", help="Output audio file"),
140
+ key: str = typer.Option(None, "--key", "-k", help="API key (sk-...)"),
141
+ dev: bool = typer.Option(False, "--dev", help="Use dev API"),
142
+ ):
143
+ """Design a voice from a text description.
144
+
145
+ Examples:
146
+ hyper claw voice design "Hello" --desc "deep male voice, British accent"
147
+ hyper claw voice design "Test" -d "young woman, cheerful" -o designed.wav
148
+ """
149
+ api_key = _get_api_key(key)
150
+ payload = {
151
+ "text": text,
152
+ "instruct": description,
153
+ "language": language,
154
+ }
155
+ _post_voice("design", payload, api_key, output, dev)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hypercli-cli"
7
- version = "0.8.13"
7
+ version = "0.9.0"
8
8
  description = "CLI for HyperCLI - GPU orchestration and LLM API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes