hypercli-cli 0.4.6__tar.gz → 0.4.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypercli-cli
3
- Version: 0.4.6
3
+ Version: 0.4.7
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
@@ -1,13 +1,13 @@
1
1
  """hyper billing commands"""
2
2
  import typer
3
- from hypercli import C3
3
+ from hypercli import HyperCLI
4
4
  from .output import output, console, spinner
5
5
 
6
6
  app = typer.Typer(help="Billing and balance commands")
7
7
 
8
8
 
9
- def get_client() -> C3:
10
- return C3()
9
+ def get_client() -> HyperCLI:
10
+ return HyperCLI()
11
11
 
12
12
 
13
13
  @app.command("balance")
@@ -1,10 +1,10 @@
1
- """C3 CLI - Main entry point"""
1
+ """HyperCLI - Main entry point"""
2
2
  import sys
3
3
  import typer
4
4
  from rich.console import Console
5
5
  from rich.prompt import Prompt
6
6
 
7
- from hypercli import C3, APIError, configure
7
+ from hypercli import HyperCLI, APIError, configure
8
8
  from hypercli.config import CONFIG_FILE
9
9
 
10
10
  from . import billing, comfyui, instances, jobs, llm, renders, user
@@ -49,8 +49,8 @@ def fuzzy_match(input_str: str, options: list[str], threshold: float = 0.5) -> l
49
49
  return [opt for opt, _ in matches[:3]]
50
50
 
51
51
  app = typer.Typer(
52
- name="c3",
53
- help="HyperCLI CLI - GPU orchestration and LLM API",
52
+ name="hyper",
53
+ help="HyperCLI - GPU orchestration and LLM API",
54
54
  no_args_is_help=True,
55
55
  rich_markup_mode="rich",
56
56
  )
@@ -67,11 +67,11 @@ app.add_typer(user.app, name="user")
67
67
 
68
68
  @app.command("configure")
69
69
  def configure_cmd():
70
- """Configure C3 CLI with your API key and API URL"""
70
+ """Configure HyperCLI with your API key and API URL"""
71
71
  import getpass
72
72
  from hypercli.config import get_api_key, get_api_url, DEFAULT_API_URL
73
73
 
74
- console.print("\n[bold cyan]C3 CLI Configuration[/bold cyan]\n")
74
+ console.print("\n[bold cyan]HyperCLI Configuration[/bold cyan]\n")
75
75
 
76
76
  # Show current config
77
77
  current_key = get_api_key()
@@ -111,7 +111,7 @@ def configure_cmd():
111
111
  console.print(f" API key: {preview}")
112
112
  if final_url:
113
113
  console.print(f" API URL: {final_url}")
114
- console.print("\nTest your setup with: [cyan]c3 billing balance[/cyan]\n")
114
+ console.print("\nTest your setup with: [cyan]hyper billing balance[/cyan]\n")
115
115
 
116
116
 
117
117
  @app.callback()
@@ -119,19 +119,19 @@ def main(
119
119
  version: bool = typer.Option(False, "--version", "-v", help="Show version"),
120
120
  ):
121
121
  """
122
- [bold cyan]C3 CLI[/bold cyan] - HyperCLI GPU orchestration and LLM API
122
+ [bold cyan]HyperCLI[/bold cyan] - GPU orchestration and LLM API
123
123
 
124
- Set your API key: [green]c3 configure[/green]
124
+ Set your API key: [green]hyper configure[/green]
125
125
 
126
126
  Get started:
127
- c3 instances list Browse available GPUs
128
- c3 instances launch Launch a GPU instance
129
- c3 jobs list View your running jobs
130
- c3 llm chat -i Start a chat
127
+ hyper instances list Browse available GPUs
128
+ hyper instances launch Launch a GPU instance
129
+ hyper jobs list View your running jobs
130
+ hyper llm chat -i Start a chat
131
131
  """
132
132
  if version:
133
133
  from . import __version__
134
- console.print(f"c3 version {__version__}")
134
+ console.print(f"hyper version {__version__}")
135
135
  raise typer.Exit()
136
136
 
137
137
 
@@ -167,7 +167,7 @@ def cli():
167
167
  # Check for region errors
168
168
  if "region" in detail.lower() and "not found" in detail.lower():
169
169
  console.print(f"[bold red]Error:[/bold red] {detail}")
170
- console.print("\n[dim]Tip: Use 'c3 jobs regions' to see available regions[/dim]")
170
+ console.print("\n[dim]Tip: Use 'hyper jobs regions' to see available regions[/dim]")
171
171
  sys.exit(1)
172
172
 
173
173
  # Generic API error
@@ -7,15 +7,15 @@ from typing import Optional
7
7
 
8
8
  import typer
9
9
 
10
- from hypercli import C3, ComfyUIJob, APIError, apply_params, apply_graph_modes, load_template, graph_to_api
10
+ from hypercli import HyperCLI, ComfyUIJob, APIError, apply_params, apply_graph_modes, load_template, graph_to_api
11
11
  from .output import console, error, success, spinner
12
12
  from .tui import JobStatus, run_job_monitor
13
13
 
14
14
  app = typer.Typer(help="Run ComfyUI workflows on GPU")
15
15
 
16
16
 
17
- def get_client() -> C3:
18
- return C3()
17
+ def get_client() -> HyperCLI:
18
+ return HyperCLI()
19
19
 
20
20
 
21
21
  def _run_workflow(
@@ -220,14 +220,16 @@ def run(
220
220
  gpu_count: int = typer.Option(1, "--count", help="Number of GPUs"),
221
221
  interruptible: bool = typer.Option(True, "--interruptible/--on-demand", help="Use interruptible instances"),
222
222
  region: Optional[str] = typer.Option(None, "--region", help="Region (e.g., us-east, eu-west, asia-east)"),
223
+ runtime: int = typer.Option(3600, "--runtime", help="Instance runtime in seconds"),
223
224
  output_dir: str = typer.Option(".", "--output-dir", "-d", help="Output directory"),
224
- timeout: int = typer.Option(600, "--timeout", "-t", help="Timeout in seconds"),
225
+ timeout: int = typer.Option(600, "--timeout", "-t", help="Workflow timeout in seconds"),
225
226
  num: int = typer.Option(1, "--num", "-N", help="Number of images to generate"),
226
227
  new: bool = typer.Option(False, "--new", help="Always launch new instance"),
227
228
  instance: Optional[str] = typer.Option(None, "--instance", "-i", help="Connect to job by ID, hostname, or IP"),
228
- lb: Optional[int] = typer.Option(None, "--lb", help="Enable HTTPS load balancer on port (e.g., 8188)"),
229
- auth: bool = typer.Option(False, "--auth", help="Enable Bearer token auth on load balancer"),
229
+ lb: int = typer.Option(8188, "--lb", help="HTTPS load balancer port (default: 8188, use 0 to disable)"),
230
+ auth: bool = typer.Option(True, "--auth/--no-auth", help="Bearer token auth (default: enabled)"),
230
231
  stdout: bool = typer.Option(False, "--stdout", help="Output to stdout instead of TUI"),
232
+ cancel_on_exit: bool = typer.Option(False, "--cancel-on-exit", help="Cancel job when exiting with Ctrl+C"),
231
233
  debug: bool = typer.Option(False, "--debug", help="Debug mode: stdout + verbose errors"),
232
234
  workflow_json: bool = typer.Option(False, "--workflow-json", help="Output generated workflow JSON and exit (don't run)"),
233
235
  nodes: Optional[str] = typer.Option(None, "--nodes", help="Node-specific params as JSON: '{\"node_id\": {\"image\": \"file.png\"}}'"),
@@ -301,24 +303,28 @@ def run(
301
303
 
302
304
  # Get or create job
303
305
  try:
306
+ # lb=0 means disabled
307
+ lb_port = lb if lb else None
308
+
304
309
  if instance:
305
310
  # Connect to specific instance by ID, hostname, or IP
306
311
  with spinner(f"Connecting to instance {instance}..."):
307
- job = ComfyUIJob.get_by_instance(c3, instance)
312
+ job = ComfyUIJob.get_by_instance(client, instance)
308
313
  job.template = template
309
- job.use_lb = lb is not None
314
+ job.use_lb = lb_port is not None
310
315
  job.use_auth = auth
311
316
  else:
312
317
  # Get or create job with template env var
313
318
  with spinner(f"Getting ComfyUI instance for {template}..."):
314
319
  job = ComfyUIJob.get_or_create_for_template(
315
- c3,
320
+ client,
316
321
  template=template,
317
322
  gpu_type=gpu_type,
318
323
  gpu_count=gpu_count,
319
324
  region=region,
325
+ runtime=runtime,
320
326
  reuse=not new,
321
- lb=lb,
327
+ lb=lb_port,
322
328
  auth=auth,
323
329
  interruptible=interruptible,
324
330
  )
@@ -438,26 +444,39 @@ def run(
438
444
  seed_info = f"seed: {iteration_seed}" if iteration_seed else "default seed"
439
445
  console.print(f"\n[bold]Image {i+1}/{num}[/bold] ({seed_info})")
440
446
 
441
- if stdout or debug:
442
- # Simple mode without TUI
443
- _run_simple(job, template, params, timeout, Path(output_dir), debug=debug)
444
- else:
445
- # TUI mode with status pane
446
- status_q: Queue = Queue()
447
-
448
- # Start workflow in background thread
449
- workflow_thread = threading.Thread(
450
- target=_run_workflow,
451
- args=(job, template, params, timeout, Path(output_dir), status_q),
452
- daemon=True,
453
- )
454
- workflow_thread.start()
447
+ try:
448
+ if stdout or debug:
449
+ # Simple mode without TUI
450
+ _run_simple(job, template, params, timeout, Path(output_dir), debug=debug)
451
+ else:
452
+ # TUI mode with status pane
453
+ status_q: Queue = Queue()
454
+
455
+ # Start workflow in background thread
456
+ workflow_thread = threading.Thread(
457
+ target=_run_workflow,
458
+ args=(job, template, params, timeout, Path(output_dir), status_q),
459
+ daemon=True,
460
+ )
461
+ workflow_thread.start()
455
462
 
456
- # Run TUI in main thread
457
- run_job_monitor(job.job_id, status_q=status_q, stop_on_status_complete=True)
463
+ # Run TUI in main thread
464
+ run_job_monitor(job.job_id, status_q=status_q, stop_on_status_complete=True, cancel_on_exit=cancel_on_exit)
458
465
 
459
- # Wait for workflow thread to finish
460
- workflow_thread.join(timeout=5)
466
+ # Wait for workflow thread to finish
467
+ workflow_thread.join(timeout=5)
468
+ except KeyboardInterrupt:
469
+ console.print("\n[dim]Interrupted[/dim]")
470
+ if cancel_on_exit:
471
+ try:
472
+ job.refresh()
473
+ if job.job.state in ("queued", "assigned", "running"):
474
+ console.print("[yellow]Cancelling job...[/yellow]")
475
+ client.jobs.cancel(job.job_id)
476
+ console.print("[green]Job cancelled[/green]")
477
+ except Exception as e:
478
+ console.print(f"[red]Failed to cancel: {e}[/red]")
479
+ raise typer.Exit(1)
461
480
 
462
481
 
463
482
  def _run_simple(job: ComfyUIJob, template: str, params: dict, timeout: int, output_dir: Path, debug: bool = False):
@@ -680,7 +699,7 @@ def status():
680
699
  client = get_client()
681
700
 
682
701
  with spinner("Checking for running jobs..."):
683
- job = ComfyUIJob.get_running(c3)
702
+ job = ComfyUIJob.get_running(client)
684
703
 
685
704
  if not job:
686
705
  console.print("[dim]No running ComfyUI job[/dim]")
@@ -717,10 +736,10 @@ def download(
717
736
  # Get job
718
737
  if instance:
719
738
  with spinner(f"Connecting to {instance}..."):
720
- job = ComfyUIJob.get_by_instance(c3, instance)
739
+ job = ComfyUIJob.get_by_instance(client, instance)
721
740
  else:
722
741
  with spinner("Finding running job..."):
723
- job = ComfyUIJob.get_running(c3)
742
+ job = ComfyUIJob.get_running(client)
724
743
  if not job:
725
744
  error("No running ComfyUI job found. Use --instance to specify one.")
726
745
  raise typer.Exit(1)
@@ -809,7 +828,7 @@ def stop():
809
828
  client = get_client()
810
829
 
811
830
  with spinner("Finding running job..."):
812
- job = ComfyUIJob.get_running(c3)
831
+ job = ComfyUIJob.get_running(client)
813
832
 
814
833
  if not job:
815
834
  console.print("[dim]No running ComfyUI job[/dim]")
@@ -1,14 +1,14 @@
1
1
  """hyper instances commands"""
2
2
  import typer
3
3
  from typing import Optional
4
- from hypercli import C3
4
+ from hypercli import HyperCLI
5
5
  from .output import output, console, success, spinner
6
6
 
7
7
  app = typer.Typer(help="GPU instances - browse and launch")
8
8
 
9
9
 
10
- def get_client() -> C3:
11
- return C3()
10
+ def get_client() -> HyperCLI:
11
+ return HyperCLI()
12
12
 
13
13
 
14
14
  @app.command("list")
@@ -140,6 +140,7 @@ def launch(
140
140
  env: Optional[list[str]] = typer.Option(None, "--env", "-e", help="Env vars (KEY=VALUE)"),
141
141
  port: Optional[list[str]] = typer.Option(None, "--port", "-p", help="Ports (name:port)"),
142
142
  follow: bool = typer.Option(False, "--follow", "-f", help="Follow logs after creation"),
143
+ cancel_on_exit: bool = typer.Option(False, "--cancel-on-exit", help="Cancel job when exiting with Ctrl+C"),
143
144
  fmt: str = typer.Option("table", "--output", "-o", help="Output format: table|json"),
144
145
  ):
145
146
  """Launch a new GPU instance"""
@@ -190,4 +191,4 @@ def launch(
190
191
  if follow:
191
192
  console.print()
192
193
  from .tui.job_monitor import run_job_monitor
193
- run_job_monitor(job.job_id)
194
+ run_job_monitor(job.job_id, cancel_on_exit=cancel_on_exit)
@@ -1,14 +1,14 @@
1
1
  """hyper jobs commands"""
2
2
  import typer
3
3
  from typing import Optional
4
- from hypercli import C3
4
+ from hypercli import HyperCLI
5
5
  from .output import output, console, success, spinner
6
6
 
7
7
  app = typer.Typer(help="Manage running jobs")
8
8
 
9
9
 
10
- def get_client() -> C3:
11
- return C3()
10
+ def get_client() -> HyperCLI:
11
+ return HyperCLI()
12
12
 
13
13
 
14
14
  @app.command("list")
@@ -46,12 +46,13 @@ def get_job(
46
46
  def logs(
47
47
  job_id: str = typer.Argument(..., help="Job ID"),
48
48
  follow: bool = typer.Option(False, "--follow", "-f", help="Stream logs"),
49
+ cancel_on_exit: bool = typer.Option(False, "--cancel-on-exit", help="Cancel job when exiting with Ctrl+C"),
49
50
  ):
50
51
  """Get job logs"""
51
52
  client = get_client()
52
53
 
53
54
  if follow:
54
- _follow_job(job_id)
55
+ _follow_job(job_id, cancel_on_exit=cancel_on_exit)
55
56
  else:
56
57
  with spinner("Fetching logs..."):
57
58
  logs_str = client.jobs.logs(job_id)
@@ -167,10 +168,10 @@ def _make_bar(value: float, max_val: float, warn: float = None, crit: float = No
167
168
  return f"[{color}]{bar}[/{color}]"
168
169
 
169
170
 
170
- def _follow_job(job_id: str):
171
+ def _follow_job(job_id: str, cancel_on_exit: bool = False):
171
172
  """Follow job with TUI"""
172
173
  from .tui.job_monitor import run_job_monitor
173
- run_job_monitor(job_id)
174
+ run_job_monitor(job_id, cancel_on_exit=cancel_on_exit)
174
175
 
175
176
 
176
177
  def _watch_metrics(job_id: str):
@@ -9,10 +9,10 @@ app = typer.Typer(help="LLM API commands")
9
9
 
10
10
 
11
11
  def get_openai_client() -> OpenAI:
12
- """Get OpenAI client configured for C3"""
12
+ """Get OpenAI client configured for HyperCLI"""
13
13
  api_key = get_api_key()
14
14
  if not api_key:
15
- raise typer.Exit("HYPERCLI_API_KEY not set. Run: c3 configure")
15
+ raise typer.Exit("HYPERCLI_API_KEY not set. Run: hyper configure")
16
16
 
17
17
  base_url = get_api_url()
18
18
  return OpenAI(api_key=api_key, base_url=f"{base_url}/v1")
@@ -126,7 +126,7 @@ def _interactive_chat(client: OpenAI, model: Optional[str], system: Optional[str
126
126
  sys_preview = current_system[:50] + "..." if len(current_system) > 50 else current_system
127
127
  console.print(f"[dim]System: {sys_preview}[/]")
128
128
 
129
- console.print("\n[bold cyan]C3 Chat[/bold cyan]")
129
+ console.print("\n[bold cyan]HyperCLI Chat[/bold cyan]")
130
130
  show_status()
131
131
  console.print("[dim]Type /help for commands, /quit to exit[/dim]\n")
132
132
 
@@ -2,14 +2,14 @@
2
2
  import time
3
3
  import typer
4
4
  from typing import Optional
5
- from hypercli import C3
5
+ from hypercli import HyperCLI
6
6
  from .output import output, console, success, spinner
7
7
 
8
8
  app = typer.Typer(help="Manage renders")
9
9
 
10
10
 
11
- def get_client() -> C3:
12
- return C3()
11
+ def get_client() -> HyperCLI:
12
+ return HyperCLI()
13
13
 
14
14
 
15
15
  @app.command("list")
@@ -77,7 +77,7 @@ def create_render(
77
77
  console.print(f" State: {render.state}")
78
78
 
79
79
  if wait:
80
- _wait_for_render(c3, render.render_id, fmt)
80
+ _wait_for_render(client, render.render_id, fmt)
81
81
 
82
82
 
83
83
  @app.command("status")
@@ -90,7 +90,7 @@ def status(
90
90
  client = get_client()
91
91
 
92
92
  if watch:
93
- _watch_status(c3, render_id)
93
+ _watch_status(client, render_id)
94
94
  else:
95
95
  with spinner("Fetching status..."):
96
96
  s = client.renders.status(render_id)
@@ -108,7 +108,7 @@ def cancel(
108
108
  success(f"Render {render_id} cancelled")
109
109
 
110
110
 
111
- def _wait_for_render(c3: C3, render_id: str, fmt: str, poll_interval: float = 2.0):
111
+ def _wait_for_render(client: HyperCLI, render_id: str, fmt: str, poll_interval: float = 2.0):
112
112
  """Wait for render to complete"""
113
113
  console.print("Waiting for completion...")
114
114
 
@@ -138,7 +138,7 @@ def _wait_for_render(c3: C3, render_id: str, fmt: str, poll_interval: float = 2.
138
138
  console.print(f" Error: {render.error}")
139
139
 
140
140
 
141
- def _watch_status(c3: C3, render_id: str, poll_interval: float = 2.0):
141
+ def _watch_status(client: HyperCLI, render_id: str, poll_interval: float = 2.0):
142
142
  """Watch render status live"""
143
143
  from rich.live import Live
144
144
  from rich.panel import Panel
@@ -12,7 +12,7 @@ from rich.panel import Panel
12
12
  from rich.layout import Layout
13
13
  from rich import box
14
14
 
15
- from hypercli import C3, LogStream, fetch_logs
15
+ from hypercli import HyperCLI, LogStream, fetch_logs
16
16
 
17
17
  console = Console()
18
18
 
@@ -70,7 +70,7 @@ def build_header(job, elapsed: int, metrics) -> Panel:
70
70
  mem_pct = (s.memory_used / s.memory_limit * 100) if s.memory_limit else 0
71
71
  mem_c = "red" if mem_pct >= 90 else "yellow" if mem_pct >= 70 else "green"
72
72
 
73
- line = f"[bold]CPU[/] {bar(cpu_pct, 20, cpu_c)} {cpu_pct:4.0f}% "
73
+ line = f"[bold]CPU [/] {bar(cpu_pct, 20, cpu_c)} {cpu_pct:4.0f}% "
74
74
  line += f"RAM {bar(mem_pct, 15, mem_c)} {s.memory_used/1024:.1f}/{s.memory_limit/1024:.1f}GB"
75
75
  parts.append(line)
76
76
 
@@ -162,9 +162,11 @@ async def _run_job_monitor_async(
162
162
  job_id: str,
163
163
  status_q: Queue = None,
164
164
  stop_on_status_complete: bool = False,
165
+ cancel_on_exit: bool = False,
165
166
  ):
166
167
  """Async job monitor - uses SDK LogStream for logs"""
167
- client = C3()
168
+ client = HyperCLI()
169
+ should_cancel = False
168
170
  logs: deque[str] = deque(maxlen=MAX_LOG_LINES)
169
171
  log_stream: Optional[LogStream] = None
170
172
  log_task: Optional[asyncio.Task] = None
@@ -173,7 +175,8 @@ async def _run_job_monitor_async(
173
175
  ws_status = "[dim]● waiting[/]"
174
176
  stop_event = asyncio.Event()
175
177
 
176
- console.print(f"[dim]Ctrl+C to exit[/]\n")
178
+ exit_msg = "Ctrl+C to exit and cancel job" if cancel_on_exit else "Ctrl+C to exit (job keeps running)"
179
+ console.print(f"[dim]{exit_msg}[/]\n")
177
180
 
178
181
  # Wait for job
179
182
  job = None
@@ -185,28 +188,15 @@ async def _run_job_monitor_async(
185
188
  await asyncio.sleep(1)
186
189
 
187
190
  async def stream_logs_task(stream: LogStream):
188
- """Background task that streams logs continuously with batching"""
191
+ """Background task that streams logs continuously"""
189
192
  nonlocal ws_status
190
- log_batch = []
191
- last_flush = asyncio.get_event_loop().time()
192
- FLUSH_INTERVAL = 0.05 # Flush every 50ms
193
193
 
194
194
  try:
195
195
  async for line in stream:
196
196
  if stop_event.is_set():
197
197
  break
198
- log_batch.append(line)
199
-
200
- # Flush batch periodically
201
- now = asyncio.get_event_loop().time()
202
- if now - last_flush >= FLUSH_INTERVAL:
203
- logs.extend(log_batch)
204
- log_batch.clear()
205
- last_flush = now
206
-
207
- # Flush remaining
208
- if log_batch:
209
- logs.extend(log_batch)
198
+ # Append directly - the deque handles thread safety
199
+ logs.append(line)
210
200
  except Exception:
211
201
  pass
212
202
  finally:
@@ -226,6 +216,8 @@ async def _run_job_monitor_async(
226
216
  async def fetch_job_task():
227
217
  """Background task that fetches job state periodically"""
228
218
  nonlocal job, log_stream, log_task, ws_status
219
+ ws_retries = 0
220
+ initial_logs_fetched = False
229
221
  while not stop_event.is_set():
230
222
  try:
231
223
  job = await asyncio.to_thread(client.jobs.get, job_id)
@@ -233,21 +225,35 @@ async def _run_job_monitor_async(
233
225
  # Start log stream when job is ready
234
226
  if job.state in ("assigned", "running") and log_stream is None and job.job_key:
235
227
  # Fetch initial logs ONCE
236
- if job.state == "running":
237
- initial = await asyncio.to_thread(fetch_logs, c3, job_id, MAX_LOG_LINES)
228
+ if job.state == "running" and not initial_logs_fetched:
229
+ initial = await asyncio.to_thread(fetch_logs, client, job_id, MAX_LOG_LINES)
238
230
  for line in initial:
239
231
  logs.append(line)
232
+ initial_logs_fetched = True
240
233
 
241
234
  # Connect websocket and start streaming task
242
- log_stream = LogStream(
243
- c3, job_id,
235
+ temp_stream = LogStream(
236
+ client, job_id,
244
237
  job_key=job.job_key,
245
238
  fetch_initial=False,
246
239
  max_buffer=MAX_LOG_LINES,
247
240
  )
248
- await log_stream.connect()
249
- log_task = asyncio.create_task(stream_logs_task(log_stream))
250
- ws_status = "[green]● live[/]"
241
+ try:
242
+ retry_msg = f" (retry {ws_retries})" if ws_retries > 0 else ""
243
+ ws_status = f"[yellow]● connecting{retry_msg}...[/]"
244
+ await temp_stream.connect()
245
+ log_stream = temp_stream
246
+ log_task = asyncio.create_task(stream_logs_task(log_stream))
247
+ ws_status = "[green]● live[/]"
248
+ ws_retries = 0
249
+ except asyncio.TimeoutError:
250
+ ws_retries += 1
251
+ ws_status = f"[red]● timeout (retry {ws_retries})[/]"
252
+ await asyncio.sleep(2) # Wait before retry
253
+ except Exception as e:
254
+ ws_retries += 1
255
+ ws_status = f"[red]● error (retry {ws_retries})[/]"
256
+ await asyncio.sleep(2) # Wait before retry
251
257
  except Exception:
252
258
  pass
253
259
  await asyncio.sleep(1)
@@ -288,7 +294,7 @@ async def _run_job_monitor_async(
288
294
  # Job terminated
289
295
  if job and job.state in ("succeeded", "failed", "canceled", "terminated"):
290
296
  # Fetch final logs ONCE
291
- final = await asyncio.to_thread(fetch_logs, c3, job_id, MAX_LOG_LINES)
297
+ final = await asyncio.to_thread(fetch_logs, client, job_id, MAX_LOG_LINES)
292
298
  logs.clear()
293
299
  for line in final:
294
300
  logs.append(line)
@@ -309,6 +315,7 @@ async def _run_job_monitor_async(
309
315
  console.print(f"\n[bold]Job {job.state}[/]")
310
316
 
311
317
  except KeyboardInterrupt:
318
+ should_cancel = cancel_on_exit
312
319
  console.print("\n[dim]Stopped[/]")
313
320
  finally:
314
321
  stop_event.set()
@@ -319,11 +326,20 @@ async def _run_job_monitor_async(
319
326
  metrics_task.cancel()
320
327
  job_task.cancel()
321
328
 
329
+ if should_cancel and job and job.state in ("queued", "assigned", "running"):
330
+ console.print("[yellow]Cancelling job...[/]")
331
+ try:
332
+ client.jobs.cancel(job_id)
333
+ console.print("[green]Job cancelled[/]")
334
+ except Exception as e:
335
+ console.print(f"[red]Failed to cancel: {e}[/]")
336
+
322
337
 
323
338
  def run_job_monitor(
324
339
  job_id: str,
325
340
  status_q: Queue = None,
326
341
  stop_on_status_complete: bool = False,
342
+ cancel_on_exit: bool = False,
327
343
  ):
328
344
  """Run the job monitor TUI (sync wrapper).
329
345
 
@@ -331,5 +347,6 @@ def run_job_monitor(
331
347
  job_id: The job ID to monitor
332
348
  status_q: Optional queue receiving JobStatus updates for status pane
333
349
  stop_on_status_complete: If True, exit when JobStatus.complete is True
350
+ cancel_on_exit: If True, cancel the job when exiting via Ctrl+C
334
351
  """
335
- asyncio.run(_run_job_monitor_async(job_id, status_q, stop_on_status_complete))
352
+ asyncio.run(_run_job_monitor_async(job_id, status_q, stop_on_status_complete, cancel_on_exit))
@@ -1,6 +1,6 @@
1
1
  """hyper user commands"""
2
2
  import typer
3
- from hypercli import C3
3
+ from hypercli import HyperCLI
4
4
  from .output import output, spinner
5
5
 
6
6
  app = typer.Typer(help="User account commands")
@@ -13,7 +13,7 @@ def user_info(
13
13
  ):
14
14
  """Get current user info"""
15
15
  if ctx.invoked_subcommand is None:
16
- client = C3()
16
+ client = HyperCLI()
17
17
  with spinner("Fetching user info..."):
18
18
  user = client.user.get()
19
19
  output(user, fmt)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hypercli-cli"
7
- version = "0.4.6"
7
+ version = "0.4.7"
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