dayhoff-tools 1.10.1__tar.gz → 1.10.3__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 (64) hide show
  1. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/PKG-INFO +3 -2
  2. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine_studio_commands.py → dayhoff_tools-1.10.3/dayhoff_tools/cli/engine/__init__.py +22 -22
  3. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine/status.py → dayhoff_tools-1.10.3/dayhoff_tools/cli/engine/engine_core.py +201 -6
  4. dayhoff_tools-1.10.3/dayhoff_tools/cli/engine/engine_maintenance.py +431 -0
  5. dayhoff_tools-1.10.3/dayhoff_tools/cli/engine/engine_management.py +505 -0
  6. dayhoff_tools-1.10.3/dayhoff_tools/cli/engine/shared.py +501 -0
  7. dayhoff_tools-1.10.3/dayhoff_tools/cli/engine/studio_commands.py +825 -0
  8. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/cli/main.py +2 -1
  9. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/deployment/base.py +10 -2
  10. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/pyproject.toml +1 -1
  11. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine/__init__.py +0 -1
  12. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine/coffee.py +0 -110
  13. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine/config_ssh.py +0 -113
  14. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine/debug.py +0 -79
  15. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine/gami.py +0 -160
  16. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine/idle.py +0 -148
  17. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine/launch.py +0 -101
  18. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine/list.py +0 -116
  19. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine/repair.py +0 -128
  20. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine/resize.py +0 -195
  21. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine/ssh.py +0 -62
  22. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine_studio_utils/__init__.py +0 -1
  23. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine_studio_utils/api_utils.py +0 -47
  24. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine_studio_utils/aws_utils.py +0 -102
  25. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine_studio_utils/constants.py +0 -21
  26. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine_studio_utils/formatting.py +0 -210
  27. dayhoff_tools-1.10.1/dayhoff_tools/cli/engine_studio_utils/ssh_utils.py +0 -141
  28. dayhoff_tools-1.10.1/dayhoff_tools/cli/studio/__init__.py +0 -1
  29. dayhoff_tools-1.10.1/dayhoff_tools/cli/studio/attach.py +0 -314
  30. dayhoff_tools-1.10.1/dayhoff_tools/cli/studio/create.py +0 -48
  31. dayhoff_tools-1.10.1/dayhoff_tools/cli/studio/delete.py +0 -71
  32. dayhoff_tools-1.10.1/dayhoff_tools/cli/studio/detach.py +0 -56
  33. dayhoff_tools-1.10.1/dayhoff_tools/cli/studio/list.py +0 -81
  34. dayhoff_tools-1.10.1/dayhoff_tools/cli/studio/reset.py +0 -90
  35. dayhoff_tools-1.10.1/dayhoff_tools/cli/studio/resize.py +0 -134
  36. dayhoff_tools-1.10.1/dayhoff_tools/cli/studio/status.py +0 -78
  37. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/README.md +0 -0
  38. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/__init__.py +0 -0
  39. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/chemistry/standardizer.py +0 -0
  40. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/chemistry/utils.py +0 -0
  41. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/cli/__init__.py +0 -0
  42. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/cli/cloud_commands.py +0 -0
  43. /dayhoff_tools-1.10.1/dayhoff_tools/cli/engine/lifecycle.py → /dayhoff_tools-1.10.3/dayhoff_tools/cli/engine/engine_lifecycle.py +0 -0
  44. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/cli/swarm_commands.py +0 -0
  45. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/cli/utility_commands.py +0 -0
  46. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/deployment/deploy_aws.py +0 -0
  47. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/deployment/deploy_gcp.py +0 -0
  48. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/deployment/deploy_utils.py +0 -0
  49. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/deployment/job_runner.py +0 -0
  50. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/deployment/processors.py +0 -0
  51. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/deployment/swarm.py +0 -0
  52. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/embedders.py +0 -0
  53. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/fasta.py +0 -0
  54. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/file_ops.py +0 -0
  55. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/h5.py +0 -0
  56. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/intake/gcp.py +0 -0
  57. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/intake/gtdb.py +0 -0
  58. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/intake/kegg.py +0 -0
  59. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/intake/mmseqs.py +0 -0
  60. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/intake/structure.py +0 -0
  61. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/intake/uniprot.py +0 -0
  62. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/logs.py +0 -0
  63. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/sqlite.py +0 -0
  64. {dayhoff_tools-1.10.1 → dayhoff_tools-1.10.3}/dayhoff_tools/warehouse.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: dayhoff-tools
3
- Version: 1.10.1
3
+ Version: 1.10.3
4
4
  Summary: Common tools for all the repos at Dayhoff Labs
5
5
  Author: Daniel Martin-Alarcon
6
6
  Author-email: dma@dayhofflabs.com
@@ -10,6 +10,7 @@ Classifier: Programming Language :: Python :: 3.10
10
10
  Classifier: Programming Language :: Python :: 3.11
11
11
  Classifier: Programming Language :: Python :: 3.12
12
12
  Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
13
14
  Provides-Extra: embedders
14
15
  Provides-Extra: full
15
16
  Requires-Dist: biopython (>=1.84) ; extra == "full"
@@ -36,7 +36,7 @@ def launch_engine_cmd(
36
36
  ),
37
37
  ):
38
38
  """Launch a new engine instance."""
39
- from .engine.launch import launch_engine
39
+ from .engine_core import launch_engine
40
40
 
41
41
  return launch_engine(name, engine_type, user, boot_disk_size, availability_zone)
42
42
 
@@ -55,7 +55,7 @@ def list_engines_cmd(
55
55
  ),
56
56
  ):
57
57
  """List engines (shows all engines by default)."""
58
- from .engine.list import list_engines
58
+ from .engine_core import list_engines
59
59
 
60
60
  return list_engines(user, running_only, stopped_only, detailed)
61
61
 
@@ -71,7 +71,7 @@ def engine_status_cmd(
71
71
  ),
72
72
  ):
73
73
  """Show engine status and information."""
74
- from .engine.status import engine_status
74
+ from .engine_core import engine_status
75
75
 
76
76
  return engine_status(name_or_id, detailed, show_log)
77
77
 
@@ -81,7 +81,7 @@ def start_engine_cmd(
81
81
  name_or_id: str = typer.Argument(help="Engine name or instance ID"),
82
82
  ):
83
83
  """Start a stopped engine."""
84
- from .engine.lifecycle import start_engine
84
+ from .engine_lifecycle import start_engine
85
85
 
86
86
  return start_engine(name_or_id)
87
87
 
@@ -94,7 +94,7 @@ def stop_engine_cmd(
94
94
  ),
95
95
  ):
96
96
  """Stop an engine."""
97
- from .engine.lifecycle import stop_engine
97
+ from .engine_lifecycle import stop_engine
98
98
 
99
99
  return stop_engine(name_or_id, force)
100
100
 
@@ -104,7 +104,7 @@ def terminate_engine_cmd(
104
104
  name_or_id: str = typer.Argument(help="Engine name or instance ID"),
105
105
  ):
106
106
  """Permanently terminate an engine."""
107
- from .engine.lifecycle import terminate_engine
107
+ from .engine_lifecycle import terminate_engine
108
108
 
109
109
  return terminate_engine(name_or_id)
110
110
 
@@ -122,7 +122,7 @@ def ssh_engine_cmd(
122
122
  ),
123
123
  ):
124
124
  """Connect to an engine via SSH."""
125
- from .engine.ssh import ssh_engine
125
+ from .engine_management import ssh_engine
126
126
 
127
127
  return ssh_engine(name_or_id, admin, idle_timeout)
128
128
 
@@ -140,7 +140,7 @@ def config_ssh_cmd(
140
140
  ),
141
141
  ):
142
142
  """Update SSH config with available engines."""
143
- from .engine.config_ssh import config_ssh
143
+ from .engine_management import config_ssh
144
144
 
145
145
  return config_ssh(clean, all_engines, admin)
146
146
 
@@ -159,7 +159,7 @@ def resize_engine_cmd(
159
159
  ),
160
160
  ):
161
161
  """Resize an engine's boot disk."""
162
- from .engine.resize import resize_engine
162
+ from .engine_management import resize_engine
163
163
 
164
164
  return resize_engine(name_or_id, size, online, force)
165
165
 
@@ -171,7 +171,7 @@ def create_ami_cmd(
171
171
  ),
172
172
  ):
173
173
  """Create a 'Golden AMI' from a running engine."""
174
- from .engine.gami import create_ami
174
+ from .engine_management import create_ami
175
175
 
176
176
  return create_ami(name_or_id)
177
177
 
@@ -185,7 +185,7 @@ def coffee_cmd(
185
185
  ),
186
186
  ):
187
187
  """Pour ☕ for an engine: keeps it awake for the given duration (or cancel)."""
188
- from .engine.coffee import coffee
188
+ from .engine_maintenance import coffee
189
189
 
190
190
  return coffee(name_or_id, duration, cancel)
191
191
 
@@ -201,7 +201,7 @@ def idle_timeout_cmd_wrapper(
201
201
  ),
202
202
  ):
203
203
  """Show or set engine idle-detector settings."""
204
- from .engine.idle import idle_timeout_cmd
204
+ from .engine_maintenance import idle_timeout_cmd
205
205
 
206
206
  return idle_timeout_cmd(name_or_id=name_or_id, set=set, slack=slack)
207
207
 
@@ -211,7 +211,7 @@ def debug_engine_cmd(
211
211
  name_or_id: str = typer.Argument(help="Engine name or instance ID"),
212
212
  ):
213
213
  """Debug engine bootstrap status and files."""
214
- from .engine.debug import debug_engine
214
+ from .engine_maintenance import debug_engine
215
215
 
216
216
  return debug_engine(name_or_id)
217
217
 
@@ -221,7 +221,7 @@ def repair_engine_cmd(
221
221
  name_or_id: str = typer.Argument(help="Engine name or instance ID"),
222
222
  ):
223
223
  """Repair an engine that's stuck in a bad state (e.g., after GAMI creation)."""
224
- from .engine.repair import repair_engine
224
+ from .engine_maintenance import repair_engine
225
225
 
226
226
  return repair_engine(name_or_id)
227
227
 
@@ -232,7 +232,7 @@ def create_studio_cmd(
232
232
  size_gb: int = typer.Option(50, "--size", "-s", help="Studio size in GB"),
233
233
  ):
234
234
  """Create a new studio for the current user."""
235
- from .studio.create import create_studio
235
+ from .studio_commands import create_studio
236
236
 
237
237
  return create_studio(size_gb)
238
238
 
@@ -244,7 +244,7 @@ def studio_status_cmd(
244
244
  ),
245
245
  ):
246
246
  """Show status of your studio."""
247
- from .studio.status import studio_status
247
+ from .studio_commands import studio_status
248
248
 
249
249
  return studio_status(user)
250
250
 
@@ -257,7 +257,7 @@ def attach_studio_cmd(
257
257
  ),
258
258
  ):
259
259
  """Attach your studio to an engine."""
260
- from .studio.attach import attach_studio
260
+ from .studio_commands import attach_studio
261
261
 
262
262
  return attach_studio(engine_name_or_id, user)
263
263
 
@@ -269,7 +269,7 @@ def detach_studio_cmd(
269
269
  ),
270
270
  ):
271
271
  """Detach your studio from its current engine."""
272
- from .studio.detach import detach_studio
272
+ from .studio_commands import detach_studio
273
273
 
274
274
  return detach_studio(user)
275
275
 
@@ -281,7 +281,7 @@ def delete_studio_cmd(
281
281
  ),
282
282
  ):
283
283
  """Delete your studio permanently."""
284
- from .studio.delete import delete_studio
284
+ from .studio_commands import delete_studio
285
285
 
286
286
  return delete_studio(user)
287
287
 
@@ -293,7 +293,7 @@ def list_studios_cmd(
293
293
  ),
294
294
  ):
295
295
  """List studios."""
296
- from .studio.list import list_studios
296
+ from .studio_commands import list_studios
297
297
 
298
298
  return list_studios(all_users)
299
299
 
@@ -305,7 +305,7 @@ def reset_studio_cmd(
305
305
  ),
306
306
  ):
307
307
  """Reset a stuck studio (admin operation)."""
308
- from .studio.reset import reset_studio
308
+ from .studio_commands import reset_studio
309
309
 
310
310
  return reset_studio(user)
311
311
 
@@ -318,6 +318,6 @@ def resize_studio_cmd(
318
318
  ),
319
319
  ):
320
320
  """Resize your studio volume (requires detachment)."""
321
- from .studio.resize import resize_studio
321
+ from .studio_commands import resize_studio
322
322
 
323
323
  return resize_studio(size, user)
@@ -1,4 +1,4 @@
1
- """Engine status command."""
1
+ """Core engine commands: launch, list, and status."""
2
2
 
3
3
  import json
4
4
  import time
@@ -7,19 +7,214 @@ from typing import Any, Dict, Optional
7
7
 
8
8
  import boto3
9
9
  import typer
10
+ from rich import box
10
11
  from rich.panel import Panel
11
-
12
- from ..engine_studio_utils.api_utils import make_api_request
13
- from ..engine_studio_utils.aws_utils import _fetch_init_stages, check_aws_sso
14
- from ..engine_studio_utils.constants import HOURLY_COSTS, console
15
- from ..engine_studio_utils.formatting import (
12
+ from rich.progress import Progress, SpinnerColumn, TextColumn
13
+ from rich.table import Table
14
+
15
+ from .shared import (
16
+ HOURLY_COSTS,
17
+ _fetch_init_stages,
18
+ check_aws_sso,
19
+ console,
16
20
  format_duration,
21
+ format_status,
17
22
  get_disk_usage_via_ssm,
23
+ make_api_request,
18
24
  parse_launch_time,
19
25
  resolve_engine,
20
26
  )
21
27
 
22
28
 
29
+ def launch_engine(
30
+ name: str = typer.Argument(help="Name for the new engine"),
31
+ engine_type: str = typer.Option(
32
+ "cpu",
33
+ "--type",
34
+ "-t",
35
+ help="Engine type: cpu, cpumax, t4, a10g, a100, 4_t4, 8_t4, 4_a10g, 8_a10g",
36
+ ),
37
+ user: Optional[str] = typer.Option(None, "--user", "-u", help="Override username"),
38
+ boot_disk_size: Optional[int] = typer.Option(
39
+ None,
40
+ "--size",
41
+ "-s",
42
+ help="Boot disk size in GB (default: 50GB, min: 20GB, max: 1000GB)",
43
+ ),
44
+ availability_zone: Optional[str] = typer.Option(
45
+ None,
46
+ "--az",
47
+ help="Prefer a specific Availability Zone (e.g., us-east-1b). If omitted the service will try all public subnets.",
48
+ ),
49
+ ):
50
+ """Launch a new engine instance."""
51
+ username = check_aws_sso()
52
+ if user:
53
+ username = user
54
+
55
+ # Validate engine type
56
+ valid_types = [
57
+ "cpu",
58
+ "cpumax",
59
+ "t4",
60
+ "a10g",
61
+ "a100",
62
+ "4_t4",
63
+ "8_t4",
64
+ "4_a10g",
65
+ "8_a10g",
66
+ ]
67
+ if engine_type not in valid_types:
68
+ console.print(f"[red]❌ Invalid engine type: {engine_type}[/red]")
69
+ console.print(f"Valid types: {', '.join(valid_types)}")
70
+ raise typer.Exit(1)
71
+
72
+ # Validate boot disk size
73
+ if boot_disk_size is not None:
74
+ if boot_disk_size < 20:
75
+ console.print("[red]❌ Boot disk size must be at least 20GB[/red]")
76
+ raise typer.Exit(1)
77
+ if boot_disk_size > 1000:
78
+ console.print("[red]❌ Boot disk size cannot exceed 1000GB[/red]")
79
+ raise typer.Exit(1)
80
+
81
+ cost = HOURLY_COSTS.get(engine_type, 0)
82
+ disk_info = f" with {boot_disk_size}GB boot disk" if boot_disk_size else ""
83
+ console.print(
84
+ f"Launching [cyan]{name}[/cyan] ({engine_type}){disk_info} for ${cost:.2f}/hour..."
85
+ )
86
+
87
+ with Progress(
88
+ SpinnerColumn(),
89
+ TextColumn("[progress.description]{task.description}"),
90
+ transient=True,
91
+ ) as progress:
92
+ progress.add_task("Creating engine...", total=None)
93
+
94
+ request_data: Dict[str, Any] = {
95
+ "name": name,
96
+ "user": username,
97
+ "engine_type": engine_type,
98
+ }
99
+ if boot_disk_size is not None:
100
+ request_data["boot_disk_size"] = boot_disk_size
101
+ if availability_zone:
102
+ request_data["availability_zone"] = availability_zone
103
+
104
+ response = make_api_request("POST", "/engines", json_data=request_data)
105
+
106
+ if response.status_code == 201:
107
+ data = response.json()
108
+ console.print(f"[green]✓ Engine launched successfully![/green]")
109
+ console.print(f"Instance ID: [cyan]{data['instance_id']}[/cyan]")
110
+ console.print(f"Type: {data['instance_type']} (${cost:.2f}/hour)")
111
+ if boot_disk_size:
112
+ console.print(f"Boot disk: {boot_disk_size}GB")
113
+ console.print("\nThe engine is initializing. This may take a few minutes.")
114
+ console.print(f"Check status with: [cyan]dh engine status {name}[/cyan]")
115
+ else:
116
+ error = response.json().get("error", "Unknown error")
117
+ console.print(f"[red]❌ Failed to launch engine: {error}[/red]")
118
+
119
+
120
+ def list_engines(
121
+ user: Optional[str] = typer.Option(None, "--user", "-u", help="Filter by user"),
122
+ running_only: bool = typer.Option(
123
+ False, "--running", help="Show only running engines"
124
+ ),
125
+ stopped_only: bool = typer.Option(
126
+ False, "--stopped", help="Show only stopped engines"
127
+ ),
128
+ detailed: bool = typer.Option(
129
+ False, "--detailed", "-d", help="Show detailed status (slower)"
130
+ ),
131
+ ):
132
+ """List engines (shows all engines by default)."""
133
+ current_user = check_aws_sso()
134
+
135
+ params = {}
136
+ if user:
137
+ params["user"] = user
138
+ if detailed:
139
+ params["check_ready"] = "true"
140
+
141
+ response = make_api_request("GET", "/engines", params=params)
142
+
143
+ if response.status_code == 200:
144
+ data = response.json()
145
+ engines = data.get("engines", [])
146
+
147
+ # Filter by state if requested
148
+ if running_only:
149
+ engines = [e for e in engines if e["state"].lower() == "running"]
150
+ elif stopped_only:
151
+ engines = [e for e in engines if e["state"].lower() == "stopped"]
152
+
153
+ if not engines:
154
+ console.print("No engines found.")
155
+ return
156
+
157
+ # Only fetch detailed info if requested (slow)
158
+ stages_map = {}
159
+ if detailed:
160
+ stages_map = _fetch_init_stages([e["instance_id"] for e in engines])
161
+
162
+ # Create table
163
+ table = Table(title="Engines", box=box.ROUNDED)
164
+ table.add_column("Name", style="cyan")
165
+ table.add_column("Instance ID", style="dim")
166
+ table.add_column("Type")
167
+ table.add_column("User")
168
+ table.add_column("Status")
169
+ if detailed:
170
+ table.add_column("Disk Usage")
171
+ table.add_column("Uptime/Since")
172
+ table.add_column("$/hour", justify="right")
173
+
174
+ for engine in engines:
175
+ launch_time = parse_launch_time(engine["launch_time"])
176
+ uptime = datetime.now(timezone.utc) - launch_time
177
+ hourly_cost = HOURLY_COSTS.get(engine["engine_type"], 0)
178
+
179
+ if engine["state"].lower() == "running":
180
+ time_str = format_duration(uptime)
181
+ # Only get disk usage if detailed mode
182
+ if detailed:
183
+ disk_usage = get_disk_usage_via_ssm(engine["instance_id"]) or "-"
184
+ else:
185
+ disk_usage = None
186
+ else:
187
+ time_str = launch_time.strftime("%Y-%m-%d %H:%M")
188
+ disk_usage = "-" if detailed else None
189
+
190
+ row_data = [
191
+ engine["name"],
192
+ engine["instance_id"],
193
+ engine["engine_type"],
194
+ engine["user"],
195
+ format_status(engine["state"], engine.get("ready")),
196
+ ]
197
+ if detailed:
198
+ row_data.append(disk_usage)
199
+ row_data.extend(
200
+ [
201
+ time_str,
202
+ f"${hourly_cost:.2f}",
203
+ ]
204
+ )
205
+
206
+ table.add_row(*row_data)
207
+
208
+ console.print(table)
209
+ if not detailed and any(e["state"].lower() == "running" for e in engines):
210
+ console.print(
211
+ "\n[dim]Tip: Use --detailed to see disk usage and bootstrap status (slower)[/dim]"
212
+ )
213
+ else:
214
+ error = response.json().get("error", "Unknown error")
215
+ console.print(f"[red]❌ Failed to list engines: {error}[/red]")
216
+
217
+
23
218
  def engine_status(
24
219
  name_or_id: str = typer.Argument(help="Engine name or instance ID"),
25
220
  detailed: bool = typer.Option(