dayhoff-tools 1.1.10__py3-none-any.whl → 1.13.12__py3-none-any.whl

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 (41) hide show
  1. dayhoff_tools/__init__.py +10 -0
  2. dayhoff_tools/cli/cloud_commands.py +179 -43
  3. dayhoff_tools/cli/engine1/__init__.py +323 -0
  4. dayhoff_tools/cli/engine1/engine_core.py +703 -0
  5. dayhoff_tools/cli/engine1/engine_lifecycle.py +136 -0
  6. dayhoff_tools/cli/engine1/engine_maintenance.py +431 -0
  7. dayhoff_tools/cli/engine1/engine_management.py +505 -0
  8. dayhoff_tools/cli/engine1/shared.py +501 -0
  9. dayhoff_tools/cli/engine1/studio_commands.py +825 -0
  10. dayhoff_tools/cli/engines_studios/__init__.py +6 -0
  11. dayhoff_tools/cli/engines_studios/api_client.py +351 -0
  12. dayhoff_tools/cli/engines_studios/auth.py +144 -0
  13. dayhoff_tools/cli/engines_studios/engine-studio-cli.md +1230 -0
  14. dayhoff_tools/cli/engines_studios/engine_commands.py +1151 -0
  15. dayhoff_tools/cli/engines_studios/progress.py +260 -0
  16. dayhoff_tools/cli/engines_studios/simulators/cli-simulators.md +151 -0
  17. dayhoff_tools/cli/engines_studios/simulators/demo.sh +75 -0
  18. dayhoff_tools/cli/engines_studios/simulators/engine_list_simulator.py +319 -0
  19. dayhoff_tools/cli/engines_studios/simulators/engine_status_simulator.py +369 -0
  20. dayhoff_tools/cli/engines_studios/simulators/idle_status_simulator.py +476 -0
  21. dayhoff_tools/cli/engines_studios/simulators/simulator_utils.py +180 -0
  22. dayhoff_tools/cli/engines_studios/simulators/studio_list_simulator.py +374 -0
  23. dayhoff_tools/cli/engines_studios/simulators/studio_status_simulator.py +164 -0
  24. dayhoff_tools/cli/engines_studios/studio_commands.py +755 -0
  25. dayhoff_tools/cli/main.py +106 -7
  26. dayhoff_tools/cli/utility_commands.py +896 -179
  27. dayhoff_tools/deployment/base.py +70 -6
  28. dayhoff_tools/deployment/deploy_aws.py +165 -25
  29. dayhoff_tools/deployment/deploy_gcp.py +78 -5
  30. dayhoff_tools/deployment/deploy_utils.py +20 -7
  31. dayhoff_tools/deployment/job_runner.py +9 -4
  32. dayhoff_tools/deployment/processors.py +230 -418
  33. dayhoff_tools/deployment/swarm.py +47 -12
  34. dayhoff_tools/embedders.py +28 -26
  35. dayhoff_tools/fasta.py +181 -64
  36. dayhoff_tools/warehouse.py +268 -1
  37. {dayhoff_tools-1.1.10.dist-info → dayhoff_tools-1.13.12.dist-info}/METADATA +20 -5
  38. dayhoff_tools-1.13.12.dist-info/RECORD +54 -0
  39. {dayhoff_tools-1.1.10.dist-info → dayhoff_tools-1.13.12.dist-info}/WHEEL +1 -1
  40. dayhoff_tools-1.1.10.dist-info/RECORD +0 -32
  41. {dayhoff_tools-1.1.10.dist-info → dayhoff_tools-1.13.12.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,755 @@
1
+ """Studio CLI commands for engines_studios system."""
2
+
3
+ import os
4
+ from typing import Optional
5
+
6
+ import click
7
+
8
+ from .api_client import StudioManagerClient
9
+ from .auth import check_aws_auth, detect_aws_environment, get_aws_username
10
+ from .progress import format_time_ago, wait_with_progress
11
+
12
+
13
+ @click.group()
14
+ def studio_cli():
15
+ """Manage studios."""
16
+ pass
17
+
18
+
19
+ # ============================================================================
20
+ # Lifecycle Management
21
+ # ============================================================================
22
+
23
+
24
+ @studio_cli.command("create")
25
+ @click.option("--size", "size_gb", type=int, default=100, help="Studio size in GB")
26
+ @click.option(
27
+ "--user",
28
+ default=None,
29
+ help="User to create studio for (defaults to current user, use for testing/admin)",
30
+ )
31
+ @click.option(
32
+ "--env",
33
+ default=None,
34
+ help="Environment (dev, sand, prod) - auto-detected if not specified",
35
+ )
36
+ def create_studio(size_gb: int, user: Optional[str], env: Optional[str]):
37
+ """Create a new studio for the current user (or specified user with --user flag)."""
38
+
39
+ # Check AWS auth first to provide clear error messages
40
+ check_aws_auth()
41
+
42
+ # Auto-detect environment if not specified
43
+ if env is None:
44
+ env = detect_aws_environment()
45
+ click.echo(f"🔍 Detected environment: {env}")
46
+
47
+ # Require confirmation for non-dev environments
48
+ if env != "dev":
49
+ if not click.confirm(
50
+ f"⚠️ You are about to create in {env.upper()}. Continue?"
51
+ ):
52
+ click.echo("Cancelled")
53
+ raise click.Abort()
54
+
55
+ client = StudioManagerClient(environment=env)
56
+
57
+ # Get user (from flag or current AWS user)
58
+ if user is None:
59
+ try:
60
+ user = get_aws_username()
61
+ except RuntimeError as e:
62
+ click.echo(f"✗ {e}", err=True)
63
+ raise click.Abort()
64
+
65
+ try:
66
+ # Check if user already has a studio (only for current user)
67
+ try:
68
+ current_aws_user = get_aws_username()
69
+ if user == current_aws_user:
70
+ existing = client.get_my_studio()
71
+ if existing:
72
+ click.echo(
73
+ f"✗ You already have a studio: {existing['studio_id']}",
74
+ err=True,
75
+ )
76
+ click.echo(f" Use 'dh studio delete' to remove it first")
77
+ raise click.Abort()
78
+ except click.Abort:
79
+ # Re-raise Abort so it propagates correctly
80
+ raise
81
+ except Exception:
82
+ # If we can't get current user for other reasons, skip the check
83
+ pass
84
+
85
+ click.echo(f"Creating {size_gb}GB studio for {user}...")
86
+
87
+ studio = client.create_studio(user=user, size_gb=size_gb)
88
+
89
+ if "error" in studio:
90
+ click.echo(f"✗ Error: {studio['error']}", err=True)
91
+ raise click.Abort()
92
+
93
+ studio_id = studio["studio_id"]
94
+ click.echo(f"✓ Studio created: {studio_id}")
95
+ click.echo(f"\nAttach to an engine with:")
96
+ click.echo(f" dh studio attach <engine-name>")
97
+
98
+ except Exception as e:
99
+ click.echo(f"✗ Error: {e}", err=True)
100
+ raise click.Abort()
101
+
102
+
103
+ @studio_cli.command("delete")
104
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
105
+ @click.option(
106
+ "--user",
107
+ default=None,
108
+ help="User whose studio to delete (defaults to current user, use for testing/admin)",
109
+ )
110
+ @click.option(
111
+ "--env",
112
+ default=None,
113
+ help="Environment (dev, sand, prod) - auto-detected if not specified",
114
+ )
115
+ def delete_studio(yes: bool, user: Optional[str], env: Optional[str]):
116
+ """Delete your studio (or another user's studio with --user flag)."""
117
+
118
+ # Check AWS auth first to provide clear error messages
119
+ check_aws_auth()
120
+
121
+ # Auto-detect environment if not specified
122
+ if env is None:
123
+ env = detect_aws_environment()
124
+
125
+ client = StudioManagerClient(environment=env)
126
+
127
+ try:
128
+ # Get studio (for current user or specified user)
129
+ if user is None:
130
+ studio = client.get_my_studio()
131
+ if not studio:
132
+ click.echo("You don't have a studio")
133
+ return
134
+ else:
135
+ # Get studio by user - list all and filter
136
+ result = client.list_studios()
137
+ studios = result.get("studios", [])
138
+ user_studios = [s for s in studios if s.get("user") == user]
139
+ if not user_studios:
140
+ click.echo(f"User '{user}' doesn't have a studio")
141
+ return
142
+ studio = user_studios[0]
143
+
144
+ studio_id = studio["studio_id"]
145
+
146
+ # Must be detached first
147
+ if studio["status"] == "attached":
148
+ click.echo("✗ Studio must be detached before deletion", err=True)
149
+ click.echo(" Run: dh studio detach")
150
+ raise click.Abort()
151
+
152
+ # Confirm
153
+ if not yes:
154
+ click.echo(
155
+ f"⚠ WARNING: This will permanently delete all data in {studio_id}"
156
+ )
157
+ if not click.confirm("Are you sure?"):
158
+ click.echo("Cancelled")
159
+ return
160
+
161
+ # Delete
162
+ result = client.delete_studio(studio_id)
163
+
164
+ if "error" in result:
165
+ click.echo(f"✗ Error: {result['error']}", err=True)
166
+ raise click.Abort()
167
+
168
+ click.echo(f"✓ Studio {studio_id} deleted")
169
+
170
+ except Exception as e:
171
+ click.echo(f"✗ Error: {e}", err=True)
172
+ raise click.Abort()
173
+
174
+
175
+ # ============================================================================
176
+ # Status and Information
177
+ # ============================================================================
178
+
179
+
180
+ @studio_cli.command("status")
181
+ @click.option(
182
+ "--user",
183
+ default=None,
184
+ help="User whose studio status to check (defaults to current user, use for testing/admin)",
185
+ )
186
+ @click.option(
187
+ "--env",
188
+ default=None,
189
+ help="Environment (dev, sand, prod) - auto-detected if not specified",
190
+ )
191
+ def studio_status(user: Optional[str], env: Optional[str]):
192
+ """Show information about your studio."""
193
+
194
+ # Check AWS auth first to provide clear error messages
195
+ check_aws_auth()
196
+
197
+ # Auto-detect environment if not specified
198
+ if env is None:
199
+ env = detect_aws_environment()
200
+
201
+ client = StudioManagerClient(environment=env)
202
+
203
+ try:
204
+ # Get studio (for current user or specified user)
205
+ if user is None:
206
+ studio = client.get_my_studio()
207
+ if not studio:
208
+ click.echo("You don't have a studio yet. Create one with:")
209
+ click.echo(" dh studio create")
210
+ return
211
+ else:
212
+ # Get studio by user - list all and filter
213
+ result = client.list_studios()
214
+ studios = result.get("studios", [])
215
+ user_studios = [s for s in studios if s.get("user") == user]
216
+ if not user_studios:
217
+ click.echo(f"User '{user}' doesn't have a studio")
218
+ return
219
+ studio = user_studios[0]
220
+
221
+ # Reordered output: User, Status, Attached to, Account, Size, Created, Studio ID
222
+ click.echo(f"User: {studio['user']}")
223
+ # Status in blue
224
+ click.echo(f"Status: \033[34m{studio['status']}\033[0m")
225
+ # Attached to in blue (if present) - resolve instance ID to engine name
226
+ if studio.get("attached_to"):
227
+ instance_id = studio["attached_to"]
228
+ # Try to resolve instance ID to engine name by searching engines list
229
+ engine_name = instance_id # Default to instance ID if not found
230
+ try:
231
+ engines_result = client.list_engines()
232
+ for engine in engines_result.get("engines", []):
233
+ if engine.get("instance_id") == instance_id:
234
+ engine_name = engine.get("name", instance_id)
235
+ break
236
+ except Exception:
237
+ pass # Fall back to instance ID
238
+ click.echo(f"Attached to: \033[34m{engine_name}\033[0m")
239
+ click.echo(f"Account: {env}")
240
+ click.echo(f"Size: {studio['size_gb']}GB")
241
+ if studio.get("created_at"):
242
+ click.echo(f"Created: {format_time_ago(studio['created_at'])}")
243
+ click.echo(f"Studio ID: {studio['studio_id']}")
244
+
245
+ except Exception as e:
246
+ click.echo(f"✗ Error: {e}", err=True)
247
+ raise click.Abort()
248
+
249
+
250
+ @studio_cli.command("list")
251
+ @click.option(
252
+ "--env",
253
+ default=None,
254
+ help="Environment (dev, sand, prod) - auto-detected if not specified",
255
+ )
256
+ def list_studios(env: Optional[str]):
257
+ """List all studios."""
258
+
259
+ # Check AWS auth first to provide clear error messages
260
+ check_aws_auth()
261
+
262
+ # Auto-detect environment if not specified
263
+ if env is None:
264
+ env = detect_aws_environment()
265
+
266
+ client = StudioManagerClient(environment=env)
267
+
268
+ try:
269
+ result = client.list_studios()
270
+ studios = result.get("studios", [])
271
+
272
+ # Show account header with blue account name
273
+ click.echo(f"\nStudios for AWS Account \033[34m{env}\033[0m")
274
+
275
+ if not studios:
276
+ click.echo("No studios found\n")
277
+ return
278
+
279
+ # Get all engines to map instance IDs to names
280
+ engines_result = client.list_engines()
281
+ engines_map = {}
282
+ for engine in engines_result.get("engines", []):
283
+ engines_map[engine["instance_id"]] = engine["name"]
284
+
285
+ # Calculate dynamic width for User column (longest user + 2 for padding)
286
+ max_user_len = max(
287
+ (len(studio.get("user", "unknown")) for studio in studios), default=4
288
+ )
289
+ user_width = max(max_user_len + 2, len("User") + 2)
290
+
291
+ # Calculate dynamic width for Attached To column
292
+ max_attached_len = 0
293
+ for studio in studios:
294
+ if studio.get("attached_to"):
295
+ instance_id = studio["attached_to"]
296
+ engine_name = engines_map.get(instance_id, "unknown")
297
+ max_attached_len = max(max_attached_len, len(engine_name))
298
+ attached_width = max(
299
+ max_attached_len + 2, len("Attached To") + 2, 3
300
+ ) # At least 3 for "-"
301
+
302
+ # Fixed widths for other columns - reordered to [User, Status, Attached To, Size, Studio ID]
303
+ status_width = 12
304
+ size_width = 10
305
+ id_width = 25
306
+
307
+ # Table top border
308
+ click.echo(
309
+ "╭"
310
+ + "─" * (user_width + 1)
311
+ + "┬"
312
+ + "─" * (status_width + 1)
313
+ + "┬"
314
+ + "─" * (attached_width + 1)
315
+ + "┬"
316
+ + "─" * (size_width + 1)
317
+ + "┬"
318
+ + "─" * (id_width + 1)
319
+ + "╮"
320
+ )
321
+
322
+ # Table header - reordered to [User, Status, Attached To, Size, Studio ID]
323
+ click.echo(
324
+ f"│ {'User':<{user_width}}│ {'Status':<{status_width}}│ {'Attached To':<{attached_width}}│ {'Size':<{size_width}}│ {'Studio ID':<{id_width}}│"
325
+ )
326
+
327
+ # Header separator
328
+ click.echo(
329
+ "├"
330
+ + "─" * (user_width + 1)
331
+ + "┼"
332
+ + "─" * (status_width + 1)
333
+ + "┼"
334
+ + "─" * (attached_width + 1)
335
+ + "┼"
336
+ + "─" * (size_width + 1)
337
+ + "┼"
338
+ + "─" * (id_width + 1)
339
+ + "┤"
340
+ )
341
+
342
+ # Table rows
343
+ for studio in studios:
344
+ user = studio.get("user", "unknown")
345
+ status = studio.get("status", "unknown")
346
+ size = f"{studio.get('size_gb', 0)}GB"
347
+ studio_id = studio.get("studio_id", "unknown")
348
+ attached_to = studio.get("attached_to")
349
+
350
+ # Truncate if needed
351
+ if len(user) > user_width - 1:
352
+ user = user[: user_width - 1]
353
+
354
+ # Color the user (blue)
355
+ user_display = f"\033[34m{user:<{user_width}}\033[0m"
356
+
357
+ # Format status - display "in-use" as "attached" in purple
358
+ if status == "in-use":
359
+ display_status = "attached"
360
+ status_display = (
361
+ f"\033[35m{display_status:<{status_width}}\033[0m" # Purple
362
+ )
363
+ elif status == "available":
364
+ status_display = f"\033[32m{status:<{status_width}}\033[0m" # Green
365
+ elif status in ["attaching", "detaching"]:
366
+ status_display = f"\033[33m{status:<{status_width}}\033[0m" # Yellow
367
+ elif status == "attached":
368
+ status_display = f"\033[35m{status:<{status_width}}\033[0m" # Purple
369
+ elif status == "error":
370
+ status_display = (
371
+ f"\033[31m{status:<{status_width}}\033[0m" # Red for error
372
+ )
373
+ else:
374
+ status_display = (
375
+ f"{status:<{status_width}}" # No color for other states
376
+ )
377
+
378
+ # Format Attached To column
379
+ if attached_to:
380
+ instance_id = attached_to
381
+ engine_name = engines_map.get(instance_id, "unknown")
382
+ # Engine name in white (no color)
383
+ attached_display = f"{engine_name:<{attached_width}}"
384
+ else:
385
+ attached_display = f"{'-':<{attached_width}}"
386
+
387
+ # Color the studio ID (grey)
388
+ studio_id_display = f"\033[90m{studio_id:<{id_width}}\033[0m"
389
+
390
+ click.echo(
391
+ f"│ {user_display}│ {status_display}│ {attached_display}│ {size:<{size_width}}│ {studio_id_display}│"
392
+ )
393
+
394
+ # Table bottom border
395
+ click.echo(
396
+ "╰"
397
+ + "─" * (user_width + 1)
398
+ + "┴"
399
+ + "─" * (status_width + 1)
400
+ + "┴"
401
+ + "─" * (attached_width + 1)
402
+ + "┴"
403
+ + "─" * (size_width + 1)
404
+ + "┴"
405
+ + "─" * (id_width + 1)
406
+ + "╯"
407
+ )
408
+
409
+ click.echo(f"Total: {len(studios)}\n")
410
+
411
+ except Exception as e:
412
+ click.echo(f"✗ Error: {e}", err=True)
413
+ raise click.Abort()
414
+
415
+
416
+ # ============================================================================
417
+ # Attachment
418
+ # ============================================================================
419
+
420
+
421
+ @studio_cli.command("attach")
422
+ @click.argument("engine_name_or_id")
423
+ @click.option(
424
+ "--user",
425
+ default=None,
426
+ help="User whose studio to attach (defaults to current user, use for testing/admin)",
427
+ )
428
+ @click.option(
429
+ "--env",
430
+ default=None,
431
+ help="Environment (dev, sand, prod) - auto-detected if not specified",
432
+ )
433
+ def attach_studio(engine_name_or_id: str, user: Optional[str], env: Optional[str]):
434
+ """Attach your studio to an engine with progress tracking."""
435
+
436
+ # Check AWS auth first to provide clear error messages
437
+ check_aws_auth()
438
+
439
+ # Auto-detect environment if not specified
440
+ if env is None:
441
+ env = detect_aws_environment()
442
+
443
+ client = StudioManagerClient(environment=env)
444
+
445
+ try:
446
+ # Get studio (for current user or specified user)
447
+ if user is None:
448
+ studio = client.get_my_studio()
449
+ if not studio:
450
+ click.echo("✗ You don't have a studio yet. Create one with:", err=True)
451
+ click.echo(" dh studio create")
452
+ raise click.Abort()
453
+ else:
454
+ # Get studio by user - list all and filter
455
+ result = client.list_studios()
456
+ studios = result.get("studios", [])
457
+ user_studios = [s for s in studios if s.get("user") == user]
458
+ if not user_studios:
459
+ click.echo(f"✗ User '{user}' doesn't have a studio", err=True)
460
+ raise click.Abort()
461
+ studio = user_studios[0]
462
+
463
+ studio_id = studio["studio_id"]
464
+
465
+ if studio["status"] != "available":
466
+ click.echo(
467
+ f"✗ Studio is not available (status: {studio['status']})", err=True
468
+ )
469
+ raise click.Abort()
470
+
471
+ # Resolve engine name to ID
472
+ engine = client.get_engine_by_name(engine_name_or_id)
473
+ if not engine:
474
+ engine = {"instance_id": engine_name_or_id, "name": engine_name_or_id}
475
+
476
+ engine_id = engine["instance_id"]
477
+ engine_name = engine.get("name", engine_id)
478
+
479
+ click.echo(f"📎 Attaching studio to {engine_name}...")
480
+
481
+ # Initiate attachment
482
+ result = client.attach_studio(
483
+ studio_id=studio_id, engine_id=engine_id, user=studio["user"]
484
+ )
485
+
486
+ if "error" in result:
487
+ click.echo(f"✗ Error: {result['error']}", err=True)
488
+ raise click.Abort()
489
+
490
+ operation_id = result["operation_id"]
491
+
492
+ # Poll for progress
493
+ click.echo(f"\n⏳ Attachment in progress...\n")
494
+
495
+ try:
496
+ final_status = wait_with_progress(
497
+ status_func=lambda: client.get_attachment_progress(operation_id),
498
+ is_complete_func=lambda s: s.get("status") == "completed",
499
+ label="Progress",
500
+ timeout_seconds=180,
501
+ )
502
+
503
+ click.echo(f"\n✓ Studio attached successfully!")
504
+ click.echo(f"\nYour files are now available at:")
505
+ click.echo(f" /studios/{studio['user']}/")
506
+ click.echo(f"\nConnect with:")
507
+ click.echo(f" ssh {engine_name}")
508
+
509
+ except Exception as e:
510
+ # Get final status to show error details
511
+ try:
512
+ final_status = client.get_attachment_progress(operation_id)
513
+ if final_status.get("error"):
514
+ click.echo(
515
+ f"\n✗ Attachment failed: {final_status['error']}", err=True
516
+ )
517
+
518
+ # Show which step failed
519
+ if final_status.get("steps"):
520
+ failed_step = next(
521
+ (
522
+ s
523
+ for s in reversed(final_status["steps"])
524
+ if s.get("status") == "failed"
525
+ ),
526
+ None,
527
+ )
528
+ if failed_step:
529
+ click.echo(f"Failed at step: {failed_step['name']}")
530
+ if failed_step.get("error"):
531
+ click.echo(f"Error: {failed_step['error']}")
532
+ except:
533
+ pass
534
+
535
+ raise
536
+
537
+ except Exception as e:
538
+ if "Attachment failed" not in str(e):
539
+ click.echo(f"✗ Error: {e}", err=True)
540
+ raise click.Abort()
541
+
542
+
543
+ @studio_cli.command("detach")
544
+ @click.option(
545
+ "--user",
546
+ default=None,
547
+ help="User whose studio to detach (defaults to current user, use for testing/admin)",
548
+ )
549
+ @click.option(
550
+ "--env",
551
+ default=None,
552
+ help="Environment (dev, sand, prod) - auto-detected if not specified",
553
+ )
554
+ def detach_studio(user: Optional[str], env: Optional[str]):
555
+ """Detach your studio from its engine."""
556
+
557
+ # Check AWS auth first to provide clear error messages
558
+ check_aws_auth()
559
+
560
+ # Auto-detect environment if not specified
561
+ if env is None:
562
+ env = detect_aws_environment()
563
+
564
+ client = StudioManagerClient(environment=env)
565
+
566
+ try:
567
+ # Get studio (for current user or specified user)
568
+ if user is None:
569
+ studio = client.get_my_studio()
570
+ if not studio:
571
+ click.echo("✗ You don't have a studio", err=True)
572
+ raise click.Abort()
573
+ else:
574
+ # Get studio by user - list all and filter
575
+ result = client.list_studios()
576
+ studios = result.get("studios", [])
577
+ user_studios = [s for s in studios if s.get("user") == user]
578
+ if not user_studios:
579
+ click.echo(f"✗ User '{user}' doesn't have a studio", err=True)
580
+ raise click.Abort()
581
+ studio = user_studios[0]
582
+
583
+ if studio["status"] != "attached":
584
+ click.echo(
585
+ f"✗ Studio is not attached (status: {studio['status']})", err=True
586
+ )
587
+ raise click.Abort()
588
+
589
+ studio_id = studio["studio_id"]
590
+
591
+ click.echo(f"Detaching studio {studio_id}...")
592
+
593
+ result = client.detach_studio(studio_id)
594
+
595
+ if "error" in result:
596
+ click.echo(f"✗ Error: {result['error']}", err=True)
597
+ raise click.Abort()
598
+
599
+ click.echo(f"✓ Studio detached")
600
+
601
+ except Exception as e:
602
+ click.echo(f"✗ Error: {e}", err=True)
603
+ raise click.Abort()
604
+
605
+
606
+ # ============================================================================
607
+ # Maintenance
608
+ # ============================================================================
609
+
610
+
611
+ @studio_cli.command("resize")
612
+ @click.option("--size", "-s", required=True, type=int, help="New size in GB")
613
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
614
+ @click.option(
615
+ "--user",
616
+ default=None,
617
+ help="User whose studio to resize (defaults to current user, use for testing/admin)",
618
+ )
619
+ @click.option(
620
+ "--env",
621
+ default=None,
622
+ help="Environment (dev, sand, prod) - auto-detected if not specified",
623
+ )
624
+ def resize_studio(size: int, yes: bool, user: Optional[str], env: Optional[str]):
625
+ """Resize your studio volume (requires detachment)."""
626
+
627
+ # Check AWS auth and auto-detect environment if not specified
628
+ check_aws_auth()
629
+
630
+ if env is None:
631
+ env = detect_aws_environment()
632
+
633
+ client = StudioManagerClient(environment=env)
634
+
635
+ try:
636
+ # Get studio (for current user or specified user)
637
+ if user is None:
638
+ studio = client.get_my_studio()
639
+ if not studio:
640
+ click.echo("✗ You don't have a studio", err=True)
641
+ raise click.Abort()
642
+ else:
643
+ # Get studio by user - list all and filter
644
+ result = client.list_studios()
645
+ studios = result.get("studios", [])
646
+ user_studios = [s for s in studios if s.get("user") == user]
647
+ if not user_studios:
648
+ click.echo(f"✗ User '{user}' doesn't have a studio", err=True)
649
+ raise click.Abort()
650
+ studio = user_studios[0]
651
+
652
+ studio_id = studio["studio_id"]
653
+
654
+ # Must be detached
655
+ if studio["status"] != "available":
656
+ click.echo(
657
+ f"✗ Studio must be detached first (status: {studio['status']})",
658
+ err=True,
659
+ )
660
+ raise click.Abort()
661
+
662
+ current_size = studio.get("size_gb", 0)
663
+
664
+ if size <= current_size:
665
+ click.echo(
666
+ f"✗ New size ({size}GB) must be larger than current size ({current_size}GB)",
667
+ err=True,
668
+ )
669
+ raise click.Abort()
670
+
671
+ if not yes and not click.confirm(
672
+ f"Resize studio from {current_size}GB to {size}GB?"
673
+ ):
674
+ click.echo("Cancelled")
675
+ return
676
+
677
+ result = client.resize_studio(studio_id, size)
678
+
679
+ if "error" in result:
680
+ click.echo(f"✗ Error: {result['error']}", err=True)
681
+ raise click.Abort()
682
+
683
+ click.echo(f"✓ Studio resize initiated: {current_size}GB → {size}GB")
684
+
685
+ except Exception as e:
686
+ click.echo(f"✗ Error: {e}", err=True)
687
+ raise click.Abort()
688
+
689
+
690
+ @studio_cli.command("reset")
691
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
692
+ @click.option(
693
+ "--user",
694
+ default=None,
695
+ help="User whose studio to reset (defaults to current user, use for testing/admin)",
696
+ )
697
+ @click.option(
698
+ "--env",
699
+ default=None,
700
+ help="Environment (dev, sand, prod) - auto-detected if not specified",
701
+ )
702
+ def reset_studio(yes: bool, user: Optional[str], env: Optional[str]):
703
+ """Reset a stuck studio (admin operation)."""
704
+
705
+ # Check AWS auth and auto-detect environment if not specified
706
+ check_aws_auth()
707
+
708
+ if env is None:
709
+ env = detect_aws_environment()
710
+
711
+ client = StudioManagerClient(environment=env)
712
+
713
+ try:
714
+ # Get studio (for current user or specified user)
715
+ if user is None:
716
+ studio = client.get_my_studio()
717
+ if not studio:
718
+ click.echo("✗ You don't have a studio", err=True)
719
+ raise click.Abort()
720
+ else:
721
+ # Get studio by user - list all and filter
722
+ result = client.list_studios()
723
+ studios = result.get("studios", [])
724
+ user_studios = [s for s in studios if s.get("user") == user]
725
+ if not user_studios:
726
+ click.echo(f"✗ User '{user}' doesn't have a studio", err=True)
727
+ raise click.Abort()
728
+ studio = user_studios[0]
729
+
730
+ studio_id = studio["studio_id"]
731
+ current_status = studio.get("status", "unknown")
732
+
733
+ click.echo(f"Studio: {studio_id}")
734
+ click.echo(f"Current Status: {current_status}")
735
+
736
+ if current_status in ["available", "attached"]:
737
+ click.echo("Studio is not stuck (status is normal)")
738
+ return
739
+
740
+ if not yes and not click.confirm(f"Reset studio status to 'available'?"):
741
+ click.echo("Cancelled")
742
+ return
743
+
744
+ result = client.reset_studio(studio_id)
745
+
746
+ if "error" in result:
747
+ click.echo(f"✗ Error: {result['error']}", err=True)
748
+ raise click.Abort()
749
+
750
+ click.echo(f"✓ Studio reset to 'available' status")
751
+ click.echo(f" Note: Manual cleanup may be required on engines")
752
+
753
+ except Exception as e:
754
+ click.echo(f"✗ Error: {e}", err=True)
755
+ raise click.Abort()