hypercli-cli 0.9.2__tar.gz → 1.0.1__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.9.2 → hypercli_cli-1.0.1}/PKG-INFO +4 -4
  2. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/agents.py +231 -40
  3. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/instances.py +29 -8
  4. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/jobs.py +226 -0
  5. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/pyproject.toml +4 -4
  6. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/.gitignore +0 -0
  7. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/README.md +0 -0
  8. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/__init__.py +0 -0
  9. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/billing.py +0 -0
  10. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/claw.py +0 -0
  11. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/cli.py +0 -0
  12. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/comfyui.py +0 -0
  13. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/flow.py +0 -0
  14. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/keys.py +0 -0
  15. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/onboard.py +0 -0
  16. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/output.py +0 -0
  17. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/renders.py +0 -0
  18. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/tui/__init__.py +0 -0
  19. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/tui/job_monitor.py +0 -0
  20. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/user.py +0 -0
  21. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/voice.py +0 -0
  22. {hypercli_cli-0.9.2 → hypercli_cli-1.0.1}/hypercli_cli/wallet.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypercli-cli
3
- Version: 0.9.2
3
+ Version: 1.0.1
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
@@ -9,7 +9,7 @@ Author-email: HyperCLI <support@hypercli.com>
9
9
  License: MIT
10
10
  Requires-Python: >=3.10
11
11
  Requires-Dist: httpx>=0.27.0
12
- Requires-Dist: hypercli-sdk>=0.8.9
12
+ Requires-Dist: hypercli-sdk>=1.0.0
13
13
  Requires-Dist: mutagen>=1.47.0
14
14
  Requires-Dist: pyyaml>=6.0
15
15
  Requires-Dist: rich>=14.2.0
@@ -18,11 +18,11 @@ Requires-Dist: websocket-client>=1.6.0
18
18
  Provides-Extra: all
19
19
  Requires-Dist: argon2-cffi>=25.0.0; extra == 'all'
20
20
  Requires-Dist: eth-account>=0.13.0; extra == 'all'
21
- Requires-Dist: hypercli-sdk[comfyui]>=0.8.9; extra == 'all'
21
+ Requires-Dist: hypercli-sdk[comfyui]>=1.0.0; extra == 'all'
22
22
  Requires-Dist: web3>=7.0.0; extra == 'all'
23
23
  Requires-Dist: x402[evm,httpx]>=2.0.0; extra == 'all'
24
24
  Provides-Extra: comfyui
25
- Requires-Dist: hypercli-sdk[comfyui]>=0.8.9; extra == 'comfyui'
25
+ Requires-Dist: hypercli-sdk[comfyui]>=1.0.0; extra == 'comfyui'
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: pytest>=8.0.0; extra == 'dev'
28
28
  Requires-Dist: ruff>=0.3.0; extra == 'dev'
@@ -137,7 +137,7 @@ def budget():
137
137
 
138
138
  @app.command("create")
139
139
  def create(
140
- name: str = typer.Option("agent", "--name", "-n", help="Agent name"),
140
+ name: str = typer.Option(None, "--name", "-n", help="Agent name (auto-generated if omitted, becomes {name}.hyperclaw.app)"),
141
141
  size: str = typer.Option(None, "--size", "-s", help="Size preset: small, medium, large"),
142
142
  cpu: int = typer.Option(None, "--cpu", help="Custom CPU in cores"),
143
143
  memory: int = typer.Option(None, "--memory", help="Custom memory in GB"),
@@ -399,41 +399,26 @@ def shell(
399
399
  ):
400
400
  """Open an interactive shell on an agent pod (WebSocket PTY).
401
401
 
402
- Connects to the executor's /shell endpoint. Press Ctrl+] to disconnect.
402
+ Connects via the HyperClaw backend WebSocket proxy. Press Ctrl+] to disconnect.
403
403
  """
404
404
  agent_id = _resolve_agent(agent_id)
405
+ agents = _get_agents_client()
405
406
 
406
- try:
407
- pod = _get_pod_with_token(agent_id)
408
- except Exception as e:
409
- console.print(f"[red]❌ Failed to get agent: {e}[/red]")
410
- raise typer.Exit(1)
411
-
412
- if not pod.executor_url:
413
- console.print("[red]❌ Agent has no executor URL[/red]")
414
- raise typer.Exit(1)
415
-
416
- ws_url = pod.executor_url.replace("https://", "wss://").replace("http://", "ws://")
417
- ws_url = f"{ws_url}/shell"
418
-
419
- console.print(f"[dim]Connecting to {ws_url}...[/dim]")
407
+ console.print(f"[dim]Connecting to shell...[/dim]")
420
408
 
421
409
  try:
422
- import websockets
423
410
  import asyncio
424
411
  import termios
425
412
  import tty
426
413
  except ImportError:
427
- console.print("[red]❌ 'websockets' required: pip install websockets[/red]")
414
+ console.print("[red]❌ TTY libraries required[/red]")
428
415
  raise typer.Exit(1)
429
416
 
430
417
  async def _run_shell():
431
- headers = {}
432
- if pod.jwt_token:
433
- headers["Authorization"] = f"Bearer {pod.jwt_token}"
434
- headers["Cookie"] = f"{pod.pod_name}-token={pod.jwt_token}"
418
+ # Connect via backend WebSocket
419
+ ws = await agents.shell_connect(agent_id)
435
420
 
436
- async with websockets.connect(ws_url, additional_headers=headers) as ws:
421
+ try:
437
422
  console.print("[green]Connected.[/green] Ctrl+] to disconnect.\n")
438
423
 
439
424
  old_settings = termios.tcgetattr(sys.stdin)
@@ -453,7 +438,7 @@ def shell(
453
438
  elif isinstance(msg, bytes):
454
439
  sys.stdout.buffer.write(msg)
455
440
  sys.stdout.buffer.flush()
456
- except websockets.ConnectionClosed:
441
+ except Exception:
457
442
  pass
458
443
 
459
444
  async def read_stdin():
@@ -463,10 +448,10 @@ def shell(
463
448
  data = await loop.run_in_executor(None, lambda: os.read(sys.stdin.fileno(), 1024))
464
449
  if not data:
465
450
  break
466
- if b"\x1d" in data:
451
+ if b"\x1d" in data: # Ctrl+]
467
452
  break
468
453
  await ws.send(data.decode(errors="replace"))
469
- except (websockets.ConnectionClosed, OSError):
454
+ except Exception:
470
455
  pass
471
456
 
472
457
  done, pending = await asyncio.wait(
@@ -478,6 +463,8 @@ def shell(
478
463
  finally:
479
464
  termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
480
465
  console.print("\n[dim]Disconnected.[/dim]")
466
+ finally:
467
+ await ws.close()
481
468
 
482
469
  try:
483
470
  asyncio.run(_run_shell())
@@ -493,26 +480,46 @@ def logs(
493
480
  agent_id: str = typer.Argument(..., help="Agent ID (or prefix)"),
494
481
  lines: int = typer.Option(100, "-n", "--lines", help="Number of lines to show"),
495
482
  follow: bool = typer.Option(True, "-f/--no-follow", help="Follow log output"),
483
+ ws: bool = typer.Option(False, "--ws", help="Use WebSocket instead of SSE (via backend)"),
496
484
  ):
497
485
  """Stream logs from an agent pod."""
498
486
  agent_id = _resolve_agent(agent_id)
487
+ agents = _get_agents_client()
499
488
 
500
- try:
501
- pod = _get_pod_with_token(agent_id)
502
- except Exception as e:
503
- console.print(f"[red]❌ Failed to get agent: {e}[/red]")
504
- raise typer.Exit(1)
489
+ if ws:
490
+ # WebSocket mode via backend
491
+ import asyncio
505
492
 
506
- agents = _get_agents_client()
493
+ async def _stream_ws():
494
+ try:
495
+ async for line in agents.logs_stream_ws(agent_id, tail_lines=lines):
496
+ console.print(line)
497
+ except KeyboardInterrupt:
498
+ pass
499
+ except Exception as e:
500
+ console.print(f"[red]❌ Logs failed: {e}[/red]")
501
+ raise typer.Exit(1)
507
502
 
508
- try:
509
- for line in agents.logs_stream(pod, lines=lines, follow=follow):
510
- console.print(line)
511
- except KeyboardInterrupt:
512
- pass
513
- except Exception as e:
514
- console.print(f"[red]❌ Logs failed: {e}[/red]")
515
- raise typer.Exit(1)
503
+ try:
504
+ asyncio.run(_stream_ws())
505
+ except KeyboardInterrupt:
506
+ pass
507
+ else:
508
+ # SSE mode via executor (legacy)
509
+ try:
510
+ pod = _get_pod_with_token(agent_id)
511
+ except Exception as e:
512
+ console.print(f"[red]❌ Failed to get agent: {e}[/red]")
513
+ raise typer.Exit(1)
514
+
515
+ try:
516
+ for line in agents.logs_stream(pod, lines=lines, follow=follow):
517
+ console.print(line)
518
+ except KeyboardInterrupt:
519
+ pass
520
+ except Exception as e:
521
+ console.print(f"[red]❌ Logs failed: {e}[/red]")
522
+ raise typer.Exit(1)
516
523
 
517
524
 
518
525
  @app.command("chat")
@@ -596,3 +603,187 @@ def token(
596
603
 
597
604
  console.print(f"[green]✅ Token refreshed[/green]")
598
605
  console.print(f" Expires: {result.get('expires_at', 'unknown')}")
606
+
607
+
608
+ # ---------------------------------------------------------------------------
609
+ # Gateway commands (OpenClaw Gateway RPC via WebSocket)
610
+ # ---------------------------------------------------------------------------
611
+
612
+ def _run_async(coro):
613
+ """Run an async coroutine from sync CLI."""
614
+ import asyncio
615
+ return asyncio.run(coro)
616
+
617
+
618
+ @app.command("config")
619
+ def gateway_config(
620
+ agent_id: str = typer.Argument(None, help="Agent ID or name"),
621
+ schema: bool = typer.Option(False, "--schema", help="Show config schema instead of current config"),
622
+ ):
623
+ """Get the OpenClaw gateway config for an agent."""
624
+ from hypercli.gateway import GatewayClient
625
+
626
+ pod = _get_pod_with_token(agent_id)
627
+
628
+ async def _run():
629
+ async with pod.gateway() as gw:
630
+ if schema:
631
+ result = await gw.config_schema()
632
+ else:
633
+ result = await gw.config_get()
634
+ console.print_json(json.dumps(result, default=str))
635
+
636
+ _run_async(_run())
637
+
638
+
639
+ @app.command("config-patch")
640
+ def gateway_config_patch(
641
+ agent_id: str = typer.Argument(..., help="Agent ID or name"),
642
+ patch: str = typer.Argument(..., help="JSON patch to apply"),
643
+ ):
644
+ """Patch the OpenClaw gateway config (merges with existing). Restarts gateway."""
645
+ from hypercli.gateway import GatewayClient
646
+
647
+ pod = _get_pod_with_token(agent_id)
648
+ patch_data = json.loads(patch)
649
+
650
+ async def _run():
651
+ async with pod.gateway() as gw:
652
+ result = await gw.config_patch(patch_data)
653
+ console.print("[green]✅ Config patched. Gateway restarting.[/green]")
654
+
655
+ _run_async(_run())
656
+
657
+
658
+ @app.command("models")
659
+ def gateway_models(
660
+ agent_id: str = typer.Argument(None, help="Agent ID or name"),
661
+ ):
662
+ """List available models on an agent's gateway."""
663
+ from hypercli.gateway import GatewayClient
664
+
665
+ pod = _get_pod_with_token(agent_id)
666
+
667
+ async def _run():
668
+ async with pod.gateway() as gw:
669
+ models = await gw.models_list()
670
+ if not models:
671
+ console.print("[dim]No models configured[/dim]")
672
+ return
673
+ for m in models:
674
+ ctx = m.get("contextWindow", "?")
675
+ console.print(f" {m['provider']}/{m['name']} (ctx={ctx})")
676
+
677
+ _run_async(_run())
678
+
679
+
680
+ @app.command("files")
681
+ def gateway_files(
682
+ agent_id: str = typer.Argument(None, help="Agent ID or name"),
683
+ get: str = typer.Option(None, "--get", help="Read a specific file"),
684
+ set_file: str = typer.Option(None, "--set", help="Write a file (name=content)"),
685
+ ):
686
+ """List or read/write workspace files on an agent via Gateway."""
687
+ from hypercli.gateway import GatewayClient
688
+
689
+ pod = _get_pod_with_token(agent_id)
690
+
691
+ async def _run():
692
+ async with pod.gateway() as gw:
693
+ # Get the default agent ID from the gateway
694
+ agents = await gw.agents_list()
695
+ gw_agent_id = agents[0]["id"] if agents else "main"
696
+
697
+ if get:
698
+ content = await gw.file_get(gw_agent_id, get)
699
+ console.print(content)
700
+ elif set_file:
701
+ name, _, content = set_file.partition("=")
702
+ if not content:
703
+ console.print("[red]Usage: --set 'SOUL.md=# My Agent'[/red]")
704
+ raise typer.Exit(1)
705
+ await gw.file_set(gw_agent_id, name, content)
706
+ console.print(f"[green]✅ Written {name}[/green]")
707
+ else:
708
+ files = await gw.files_list(gw_agent_id)
709
+ if not files:
710
+ console.print("[dim]No workspace files[/dim]")
711
+ return
712
+ for f in files:
713
+ icon = "📄" if not f.get("missing") else "❌"
714
+ size = f.get("size", 0)
715
+ console.print(f" {icon} {f['name']:30s} {size:>8,} bytes")
716
+
717
+ _run_async(_run())
718
+
719
+
720
+ @app.command("sessions")
721
+ def gateway_sessions(
722
+ agent_id: str = typer.Argument(None, help="Agent ID or name"),
723
+ limit: int = typer.Option(20, "--limit", "-n"),
724
+ ):
725
+ """List chat sessions on an agent's gateway."""
726
+ from hypercli.gateway import GatewayClient
727
+
728
+ pod = _get_pod_with_token(agent_id)
729
+
730
+ async def _run():
731
+ async with pod.gateway() as gw:
732
+ sessions = await gw.sessions_list(limit=limit)
733
+ if not sessions:
734
+ console.print("[dim]No sessions[/dim]")
735
+ return
736
+ for s in sessions:
737
+ console.print(f" {s.get('key','?'):20s} {s.get('status','?'):10s} {s.get('lastActivity','')}")
738
+
739
+ _run_async(_run())
740
+
741
+
742
+ @app.command("cron")
743
+ def gateway_cron(
744
+ agent_id: str = typer.Argument(None, help="Agent ID or name"),
745
+ ):
746
+ """List cron jobs on an agent's gateway."""
747
+ from hypercli.gateway import GatewayClient
748
+
749
+ pod = _get_pod_with_token(agent_id)
750
+
751
+ async def _run():
752
+ async with pod.gateway() as gw:
753
+ jobs = await gw.cron_list()
754
+ if not jobs:
755
+ console.print("[dim]No cron jobs[/dim]")
756
+ return
757
+ for j in jobs:
758
+ enabled = "✅" if j.get("enabled", True) else "⏸️"
759
+ console.print(f" {enabled} {j.get('id','?'):20s} {j.get('name','unnamed'):20s} {j.get('schedule','')}")
760
+
761
+ _run_async(_run())
762
+
763
+
764
+ @app.command("gateway-chat")
765
+ def gateway_chat(
766
+ agent_id: str = typer.Argument(None, help="Agent ID or name"),
767
+ message: str = typer.Argument(..., help="Message to send"),
768
+ ):
769
+ """Send a chat message to an agent via the Gateway and stream the response."""
770
+ from hypercli.gateway import GatewayClient, ChatEvent
771
+
772
+ pod = _get_pod_with_token(agent_id)
773
+
774
+ async def _run():
775
+ async with pod.gateway() as gw:
776
+ async for event in gw.chat_send(message):
777
+ if event.type == "content":
778
+ print(event.text, end="", flush=True)
779
+ elif event.type == "thinking":
780
+ console.print(f"[dim]{event.text}[/dim]", end="")
781
+ elif event.type == "tool_call":
782
+ console.print(f"\n[yellow]🔧 {event.data}[/yellow]")
783
+ elif event.type == "error":
784
+ console.print(f"\n[red]❌ {event.text}[/red]")
785
+ elif event.type == "done":
786
+ print()
787
+ print()
788
+
789
+ _run_async(_run())
@@ -196,6 +196,7 @@ def launch(
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
198
  dockerfile: Optional[str] = typer.Option(None, "--dockerfile", "-d", help="Path to Dockerfile (built on GPU node, overrides image as base)"),
199
+ dry_run: bool = typer.Option(False, "--dry-run", help="Validate configuration without creating job or reserving funds"),
199
200
  x402: bool = typer.Option(False, "--x402", help="Pay per-use via embedded x402 wallet"),
200
201
  amount: Optional[float] = typer.Option(None, "--amount", help="USDC amount to spend with --x402"),
201
202
  follow: bool = typer.Option(False, "--follow", "-f", help="Follow logs after creation"),
@@ -308,7 +309,8 @@ def launch(
308
309
  else:
309
310
  client = get_client()
310
311
 
311
- with spinner("Launching instance..."):
312
+ spinner_msg = "Validating configuration..." if dry_run else "Launching instance..."
313
+ with spinner(spinner_msg):
312
314
  job = client.jobs.create(
313
315
  image=image,
314
316
  command=command,
@@ -322,18 +324,37 @@ def launch(
322
324
  auth=lb_auth,
323
325
  registry_auth=registry_auth,
324
326
  dockerfile=dockerfile_b64,
327
+ dry_run=dry_run,
325
328
  )
326
329
 
327
330
  if fmt == "json":
328
331
  output(job, "json")
329
332
  else:
330
- success(f"Instance launched: {job.job_id}")
331
- console.print(f" State: {job.state}")
332
- console.print(f" GPU: {job.gpu_type} x{job.gpu_count}")
333
- console.print(f" Region: {job.region}")
334
- console.print(f" Price: ${job.price_per_hour:.2f}/hr")
335
- if job.hostname:
336
- console.print(f" Hostname: {job.hostname}")
333
+ if dry_run:
334
+ console.print("[bold green]✓[/] Dry run successful - configuration valid")
335
+ console.print(f" GPU: {job.gpu_type} x{job.gpu_count}")
336
+ console.print(f" Region: {job.region}")
337
+ console.print(f" Price: ${job.price_per_hour:.2f}/hr")
338
+ console.print(f" Runtime: {job.runtime}s")
339
+ # Display cold boot status
340
+ import sys
341
+ if job.cold_boot:
342
+ console.print("[yellow]⏳ Cold boot — instance provisioning may take up to 15 minutes[/]", file=sys.stderr)
343
+ else:
344
+ console.print("[green]🚀 Warm instance available — should be ready in under a minute[/]", file=sys.stderr)
345
+ else:
346
+ success(f"Instance launched: {job.job_id}")
347
+ console.print(f" State: {job.state}")
348
+ console.print(f" GPU: {job.gpu_type} x{job.gpu_count}")
349
+ console.print(f" Region: {job.region}")
350
+ console.print(f" Price: ${job.price_per_hour:.2f}/hr")
351
+ if job.hostname:
352
+ console.print(f" Hostname: {job.hostname}")
353
+ # Display cold boot status for real launches too
354
+ if job.cold_boot:
355
+ console.print("[yellow]⏳ Cold boot — instance provisioning may take up to 15 minutes[/]")
356
+ else:
357
+ console.print("[green]🚀 Warm instance available — should be ready in under a minute[/]")
337
358
 
338
359
  if follow:
339
360
  console.print()
@@ -232,6 +232,106 @@ def _follow_job(job_id: str, cancel_on_exit: bool = False):
232
232
  run_job_monitor(job_id, cancel_on_exit=cancel_on_exit)
233
233
 
234
234
 
235
+ @app.command("shell")
236
+ def shell(
237
+ job_id: str = typer.Argument(..., help="Job ID (full or prefix)"),
238
+ shell_cmd: str = typer.Option("/bin/bash", "--shell", "-s", help="Shell to use"),
239
+ ):
240
+ """Open an interactive shell on a running job container (WebSocket PTY)"""
241
+ import asyncio
242
+ import sys
243
+
244
+ client = get_client()
245
+ job_id = _resolve_job_id(client, job_id)
246
+
247
+ with spinner("Connecting to shell..."):
248
+ job = client.jobs.get(job_id)
249
+
250
+ if job.state != "running":
251
+ console.print(f"[red]Error:[/red] Job is {job.state}, not running")
252
+ raise typer.Exit(1)
253
+
254
+ console.print(f"[dim]Connected to job {job_id[:8]}... (press Ctrl+D or type 'exit' to disconnect)[/dim]")
255
+
256
+ asyncio.run(_run_shell(client, job_id, job.job_key, shell_cmd))
257
+
258
+
259
+ async def _run_shell(client, job_id: str, job_key: str, shell_cmd: str):
260
+ """Run interactive shell with raw terminal mode."""
261
+ import os
262
+ import sys
263
+ import signal
264
+ import struct
265
+ import termios
266
+ import tty
267
+ import fcntl
268
+
269
+ from hypercli.shell import shell_connect
270
+
271
+ loop = asyncio.get_event_loop()
272
+
273
+ # Save terminal state
274
+ stdin_fd = sys.stdin.fileno()
275
+ old_settings = termios.tcgetattr(stdin_fd)
276
+
277
+ session = None
278
+ try:
279
+ # Connect
280
+ session = await shell_connect(
281
+ client,
282
+ job_id,
283
+ job_key=job_key,
284
+ shell=shell_cmd,
285
+ on_output=lambda data: sys.stdout.write(data) or sys.stdout.flush(),
286
+ on_close=lambda reason: None,
287
+ )
288
+
289
+ # Send initial terminal size
290
+ try:
291
+ sz = struct.unpack("hh", fcntl.ioctl(stdin_fd, termios.TIOCGWINSZ, b"\x00" * 4))
292
+ await session.resize(cols=sz[1], rows=sz[0])
293
+ except Exception:
294
+ await session.resize(cols=80, rows=24)
295
+
296
+ # Handle SIGWINCH (terminal resize)
297
+ def on_resize(*_):
298
+ try:
299
+ sz = struct.unpack("hh", fcntl.ioctl(stdin_fd, termios.TIOCGWINSZ, b"\x00" * 4))
300
+ asyncio.run_coroutine_threadsafe(session.resize(cols=sz[1], rows=sz[0]), loop)
301
+ except Exception:
302
+ pass
303
+
304
+ signal.signal(signal.SIGWINCH, on_resize)
305
+
306
+ # Enter raw mode
307
+ tty.setraw(stdin_fd)
308
+
309
+ # Read stdin and forward to shell
310
+ reader = asyncio.StreamReader()
311
+ protocol = asyncio.StreamReaderProtocol(reader)
312
+ await loop.connect_read_pipe(lambda: protocol, sys.stdin)
313
+
314
+ while not session.closed:
315
+ try:
316
+ data = await asyncio.wait_for(reader.read(4096), timeout=0.5)
317
+ if not data:
318
+ break
319
+ await session.send(data.decode(errors="replace"))
320
+ except asyncio.TimeoutError:
321
+ continue
322
+ except Exception:
323
+ break
324
+
325
+ finally:
326
+ # Restore terminal
327
+ termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_settings)
328
+ signal.signal(signal.SIGWINCH, signal.SIG_DFL)
329
+ if session:
330
+ await session.close()
331
+ sys.stdout.write("\r\n")
332
+ sys.stdout.flush()
333
+
334
+
235
335
  def _watch_metrics(job_id: str):
236
336
  """Watch metrics live"""
237
337
  import time
@@ -296,3 +396,129 @@ def _render_metrics(m):
296
396
 
297
397
  panels.append(Panel(table, title="[bold]GPU Metrics[/bold]", border_style="green"))
298
398
  return Group(*panels)
399
+
400
+
401
+ @app.command("exec")
402
+ def exec_command(
403
+ job_id: str = typer.Argument(..., help="Job ID (full or prefix)"),
404
+ command: str = typer.Argument(..., help="Command to execute"),
405
+ timeout: int = typer.Option(30, "--timeout", "-t", help="Timeout in seconds"),
406
+ ):
407
+ """Execute a command non-interactively on a running job container.
408
+
409
+ Runs the command and returns stdout/stderr. For interactive shells, use 'hyper jobs shell'.
410
+
411
+ Examples:
412
+ hyper jobs exec <job_id> "nvidia-smi"
413
+ hyper jobs exec <job_id> "ps aux" --timeout 10
414
+ """
415
+ import sys
416
+
417
+ client = get_client()
418
+ job_id = _resolve_job_id(client, job_id)
419
+
420
+ with spinner("Executing command..."):
421
+ try:
422
+ result = client.jobs.exec(job_id, command, timeout=timeout)
423
+ except Exception as e:
424
+ console.print(f"[red]Error: {e}[/red]")
425
+ raise typer.Exit(1)
426
+
427
+ # Print stdout to stdout
428
+ if result.stdout:
429
+ sys.stdout.write(result.stdout)
430
+ sys.stdout.flush()
431
+
432
+ # Print stderr to stderr
433
+ if result.stderr:
434
+ sys.stderr.write(result.stderr)
435
+ sys.stderr.flush()
436
+
437
+ # Exit with remote exit code
438
+ if result.exit_code != 0:
439
+ raise typer.Exit(result.exit_code)
440
+
441
+
442
+ @app.command("shell")
443
+ def shell(
444
+ job_id: str = typer.Argument(..., help="Job ID (or prefix/hostname)"),
445
+ shell_bin: str = typer.Option("/bin/bash", "--shell", "-s", help="Shell binary"),
446
+ ):
447
+ """Open an interactive shell on a running job container (WebSocket PTY).
448
+
449
+ Connects via the director WebSocket proxy. Press Ctrl+] to disconnect.
450
+ """
451
+ import asyncio
452
+ import os
453
+ import sys
454
+
455
+ client = get_client()
456
+ job_id = _resolve_job_id(client, job_id)
457
+
458
+ console.print(f"[dim]Connecting to shell...[/dim]")
459
+
460
+ try:
461
+ import termios
462
+ import tty
463
+ except ImportError:
464
+ console.print("[red]❌ TTY libraries required (not available on Windows)[/red]")
465
+ raise typer.Exit(1)
466
+
467
+ async def _run_shell():
468
+ ws = await client.jobs.shell_connect(job_id, shell=shell_bin)
469
+
470
+ try:
471
+ console.print("[green]Connected.[/green] Ctrl+] to disconnect.\n")
472
+
473
+ old_settings = termios.tcgetattr(sys.stdin)
474
+ try:
475
+ tty.setraw(sys.stdin.fileno())
476
+
477
+ import shutil
478
+ cols, rows = shutil.get_terminal_size()
479
+ await ws.send(f"\x1b[8;{rows};{cols}t")
480
+
481
+ async def read_ws():
482
+ try:
483
+ async for msg in ws:
484
+ if isinstance(msg, str):
485
+ sys.stdout.write(msg)
486
+ sys.stdout.flush()
487
+ elif isinstance(msg, bytes):
488
+ sys.stdout.buffer.write(msg)
489
+ sys.stdout.buffer.flush()
490
+ except Exception:
491
+ pass
492
+
493
+ async def read_stdin():
494
+ loop = asyncio.get_event_loop()
495
+ try:
496
+ while True:
497
+ data = await loop.run_in_executor(None, lambda: os.read(sys.stdin.fileno(), 1024))
498
+ if not data:
499
+ break
500
+ if b"\x1d" in data: # Ctrl+]
501
+ break
502
+ await ws.send(data.decode(errors="replace"))
503
+ except Exception:
504
+ pass
505
+
506
+ done, pending = await asyncio.wait(
507
+ [asyncio.create_task(read_ws()), asyncio.create_task(read_stdin())],
508
+ return_when=asyncio.FIRST_COMPLETED,
509
+ )
510
+ for t in pending:
511
+ t.cancel()
512
+ finally:
513
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
514
+ console.print("\n[dim]Disconnected.[/dim]")
515
+ finally:
516
+ await ws.close()
517
+
518
+ try:
519
+ asyncio.run(_run_shell())
520
+ except KeyboardInterrupt:
521
+ console.print("\n[dim]Disconnected.[/dim]")
522
+ except Exception as e:
523
+ console.print(f"[red]❌ Shell failed: {e}[/red]")
524
+ raise typer.Exit(1)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hypercli-cli"
7
- version = "0.9.2"
7
+ version = "1.0.1"
8
8
  description = "CLI for HyperCLI - GPU orchestration and LLM API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -13,7 +13,7 @@ authors = [
13
13
  { name = "HyperCLI", email = "support@hypercli.com" }
14
14
  ]
15
15
  dependencies = [
16
- "hypercli-sdk>=0.8.9",
16
+ "hypercli-sdk>=1.0.0",
17
17
  "typer>=0.20.0",
18
18
  "rich>=14.2.0",
19
19
  "websocket-client>=1.6.0",
@@ -24,7 +24,7 @@ dependencies = [
24
24
 
25
25
  [project.optional-dependencies]
26
26
  comfyui = [
27
- "hypercli-sdk[comfyui]>=0.8.9",
27
+ "hypercli-sdk[comfyui]>=1.0.0",
28
28
  ]
29
29
  wallet = [
30
30
  "x402[httpx,evm]>=2.0.0",
@@ -34,7 +34,7 @@ wallet = [
34
34
  "qrcode[pil]>=7.4.0",
35
35
  ]
36
36
  all = [
37
- "hypercli-sdk[comfyui]>=0.8.9",
37
+ "hypercli-sdk[comfyui]>=1.0.0",
38
38
  "x402[httpx,evm]>=2.0.0",
39
39
  "eth-account>=0.13.0",
40
40
  "web3>=7.0.0",
File without changes
File without changes