aws-bootstrap-g4dn 0.6.0__py3-none-any.whl → 0.7.0__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.
aws_bootstrap/cli.py CHANGED
@@ -27,6 +27,7 @@ from .ec2 import (
27
27
  validate_ebs_volume,
28
28
  wait_instance_ready,
29
29
  )
30
+ from .output import OutputFormat, emit, is_text
30
31
  from .ssh import (
31
32
  add_ssh_host,
32
33
  cleanup_stale_ssh_hosts,
@@ -48,22 +49,32 @@ SETUP_SCRIPT = Path(__file__).parent / "resources" / "remote_setup.sh"
48
49
 
49
50
 
50
51
  def step(number: int, total: int, msg: str) -> None:
52
+ if not is_text():
53
+ return
51
54
  click.secho(f"\n[{number}/{total}] {msg}", bold=True, fg="cyan")
52
55
 
53
56
 
54
57
  def info(msg: str) -> None:
58
+ if not is_text():
59
+ return
55
60
  click.echo(f" {msg}")
56
61
 
57
62
 
58
63
  def val(label: str, value: str) -> None:
64
+ if not is_text():
65
+ return
59
66
  click.echo(f" {label}: " + click.style(str(value), fg="bright_white"))
60
67
 
61
68
 
62
69
  def success(msg: str) -> None:
70
+ if not is_text():
71
+ return
63
72
  click.secho(f" {msg}", fg="green")
64
73
 
65
74
 
66
75
  def warn(msg: str) -> None:
76
+ if not is_text():
77
+ return
67
78
  click.secho(f" WARNING: {msg}", fg="yellow", err=True)
68
79
 
69
80
 
@@ -101,8 +112,19 @@ class _AWSGroup(click.Group):
101
112
 
102
113
  @click.group(cls=_AWSGroup)
103
114
  @click.version_option(package_name="aws-bootstrap-g4dn")
104
- def main():
115
+ @click.option(
116
+ "--output",
117
+ "-o",
118
+ type=click.Choice(["text", "json", "yaml", "table"], case_sensitive=False),
119
+ default="text",
120
+ show_default=True,
121
+ help="Output format.",
122
+ )
123
+ @click.pass_context
124
+ def main(ctx, output):
105
125
  """Bootstrap AWS EC2 GPU instances for hybrid local-remote development."""
126
+ ctx.ensure_object(dict)
127
+ ctx.obj["output_format"] = OutputFormat(output)
106
128
 
107
129
 
108
130
  @main.command()
@@ -141,7 +163,9 @@ def main():
141
163
  type=str,
142
164
  help="Attach an existing EBS volume by ID (e.g. vol-0abc123). Mounted at /data.",
143
165
  )
166
+ @click.pass_context
144
167
  def launch(
168
+ ctx,
145
169
  instance_type,
146
170
  ami_filter,
147
171
  spot,
@@ -210,26 +234,48 @@ def launch(
210
234
  pricing = "spot" if config.spot else "on-demand"
211
235
 
212
236
  if config.dry_run:
213
- click.echo()
214
- click.secho("--- Dry Run Summary ---", bold=True, fg="yellow")
215
- val("Instance type", config.instance_type)
216
- val("AMI", f"{ami['ImageId']} ({ami['Name']})")
217
- val("Pricing", pricing)
218
- val("Key pair", config.key_name)
219
- val("Security group", sg_id)
220
- val("Volume", f"{config.volume_size} GB gp3")
221
- val("Region", config.region)
222
- val("Remote setup", "yes" if config.run_setup else "no")
223
- if config.ssh_port != 22:
224
- val("SSH port", str(config.ssh_port))
225
- if config.python_version:
226
- val("Python version", config.python_version)
227
- if config.ebs_storage:
228
- val("EBS data volume", f"{config.ebs_storage} GB gp3 (new, mounted at {EBS_MOUNT_POINT})")
229
- if config.ebs_volume_id:
230
- val("EBS data volume", f"{config.ebs_volume_id} (existing, mounted at {EBS_MOUNT_POINT})")
231
- click.echo()
232
- click.secho("No resources launched (dry-run mode).", fg="yellow")
237
+ if is_text(ctx):
238
+ click.echo()
239
+ click.secho("--- Dry Run Summary ---", bold=True, fg="yellow")
240
+ val("Instance type", config.instance_type)
241
+ val("AMI", f"{ami['ImageId']} ({ami['Name']})")
242
+ val("Pricing", pricing)
243
+ val("Key pair", config.key_name)
244
+ val("Security group", sg_id)
245
+ val("Volume", f"{config.volume_size} GB gp3")
246
+ val("Region", config.region)
247
+ val("Remote setup", "yes" if config.run_setup else "no")
248
+ if config.ssh_port != 22:
249
+ val("SSH port", str(config.ssh_port))
250
+ if config.python_version:
251
+ val("Python version", config.python_version)
252
+ if config.ebs_storage:
253
+ val("EBS data volume", f"{config.ebs_storage} GB gp3 (new, mounted at {EBS_MOUNT_POINT})")
254
+ if config.ebs_volume_id:
255
+ val("EBS data volume", f"{config.ebs_volume_id} (existing, mounted at {EBS_MOUNT_POINT})")
256
+ click.echo()
257
+ click.secho("No resources launched (dry-run mode).", fg="yellow")
258
+ else:
259
+ result: dict = {
260
+ "dry_run": True,
261
+ "instance_type": config.instance_type,
262
+ "ami_id": ami["ImageId"],
263
+ "ami_name": ami["Name"],
264
+ "pricing": pricing,
265
+ "key_name": config.key_name,
266
+ "security_group": sg_id,
267
+ "volume_size_gb": config.volume_size,
268
+ "region": config.region,
269
+ }
270
+ if config.ssh_port != 22:
271
+ result["ssh_port"] = config.ssh_port
272
+ if config.python_version:
273
+ result["python_version"] = config.python_version
274
+ if config.ebs_storage:
275
+ result["ebs_storage_gb"] = config.ebs_storage
276
+ if config.ebs_volume_id:
277
+ result["ebs_volume_id"] = config.ebs_volume_id
278
+ emit(result, ctx=ctx)
233
279
  return
234
280
 
235
281
  # Step 4: Launch instance
@@ -330,7 +376,30 @@ def launch(
330
376
  )
331
377
  success(f"Added SSH config alias: {alias}")
332
378
 
333
- # Print connection info
379
+ # Structured output for non-text modes
380
+ if not is_text(ctx):
381
+ result_data: dict = {
382
+ "instance_id": instance_id,
383
+ "public_ip": public_ip,
384
+ "instance_type": config.instance_type,
385
+ "availability_zone": az,
386
+ "ami_id": ami["ImageId"],
387
+ "pricing": pricing,
388
+ "region": config.region,
389
+ "ssh_alias": alias,
390
+ }
391
+ if ebs_volume_attached:
392
+ ebs_info: dict = {
393
+ "volume_id": ebs_volume_attached,
394
+ "mount_point": EBS_MOUNT_POINT,
395
+ }
396
+ if config.ebs_storage:
397
+ ebs_info["size_gb"] = config.ebs_storage
398
+ result_data["ebs_volume"] = ebs_info
399
+ emit(result_data, ctx=ctx)
400
+ return
401
+
402
+ # Print connection info (text mode)
334
403
  click.echo()
335
404
  click.secho("=" * 60, fg="green")
336
405
  click.secho(" Instance ready!", bold=True, fg="green")
@@ -391,44 +460,66 @@ def launch(
391
460
  show_default=True,
392
461
  help="Show connection commands (SSH, Jupyter, VSCode) for each running instance.",
393
462
  )
394
- def status(region, profile, gpu, instructions):
463
+ @click.pass_context
464
+ def status(ctx, region, profile, gpu, instructions):
395
465
  """Show running instances created by aws-bootstrap."""
396
466
  session = boto3.Session(profile_name=profile, region_name=region)
397
467
  ec2 = session.client("ec2")
398
468
 
399
469
  instances = find_tagged_instances(ec2, "aws-bootstrap-g4dn")
400
470
  if not instances:
401
- click.secho("No active aws-bootstrap instances found.", fg="yellow")
471
+ if is_text(ctx):
472
+ click.secho("No active aws-bootstrap instances found.", fg="yellow")
473
+ else:
474
+ emit({"instances": []}, ctx=ctx)
402
475
  return
403
476
 
404
477
  ssh_hosts = list_ssh_hosts()
405
478
 
406
- click.secho(f"\n Found {len(instances)} instance(s):\n", bold=True, fg="cyan")
407
- if gpu:
408
- click.echo(" " + click.style("Querying GPU info via SSH...", dim=True))
409
- click.echo()
479
+ if is_text(ctx):
480
+ click.secho(f"\n Found {len(instances)} instance(s):\n", bold=True, fg="cyan")
481
+ if gpu:
482
+ click.echo(" " + click.style("Querying GPU info via SSH...", dim=True))
483
+ click.echo()
484
+
485
+ structured_instances = []
410
486
 
411
487
  for inst in instances:
412
488
  state = inst["State"]
413
- state_color = {
414
- "running": "green",
415
- "pending": "yellow",
416
- "stopping": "yellow",
417
- "stopped": "red",
418
- "shutting-down": "red",
419
- }.get(state, "white")
420
489
  alias = ssh_hosts.get(inst["InstanceId"])
421
- alias_str = f" ({alias})" if alias else ""
422
- click.echo(
423
- " "
424
- + click.style(inst["InstanceId"], fg="bright_white")
425
- + click.style(alias_str, fg="cyan")
426
- + " "
427
- + click.style(state, fg=state_color)
428
- )
429
- val(" Type", inst["InstanceType"])
430
- if inst["PublicIp"]:
431
- val(" IP", inst["PublicIp"])
490
+
491
+ # Text mode: inline display
492
+ if is_text(ctx):
493
+ state_color = {
494
+ "running": "green",
495
+ "pending": "yellow",
496
+ "stopping": "yellow",
497
+ "stopped": "red",
498
+ "shutting-down": "red",
499
+ }.get(state, "white")
500
+ alias_str = f" ({alias})" if alias else ""
501
+ click.echo(
502
+ " "
503
+ + click.style(inst["InstanceId"], fg="bright_white")
504
+ + click.style(alias_str, fg="cyan")
505
+ + " "
506
+ + click.style(state, fg=state_color)
507
+ )
508
+ val(" Type", inst["InstanceType"])
509
+ if inst["PublicIp"]:
510
+ val(" IP", inst["PublicIp"])
511
+
512
+ # Build structured record
513
+ inst_data: dict = {
514
+ "instance_id": inst["InstanceId"],
515
+ "state": state,
516
+ "instance_type": inst["InstanceType"],
517
+ "public_ip": inst["PublicIp"] or None,
518
+ "ssh_alias": alias,
519
+ "lifecycle": inst["Lifecycle"],
520
+ "availability_zone": inst["AvailabilityZone"],
521
+ "launch_time": inst["LaunchTime"],
522
+ }
432
523
 
433
524
  # Look up SSH config details once (used by --gpu and --with-instructions)
434
525
  details = None
@@ -446,51 +537,81 @@ def status(region, profile, gpu, instructions):
446
537
  Path("~/.ssh/id_ed25519").expanduser(),
447
538
  )
448
539
  if gpu_info:
449
- val(" GPU", f"{gpu_info.gpu_name} ({gpu_info.architecture})")
450
- if gpu_info.cuda_toolkit_version:
451
- cuda_str = gpu_info.cuda_toolkit_version
452
- if gpu_info.cuda_driver_version != gpu_info.cuda_toolkit_version:
453
- cuda_str += f" (driver supports up to {gpu_info.cuda_driver_version})"
454
- else:
455
- cuda_str = f"{gpu_info.cuda_driver_version} (driver max, toolkit unknown)"
456
- val(" CUDA", cuda_str)
457
- val(" Driver", gpu_info.driver_version)
540
+ if is_text(ctx):
541
+ val(" GPU", f"{gpu_info.gpu_name} ({gpu_info.architecture})")
542
+ if gpu_info.cuda_toolkit_version:
543
+ cuda_str = gpu_info.cuda_toolkit_version
544
+ if gpu_info.cuda_driver_version != gpu_info.cuda_toolkit_version:
545
+ cuda_str += f" (driver supports up to {gpu_info.cuda_driver_version})"
546
+ else:
547
+ cuda_str = f"{gpu_info.cuda_driver_version} (driver max, toolkit unknown)"
548
+ val(" CUDA", cuda_str)
549
+ val(" Driver", gpu_info.driver_version)
550
+ inst_data["gpu"] = {
551
+ "name": gpu_info.gpu_name,
552
+ "architecture": gpu_info.architecture,
553
+ "cuda_toolkit": gpu_info.cuda_toolkit_version,
554
+ "cuda_driver_max": gpu_info.cuda_driver_version,
555
+ "driver": gpu_info.driver_version,
556
+ }
458
557
  else:
459
- click.echo(" GPU: " + click.style("unavailable", dim=True))
558
+ if is_text(ctx):
559
+ click.echo(" GPU: " + click.style("unavailable", dim=True))
460
560
 
461
561
  # EBS data volumes
462
562
  ebs_volumes = find_ebs_volumes_for_instance(ec2, inst["InstanceId"], "aws-bootstrap-g4dn")
463
- for vol in ebs_volumes:
464
- vol_state = f", {vol['State']}" if vol["State"] != "in-use" else ""
465
- val(" EBS", f"{vol['VolumeId']} ({vol['Size']} GB, {EBS_MOUNT_POINT}{vol_state})")
563
+ if ebs_volumes:
564
+ if is_text(ctx):
565
+ for vol in ebs_volumes:
566
+ vol_state = f", {vol['State']}" if vol["State"] != "in-use" else ""
567
+ val(" EBS", f"{vol['VolumeId']} ({vol['Size']} GB, {EBS_MOUNT_POINT}{vol_state})")
568
+ inst_data["ebs_volumes"] = [
569
+ {
570
+ "volume_id": vol["VolumeId"],
571
+ "size_gb": vol["Size"],
572
+ "mount_point": EBS_MOUNT_POINT,
573
+ "state": vol["State"],
574
+ }
575
+ for vol in ebs_volumes
576
+ ]
466
577
 
467
578
  lifecycle = inst["Lifecycle"]
468
579
  is_spot = lifecycle == "spot"
580
+ spot_price = None
469
581
 
470
582
  if is_spot:
471
583
  spot_price = get_spot_price(ec2, inst["InstanceType"], inst["AvailabilityZone"])
584
+ if is_text(ctx):
585
+ if spot_price is not None:
586
+ val(" Pricing", f"spot (${spot_price:.4f}/hr)")
587
+ else:
588
+ val(" Pricing", "spot")
472
589
  if spot_price is not None:
473
- val(" Pricing", f"spot (${spot_price:.4f}/hr)")
474
- else:
475
- val(" Pricing", "spot")
590
+ inst_data["spot_price_per_hour"] = spot_price
476
591
  else:
477
- val(" Pricing", "on-demand")
592
+ if is_text(ctx):
593
+ val(" Pricing", "on-demand")
478
594
 
479
595
  if state == "running" and is_spot:
480
596
  uptime = datetime.now(UTC) - inst["LaunchTime"]
481
597
  total_seconds = int(uptime.total_seconds())
482
- hours, remainder = divmod(total_seconds, 3600)
483
- minutes = remainder // 60
484
- val(" Uptime", f"{hours}h {minutes:02d}m")
598
+ inst_data["uptime_seconds"] = total_seconds
599
+ if is_text(ctx):
600
+ hours, remainder = divmod(total_seconds, 3600)
601
+ minutes = remainder // 60
602
+ val(" Uptime", f"{hours}h {minutes:02d}m")
485
603
  if spot_price is not None:
486
604
  uptime_hours = uptime.total_seconds() / 3600
487
605
  est_cost = uptime_hours * spot_price
488
- val(" Est. cost", f"~${est_cost:.4f}")
606
+ inst_data["estimated_cost"] = round(est_cost, 4)
607
+ if is_text(ctx):
608
+ val(" Est. cost", f"~${est_cost:.4f}")
489
609
 
490
- val(" Launched", str(inst["LaunchTime"]))
610
+ if is_text(ctx):
611
+ val(" Launched", str(inst["LaunchTime"]))
491
612
 
492
613
  # Connection instructions (opt-in, only for running instances with a public IP and alias)
493
- if instructions and state == "running" and inst["PublicIp"] and alias:
614
+ if is_text(ctx) and instructions and state == "running" and inst["PublicIp"] and alias:
494
615
  user = details.user if details else "ubuntu"
495
616
  port = details.port if details else 22
496
617
  port_flag = f" -p {port}" if port != 22 else ""
@@ -511,6 +632,24 @@ def status(region, profile, gpu, instructions):
511
632
  click.secho(" GPU Benchmark:", fg="cyan")
512
633
  click.secho(f" ssh {alias} 'python ~/gpu_benchmark.py'", bold=True)
513
634
 
635
+ structured_instances.append(inst_data)
636
+
637
+ if not is_text(ctx):
638
+ emit(
639
+ {"instances": structured_instances},
640
+ headers={
641
+ "instance_id": "Instance ID",
642
+ "state": "State",
643
+ "instance_type": "Type",
644
+ "public_ip": "IP",
645
+ "ssh_alias": "Alias",
646
+ "lifecycle": "Pricing",
647
+ "uptime_seconds": "Uptime (s)",
648
+ },
649
+ ctx=ctx,
650
+ )
651
+ return
652
+
514
653
  click.echo()
515
654
  first_id = instances[0]["InstanceId"]
516
655
  first_ref = ssh_hosts.get(first_id, first_id)
@@ -524,7 +663,8 @@ def status(region, profile, gpu, instructions):
524
663
  @click.option("--yes", "-y", is_flag=True, default=False, help="Skip confirmation prompt.")
525
664
  @click.option("--keep-ebs", is_flag=True, default=False, help="Preserve EBS data volumes instead of deleting them.")
526
665
  @click.argument("instance_ids", nargs=-1, metavar="[INSTANCE_ID_OR_ALIAS]...")
527
- def terminate(region, profile, yes, keep_ebs, instance_ids):
666
+ @click.pass_context
667
+ def terminate(ctx, region, profile, yes, keep_ebs, instance_ids):
528
668
  """Terminate instances created by aws-bootstrap.
529
669
 
530
670
  Pass specific instance IDs or SSH aliases (e.g. aws-gpu1) to terminate,
@@ -533,6 +673,10 @@ def terminate(region, profile, yes, keep_ebs, instance_ids):
533
673
  session = boto3.Session(profile_name=profile, region_name=region)
534
674
  ec2 = session.client("ec2")
535
675
 
676
+ # In structured output modes, require --yes (prompts would corrupt output)
677
+ if not is_text(ctx) and not yes:
678
+ raise CLIError("--yes is required when using structured output (--output json/yaml/table).")
679
+
536
680
  if instance_ids:
537
681
  targets = []
538
682
  for value in instance_ids:
@@ -548,13 +692,17 @@ def terminate(region, profile, yes, keep_ebs, instance_ids):
548
692
  else:
549
693
  instances = find_tagged_instances(ec2, "aws-bootstrap-g4dn")
550
694
  if not instances:
551
- click.secho("No active aws-bootstrap instances found.", fg="yellow")
695
+ if is_text(ctx):
696
+ click.secho("No active aws-bootstrap instances found.", fg="yellow")
697
+ else:
698
+ emit({"terminated": []}, ctx=ctx)
552
699
  return
553
700
  targets = [inst["InstanceId"] for inst in instances]
554
- click.secho(f"\n Found {len(targets)} instance(s) to terminate:\n", bold=True, fg="cyan")
555
- for inst in instances:
556
- iid = click.style(inst["InstanceId"], fg="bright_white")
557
- click.echo(f" {iid} {inst['State']} {inst['InstanceType']}")
701
+ if is_text(ctx):
702
+ click.secho(f"\n Found {len(targets)} instance(s) to terminate:\n", bold=True, fg="cyan")
703
+ for inst in instances:
704
+ iid = click.style(inst["InstanceId"], fg="bright_white")
705
+ click.echo(f" {iid} {inst['State']} {inst['InstanceType']}")
558
706
 
559
707
  if not yes:
560
708
  click.echo()
@@ -570,36 +718,59 @@ def terminate(region, profile, yes, keep_ebs, instance_ids):
570
718
  ebs_by_instance[target] = volumes
571
719
 
572
720
  changes = terminate_tagged_instances(ec2, targets)
573
- click.echo()
721
+
722
+ terminated_results = []
723
+
724
+ if is_text(ctx):
725
+ click.echo()
574
726
  for change in changes:
575
727
  prev = change["PreviousState"]["Name"]
576
728
  curr = change["CurrentState"]["Name"]
577
- click.echo(
578
- " " + click.style(change["InstanceId"], fg="bright_white") + f" {prev} -> " + click.style(curr, fg="red")
579
- )
580
- removed_alias = remove_ssh_host(change["InstanceId"])
729
+ iid = change["InstanceId"]
730
+ if is_text(ctx):
731
+ click.echo(" " + click.style(iid, fg="bright_white") + f" {prev} -> " + click.style(curr, fg="red"))
732
+ removed_alias = remove_ssh_host(iid)
581
733
  if removed_alias:
582
734
  info(f"Removed SSH config alias: {removed_alias}")
583
735
 
736
+ change_data: dict = {
737
+ "instance_id": iid,
738
+ "previous_state": prev,
739
+ "current_state": curr,
740
+ }
741
+ if removed_alias:
742
+ change_data["ssh_alias_removed"] = removed_alias
743
+ terminated_results.append(change_data)
744
+
584
745
  # Handle EBS volume cleanup
585
746
  for _iid, volumes in ebs_by_instance.items():
586
747
  for vol in volumes:
587
748
  vid = vol["VolumeId"]
588
749
  if keep_ebs:
589
- click.echo()
750
+ if is_text(ctx):
751
+ click.echo()
590
752
  info(f"Preserving EBS volume: {vid} ({vol['Size']} GB)")
591
753
  info(f"Reattach with: aws-bootstrap launch --ebs-volume-id {vid}")
592
754
  else:
593
- click.echo()
755
+ if is_text(ctx):
756
+ click.echo()
594
757
  info(f"Waiting for EBS volume {vid} to detach...")
595
758
  try:
596
759
  waiter = ec2.get_waiter("volume_available")
597
760
  waiter.wait(VolumeIds=[vid], WaiterConfig={"Delay": 10, "MaxAttempts": 30})
598
761
  delete_ebs_volume(ec2, vid)
599
762
  success(f"Deleted EBS volume: {vid}")
763
+ # Record deleted volume in the corresponding terminated result
764
+ for tr in terminated_results:
765
+ if tr["instance_id"] == _iid:
766
+ tr.setdefault("ebs_volumes_deleted", []).append(vid)
600
767
  except Exception as e:
601
768
  warn(f"Failed to delete EBS volume {vid}: {e}")
602
769
 
770
+ if not is_text(ctx):
771
+ emit({"terminated": terminated_results}, ctx=ctx)
772
+ return
773
+
603
774
  click.echo()
604
775
  success(f"Terminated {len(changes)} instance(s).")
605
776
 
@@ -609,27 +780,46 @@ def terminate(region, profile, yes, keep_ebs, instance_ids):
609
780
  @click.option("--yes", "-y", is_flag=True, default=False, help="Skip confirmation prompt.")
610
781
  @click.option("--region", default="us-west-2", show_default=True, help="AWS region.")
611
782
  @click.option("--profile", default=None, help="AWS profile override.")
612
- def cleanup(dry_run, yes, region, profile):
783
+ @click.pass_context
784
+ def cleanup(ctx, dry_run, yes, region, profile):
613
785
  """Remove stale SSH config entries for terminated instances."""
614
786
  session = boto3.Session(profile_name=profile, region_name=region)
615
787
  ec2 = session.client("ec2")
616
788
 
789
+ # In structured output modes, require --yes for non-dry-run (prompts would corrupt output)
790
+ if not is_text(ctx) and not yes and not dry_run:
791
+ raise CLIError("--yes is required when using structured output (--output json/yaml/table).")
792
+
617
793
  live_instances = find_tagged_instances(ec2, "aws-bootstrap-g4dn")
618
794
  live_ids = {inst["InstanceId"] for inst in live_instances}
619
795
 
620
796
  stale = find_stale_ssh_hosts(live_ids)
621
797
  if not stale:
622
- click.secho("No stale SSH config entries found.", fg="green")
798
+ if is_text(ctx):
799
+ click.secho("No stale SSH config entries found.", fg="green")
800
+ else:
801
+ result_key = "stale" if dry_run else "cleaned"
802
+ emit({result_key: []}, ctx=ctx)
623
803
  return
624
804
 
625
- click.secho(f"\n Found {len(stale)} stale SSH config entry(ies):\n", bold=True, fg="cyan")
626
- for iid, alias in stale:
627
- click.echo(" " + click.style(alias, fg="bright_white") + f" ({iid})")
805
+ if is_text(ctx):
806
+ click.secho(f"\n Found {len(stale)} stale SSH config entry(ies):\n", bold=True, fg="cyan")
807
+ for iid, alias in stale:
808
+ click.echo(" " + click.style(alias, fg="bright_white") + f" ({iid})")
628
809
 
629
810
  if dry_run:
630
- click.echo()
631
- for iid, alias in stale:
632
- info(f"Would remove {alias} ({iid})")
811
+ if is_text(ctx):
812
+ click.echo()
813
+ for iid, alias in stale:
814
+ info(f"Would remove {alias} ({iid})")
815
+ else:
816
+ emit(
817
+ {
818
+ "stale": [{"instance_id": iid, "alias": alias} for iid, alias in stale],
819
+ "dry_run": True,
820
+ },
821
+ ctx=ctx,
822
+ )
633
823
  return
634
824
 
635
825
  if not yes:
@@ -639,6 +829,16 @@ def cleanup(dry_run, yes, region, profile):
639
829
  return
640
830
 
641
831
  results = cleanup_stale_ssh_hosts(live_ids)
832
+
833
+ if not is_text(ctx):
834
+ emit(
835
+ {
836
+ "cleaned": [{"instance_id": r.instance_id, "alias": r.alias, "removed": r.removed} for r in results],
837
+ },
838
+ ctx=ctx,
839
+ )
840
+ return
841
+
642
842
  click.echo()
643
843
  for r in results:
644
844
  success(f"Removed {r.alias} ({r.instance_id})")
@@ -663,14 +863,40 @@ def list_cmd():
663
863
  @click.option("--prefix", default="g4dn", show_default=True, help="Instance type family prefix to filter on.")
664
864
  @click.option("--region", default="us-west-2", show_default=True, help="AWS region.")
665
865
  @click.option("--profile", default=None, help="AWS profile override.")
666
- def list_instance_types_cmd(prefix, region, profile):
866
+ @click.pass_context
867
+ def list_instance_types_cmd(ctx, prefix, region, profile):
667
868
  """List EC2 instance types matching a family prefix (e.g. g4dn, p3, g5)."""
668
869
  session = boto3.Session(profile_name=profile, region_name=region)
669
870
  ec2 = session.client("ec2")
670
871
 
671
872
  types = list_instance_types(ec2, prefix)
672
873
  if not types:
673
- click.secho(f"No instance types found matching '{prefix}.*'", fg="yellow")
874
+ if is_text(ctx):
875
+ click.secho(f"No instance types found matching '{prefix}.*'", fg="yellow")
876
+ else:
877
+ emit([], ctx=ctx)
878
+ return
879
+
880
+ if not is_text(ctx):
881
+ structured = [
882
+ {
883
+ "instance_type": t["InstanceType"],
884
+ "vcpus": t["VCpuCount"],
885
+ "memory_mib": t["MemoryMiB"],
886
+ "gpu": t["GpuSummary"] or None,
887
+ }
888
+ for t in types
889
+ ]
890
+ emit(
891
+ structured,
892
+ headers={
893
+ "instance_type": "Instance Type",
894
+ "vcpus": "vCPUs",
895
+ "memory_mib": "Memory (MiB)",
896
+ "gpu": "GPU",
897
+ },
898
+ ctx=ctx,
899
+ )
674
900
  return
675
901
 
676
902
  click.secho(f"\n {len(types)} instance type(s) matching '{prefix}.*':\n", bold=True, fg="cyan")
@@ -682,8 +908,8 @@ def list_instance_types_cmd(prefix, region, profile):
682
908
  click.echo(" " + "-" * 72)
683
909
 
684
910
  for t in types:
685
- gpu = t["GpuSummary"] or "-"
686
- click.echo(f" {t['InstanceType']:<24}{t['VCpuCount']:>6}{t['MemoryMiB']:>14} {gpu}")
911
+ gpu_str = t["GpuSummary"] or "-"
912
+ click.echo(f" {t['InstanceType']:<24}{t['VCpuCount']:>6}{t['MemoryMiB']:>14} {gpu_str}")
687
913
 
688
914
  click.echo()
689
915
 
@@ -692,14 +918,40 @@ def list_instance_types_cmd(prefix, region, profile):
692
918
  @click.option("--filter", "ami_filter", default=DEFAULT_AMI_PREFIX, show_default=True, help="AMI name pattern.")
693
919
  @click.option("--region", default="us-west-2", show_default=True, help="AWS region.")
694
920
  @click.option("--profile", default=None, help="AWS profile override.")
695
- def list_amis_cmd(ami_filter, region, profile):
921
+ @click.pass_context
922
+ def list_amis_cmd(ctx, ami_filter, region, profile):
696
923
  """List available AMIs matching a name pattern."""
697
924
  session = boto3.Session(profile_name=profile, region_name=region)
698
925
  ec2 = session.client("ec2")
699
926
 
700
927
  amis = list_amis(ec2, ami_filter)
701
928
  if not amis:
702
- click.secho(f"No AMIs found matching '{ami_filter}'", fg="yellow")
929
+ if is_text(ctx):
930
+ click.secho(f"No AMIs found matching '{ami_filter}'", fg="yellow")
931
+ else:
932
+ emit([], ctx=ctx)
933
+ return
934
+
935
+ if not is_text(ctx):
936
+ structured = [
937
+ {
938
+ "image_id": ami["ImageId"],
939
+ "name": ami["Name"],
940
+ "creation_date": ami["CreationDate"][:10],
941
+ "architecture": ami["Architecture"],
942
+ }
943
+ for ami in amis
944
+ ]
945
+ emit(
946
+ structured,
947
+ headers={
948
+ "image_id": "Image ID",
949
+ "name": "Name",
950
+ "creation_date": "Created",
951
+ "architecture": "Arch",
952
+ },
953
+ ctx=ctx,
954
+ )
703
955
  return
704
956
 
705
957
  click.secho(f"\n {len(amis)} AMI(s) matching '{ami_filter}' (newest first):\n", bold=True, fg="cyan")