exchange-keyshare 0.1.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.
@@ -0,0 +1,3 @@
1
+ """Exchange Keyshare - CLI for market makers to securely share exchange credentials."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,14 @@
1
+ """CloudFormation operations for exchange-keyshare."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def get_template_path() -> Path:
7
+ """Get path to CloudFormation template."""
8
+ return Path(__file__).parent / "templates" / "stack.yaml"
9
+
10
+
11
+ def load_template() -> str:
12
+ """Load CloudFormation template as string."""
13
+ path = get_template_path()
14
+ return path.read_text()
@@ -0,0 +1,37 @@
1
+ """CLI entrypoint for exchange-keyshare."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from exchange_keyshare.commands.keys import keys
8
+ from exchange_keyshare.commands.setup import setup
9
+ from exchange_keyshare.config import Config
10
+
11
+
12
+ @click.group()
13
+ @click.version_option()
14
+ @click.option(
15
+ "--config",
16
+ "config_path",
17
+ envvar="EXCHANGE_KEYSHARE_CONFIG",
18
+ type=click.Path(),
19
+ help="Path to config file",
20
+ )
21
+ @click.pass_context
22
+ def main(ctx: click.Context, config_path: str | None) -> None:
23
+ """Exchange Keyshare - Securely share exchange API credentials."""
24
+ ctx.ensure_object(dict)
25
+ config = Config()
26
+ if config_path:
27
+ config.config_path = Path(config_path)
28
+ config.load()
29
+ ctx.obj["config"] = config
30
+
31
+
32
+ main.add_command(setup)
33
+ main.add_command(keys)
34
+
35
+
36
+ if __name__ == "__main__":
37
+ main()
@@ -0,0 +1 @@
1
+ """CLI command modules."""
@@ -0,0 +1,476 @@
1
+ """Keys subcommands for managing exchange credentials."""
2
+
3
+ import click
4
+ import questionary
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+
9
+ from exchange_keyshare.config import Config
10
+ from exchange_keyshare.keys import (
11
+ delete_credential,
12
+ get_credential,
13
+ list_credentials,
14
+ upload_credential,
15
+ )
16
+ from exchange_keyshare.schema import (
17
+ PASSPHRASE_REQUIRED_EXCHANGES,
18
+ SUPPORTED_EXCHANGES,
19
+ CredentialSchema,
20
+ )
21
+
22
+
23
+ def require_setup(config: Config) -> tuple[str, str, str]:
24
+ """Check that setup has been run, exit with error if not.
25
+
26
+ Returns (bucket, region, kms_key_arn) guaranteed to be non-None.
27
+ """
28
+ if not config.bucket or not config.region or not config.kms_key_arn:
29
+ click.echo(
30
+ "Error: No bucket configured. Run 'exchange-keyshare setup' to create the AWS infrastructure.",
31
+ err=True,
32
+ )
33
+ raise SystemExit(1)
34
+ return config.bucket, config.region, config.kms_key_arn
35
+
36
+
37
+ def format_labels(labels: list[dict[str, str]]) -> str:
38
+ """Format labels as key=value, key=value string."""
39
+ return ", ".join(f"{l['key']}={l['value']}" for l in labels)
40
+
41
+
42
+ def parse_pairs(input_str: str) -> list[str]:
43
+ """Parse comma-separated pairs input into list."""
44
+ return [p.strip() for p in input_str.split(",") if p.strip()]
45
+
46
+
47
+ def format_credential_info(cred: CredentialSchema, key: str) -> str:
48
+ """Format credential info for display."""
49
+ lines = [
50
+ f" Key: {key}",
51
+ f" Exchange: {cred.exchange}",
52
+ ]
53
+ if cred.pairs:
54
+ lines.append(f" Pairs: {', '.join(cred.pairs)}")
55
+ if cred.labels:
56
+ lines.append(f" Labels: {format_labels(cred.labels)}")
57
+ return "\n".join(lines)
58
+
59
+
60
+ @click.group()
61
+ def keys() -> None:
62
+ """Manage exchange credentials."""
63
+ pass
64
+
65
+
66
+ @keys.command("list")
67
+ @click.pass_context
68
+ def keys_list(ctx: click.Context) -> None:
69
+ """List all credentials."""
70
+ config: Config = ctx.obj["config"]
71
+ bucket, region, _kms_key_arn = require_setup(config)
72
+ console = Console()
73
+
74
+ try:
75
+ credentials = list_credentials(bucket, region)
76
+ except Exception as e:
77
+ console.print(f"[red]Error:[/red] {e}")
78
+ raise SystemExit(1)
79
+
80
+ if not credentials:
81
+ console.print("No credentials found.")
82
+ return
83
+
84
+ table = Table(title=f"{len(credentials)} Credential(s)")
85
+ table.add_column("Key", style="cyan")
86
+ table.add_column("Exchange")
87
+ table.add_column("Pairs", style="dim")
88
+ table.add_column("Labels", style="dim")
89
+
90
+ for cred in credentials:
91
+ table.add_row(
92
+ cred.key,
93
+ cred.exchange,
94
+ ", ".join(cred.pairs) if cred.pairs else "-",
95
+ format_labels(cred.labels) if cred.labels else "-",
96
+ )
97
+
98
+ console.print()
99
+ console.print(table)
100
+
101
+
102
+ def _prompt_cancelled() -> None:
103
+ """Handle user cancellation (Ctrl+C or Escape)."""
104
+ click.echo("\nAborted.", err=True)
105
+ raise SystemExit(1)
106
+
107
+
108
+ def _validate_required(text: str) -> bool | str:
109
+ """Validate that a field is not empty."""
110
+ return True if text else "This field is required"
111
+
112
+
113
+ def _validate_pairs(text: str) -> bool | str:
114
+ """Validate trading pairs format (optional, but must be BASE/QUOTE if provided)."""
115
+ if not text.strip():
116
+ return True # Empty is fine, it's optional
117
+
118
+ pairs = [p.strip() for p in text.split(",") if p.strip()]
119
+ invalid: list[str] = []
120
+ for pair in pairs:
121
+ # Must contain exactly one "/" with non-empty base and quote
122
+ parts = pair.split("/")
123
+ if len(parts) != 2 or not parts[0] or not parts[1]:
124
+ invalid.append(pair)
125
+
126
+ if invalid:
127
+ return f"Invalid format: {', '.join(invalid)}. Use BASE/QUOTE (e.g. BTC/USDT)"
128
+
129
+ return True
130
+
131
+
132
+ @keys.command("create")
133
+ @click.pass_context
134
+ def keys_create(ctx: click.Context) -> None:
135
+ """Create a new credential (interactive)."""
136
+ config: Config = ctx.obj["config"]
137
+ bucket, region, kms_key_arn = require_setup(config)
138
+ console = Console()
139
+
140
+ console.print()
141
+ console.print(Panel.fit("[bold]Create New Credential[/bold]", border_style="blue"))
142
+ console.print()
143
+
144
+ # Exchange selection (arrow keys)
145
+ exchanges = sorted(SUPPORTED_EXCHANGES)
146
+ exchange = questionary.select(
147
+ "Select exchange:",
148
+ choices=exchanges,
149
+ style=questionary.Style([("highlighted", "bold")]),
150
+ ).ask()
151
+
152
+ if exchange is None:
153
+ _prompt_cancelled()
154
+
155
+ console.print()
156
+
157
+ # API credentials (with asterisks)
158
+ api_key = questionary.password("API Key:", validate=_validate_required).ask()
159
+
160
+ if api_key is None:
161
+ _prompt_cancelled()
162
+
163
+ api_secret = questionary.password("API Secret:", validate=_validate_required).ask()
164
+
165
+ if api_secret is None:
166
+ _prompt_cancelled()
167
+
168
+ credential_data: dict[str, str] = {
169
+ "api_key": api_key,
170
+ "api_secret": api_secret,
171
+ }
172
+
173
+ # Passphrase
174
+ if exchange in PASSPHRASE_REQUIRED_EXCHANGES:
175
+ passphrase = questionary.password(
176
+ "Passphrase (required for this exchange):",
177
+ validate=_validate_required,
178
+ ).ask()
179
+
180
+ if passphrase is None:
181
+ _prompt_cancelled()
182
+
183
+ credential_data["passphrase"] = passphrase
184
+ else:
185
+ passphrase = questionary.password("Passphrase (optional):").ask()
186
+
187
+ if passphrase is None:
188
+ _prompt_cancelled()
189
+
190
+ if passphrase:
191
+ credential_data["passphrase"] = passphrase
192
+
193
+ console.print()
194
+
195
+ # Trading pairs
196
+ pairs_input = questionary.text(
197
+ "Trading pairs (optional, BASE/QUOTE format, e.g. BTC/USDT, ETH/USDT):",
198
+ validate=_validate_pairs,
199
+ ).ask()
200
+
201
+ if pairs_input is None:
202
+ _prompt_cancelled()
203
+
204
+ pairs: list[str] | None = parse_pairs(pairs_input) or None
205
+
206
+ # Labels - always prompt for name first
207
+ labels: list[dict[str, str]] = []
208
+
209
+ name_label = questionary.text("Credential name (optional):").ask()
210
+
211
+ if name_label is None:
212
+ _prompt_cancelled()
213
+
214
+ if name_label:
215
+ labels.append({"key": "name", "value": name_label})
216
+
217
+ # Additional labels
218
+ add_more = questionary.confirm(
219
+ "Add more labels? (key/value tags for your own organization)",
220
+ default=False,
221
+ ).ask()
222
+
223
+ if add_more is None:
224
+ _prompt_cancelled()
225
+
226
+ if add_more:
227
+ while True:
228
+ label_key = questionary.text("Label key (Enter to finish):").ask()
229
+
230
+ if label_key is None:
231
+ _prompt_cancelled()
232
+
233
+ if not label_key:
234
+ break
235
+
236
+ label_value = questionary.text(f"Value for '{label_key}':").ask()
237
+
238
+ if label_value is None:
239
+ _prompt_cancelled()
240
+
241
+ labels.append({"key": label_key, "value": label_value})
242
+
243
+ # Build credential
244
+ credential = CredentialSchema(
245
+ version="1",
246
+ exchange=exchange,
247
+ credential=credential_data,
248
+ pairs=pairs,
249
+ labels=labels or None,
250
+ )
251
+
252
+ # Summary
253
+ console.print()
254
+ table = Table(title="Credential Summary", show_header=False, box=None)
255
+ table.add_column("Field", style="dim")
256
+ table.add_column("Value")
257
+
258
+ table.add_row("Exchange", f"[cyan]{exchange}[/cyan]")
259
+ if pairs:
260
+ table.add_row("Pairs", ", ".join(pairs))
261
+ if labels:
262
+ table.add_row("Labels", format_labels(labels))
263
+
264
+ console.print(table)
265
+ console.print()
266
+
267
+ # Confirm upload
268
+ confirm = questionary.confirm("Upload this credential?", default=True).ask()
269
+
270
+ if not confirm:
271
+ click.echo("Aborted.")
272
+ raise SystemExit(0)
273
+
274
+ # Upload
275
+ try:
276
+ s3_key = upload_credential(bucket, region, credential, kms_key_arn)
277
+ console.print()
278
+ console.print("[green]✓[/green] Credential uploaded successfully!")
279
+ console.print(f" Key: [cyan]{s3_key}[/cyan]")
280
+ except Exception as e:
281
+ console.print(f"[red]Error:[/red] {e}", style="red")
282
+ raise SystemExit(1)
283
+
284
+
285
+ @keys.command("delete")
286
+ @click.argument("key")
287
+ @click.option("-y", "--yes", is_flag=True, help="Skip confirmation prompt")
288
+ @click.pass_context
289
+ def keys_delete(ctx: click.Context, key: str, yes: bool) -> None:
290
+ """Delete a credential by S3 key."""
291
+ config: Config = ctx.obj["config"]
292
+ bucket, region, _kms_key_arn = require_setup(config)
293
+ console = Console()
294
+
295
+ # Get credential info before deletion
296
+ try:
297
+ credential = get_credential(bucket, region, key)
298
+ except Exception as e:
299
+ console.print(f"[red]Error:[/red] Could not find credential: {e}")
300
+ raise SystemExit(1)
301
+
302
+ # Show credential info
303
+ console.print()
304
+ table = Table(title="Credential to Delete", show_header=False, box=None)
305
+ table.add_column("Field", style="dim")
306
+ table.add_column("Value")
307
+
308
+ table.add_row("Key", f"[cyan]{key}[/cyan]")
309
+ table.add_row("Exchange", credential.exchange)
310
+ if credential.pairs:
311
+ table.add_row("Pairs", ", ".join(credential.pairs))
312
+ if credential.labels:
313
+ table.add_row("Labels", format_labels(credential.labels))
314
+
315
+ console.print(table)
316
+ console.print()
317
+
318
+ if not yes:
319
+ confirm = questionary.confirm("Delete this credential?", default=False).ask()
320
+ if not confirm:
321
+ console.print("Aborted.")
322
+ raise SystemExit(0)
323
+
324
+ # Delete
325
+ try:
326
+ delete_credential(bucket, region, key)
327
+ console.print("[green]✓[/green] Credential deleted successfully.")
328
+ except Exception as e:
329
+ console.print(f"[red]Error:[/red] {e}")
330
+ raise SystemExit(1)
331
+
332
+
333
+ @keys.command("update")
334
+ @click.argument("key")
335
+ @click.pass_context
336
+ def keys_update(ctx: click.Context, key: str) -> None:
337
+ """Update a credential's pairs and labels (interactive)."""
338
+ config: Config = ctx.obj["config"]
339
+ bucket, region, kms_key_arn = require_setup(config)
340
+ console = Console()
341
+
342
+ try:
343
+ credential = get_credential(bucket, region, key)
344
+ except Exception as e:
345
+ console.print(f"[red]Error:[/red] Could not find credential: {e}")
346
+ raise SystemExit(1)
347
+
348
+ # Show current credential
349
+ console.print()
350
+ table = Table(title="Current Credential", show_header=False, box=None)
351
+ table.add_column("Field", style="dim")
352
+ table.add_column("Value")
353
+
354
+ table.add_row("Key", f"[cyan]{key}[/cyan]")
355
+ table.add_row("Exchange", credential.exchange)
356
+ if credential.pairs:
357
+ table.add_row("Pairs", ", ".join(credential.pairs))
358
+ if credential.labels:
359
+ table.add_row("Labels", format_labels(credential.labels))
360
+
361
+ console.print(table)
362
+ console.print()
363
+
364
+ # Pairs
365
+ current_pairs = list(credential.pairs) if credential.pairs else []
366
+ current_pairs_str = ", ".join(current_pairs) if current_pairs else ""
367
+ pairs_input = questionary.text(
368
+ "Trading pairs (BASE/QUOTE format, Enter to keep current):",
369
+ default=current_pairs_str,
370
+ validate=_validate_pairs,
371
+ ).ask()
372
+
373
+ if pairs_input is None:
374
+ _prompt_cancelled()
375
+
376
+ new_pairs = parse_pairs(pairs_input) if pairs_input else []
377
+
378
+ # Labels
379
+ new_labels: list[dict[str, str]] = list(credential.labels) if credential.labels else []
380
+
381
+ while True:
382
+ # Build choices - "Done" first as default
383
+ choices = ["Done editing labels", "Add label"]
384
+ if new_labels:
385
+ choices.append("Remove label")
386
+ choices.append("Clear all labels")
387
+
388
+ action = questionary.select("Labels:", choices=choices).ask()
389
+
390
+ if action is None:
391
+ _prompt_cancelled()
392
+
393
+ if action == "Done editing labels":
394
+ break
395
+
396
+ if action == "Add label":
397
+ label_key = questionary.text("Label key:").ask()
398
+ if label_key is None:
399
+ _prompt_cancelled()
400
+ if label_key:
401
+ label_value = questionary.text(f"Value for '{label_key}':").ask()
402
+ if label_value is None:
403
+ _prompt_cancelled()
404
+ new_labels = [l for l in new_labels if l["key"] != label_key]
405
+ new_labels.append({"key": label_key, "value": label_value})
406
+
407
+ elif action == "Remove label" and new_labels:
408
+ label_key = questionary.select(
409
+ "Label to remove:",
410
+ choices=[l["key"] for l in new_labels],
411
+ ).ask()
412
+ if label_key is None:
413
+ _prompt_cancelled()
414
+ new_labels = [l for l in new_labels if l["key"] != label_key]
415
+
416
+ elif action == "Clear all labels":
417
+ new_labels = []
418
+
419
+ # Show current state after changes
420
+ if action != "Done editing labels":
421
+ if new_labels:
422
+ console.print(f" Labels: {format_labels(new_labels)}")
423
+ else:
424
+ console.print(" Labels: (none)")
425
+
426
+ # Check for changes
427
+ old_pairs_set = set(current_pairs)
428
+ new_pairs_set = set(new_pairs)
429
+ old_labels_dict = {l["key"]: l["value"] for l in (credential.labels or [])}
430
+ new_labels_dict = {l["key"]: l["value"] for l in new_labels}
431
+
432
+ if old_pairs_set == new_pairs_set and old_labels_dict == new_labels_dict:
433
+ console.print("\nNo changes to apply.")
434
+ return
435
+
436
+ # Show diff
437
+ console.print()
438
+ console.print("[bold]Changes:[/bold]")
439
+
440
+ if old_pairs_set != new_pairs_set:
441
+ old_display = ", ".join(sorted(old_pairs_set)) or "(none)"
442
+ new_display = ", ".join(sorted(new_pairs_set)) or "(none)"
443
+ console.print(f" Pairs: {old_display} → {new_display}")
444
+
445
+ if old_labels_dict != new_labels_dict:
446
+ for k, v in new_labels_dict.items():
447
+ if k not in old_labels_dict:
448
+ console.print(f" Labels: [green]+{k}={v}[/green]")
449
+ elif old_labels_dict[k] != v:
450
+ console.print(f" Labels: {k}={old_labels_dict[k]} → {k}={v}")
451
+ for k in old_labels_dict:
452
+ if k not in new_labels_dict:
453
+ console.print(f" Labels: [red]-{k}={old_labels_dict[k]}[/red]")
454
+
455
+ console.print()
456
+
457
+ confirm = questionary.confirm("Apply these changes?", default=True).ask()
458
+
459
+ if not confirm:
460
+ console.print("Aborted.")
461
+ raise SystemExit(0)
462
+
463
+ updated = CredentialSchema(
464
+ version=credential.version,
465
+ exchange=credential.exchange,
466
+ credential=credential.credential,
467
+ pairs=new_pairs or None,
468
+ labels=new_labels or None,
469
+ )
470
+
471
+ try:
472
+ upload_credential(bucket, region, updated, kms_key_arn, s3_key=key)
473
+ console.print("[green]✓[/green] Credential updated successfully.")
474
+ except Exception as e:
475
+ console.print(f"[red]Error:[/red] {e}")
476
+ raise SystemExit(1)
@@ -0,0 +1,170 @@
1
+ """Setup command for creating AWS infrastructure."""
2
+
3
+ import click
4
+ from rich.console import Console, Group, RenderableType
5
+ from rich.live import Live
6
+ from rich.spinner import Spinner
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+
10
+ from exchange_keyshare.config import Config
11
+ from exchange_keyshare.setup import (
12
+ ResourceStatus,
13
+ StackProgress,
14
+ get_default_region,
15
+ get_friendly_type,
16
+ get_stack_outputs,
17
+ poll_stack_progress,
18
+ start_stack_creation,
19
+ )
20
+
21
+
22
+ def get_status_style(status: str) -> str:
23
+ """Get rich style for a resource status."""
24
+ if "COMPLETE" in status and "ROLLBACK" not in status:
25
+ return "green"
26
+ elif "IN_PROGRESS" in status:
27
+ return "yellow"
28
+ elif "FAILED" in status or "ROLLBACK" in status:
29
+ return "red"
30
+ return "white"
31
+
32
+
33
+ def get_status_icon(status: str) -> str:
34
+ """Get icon for a resource status."""
35
+ if "COMPLETE" in status and "ROLLBACK" not in status:
36
+ return "[green]✓[/green]"
37
+ elif "IN_PROGRESS" in status:
38
+ return "[yellow]⋯[/yellow]"
39
+ elif "FAILED" in status or "ROLLBACK" in status:
40
+ return "[red]✗[/red]"
41
+ return " "
42
+
43
+
44
+ def build_progress_display(progress: StackProgress) -> RenderableType:
45
+ """Build a rich display showing resource creation progress with spinner."""
46
+ table = Table(show_header=True, header_style="bold", box=None)
47
+ table.add_column("", width=2)
48
+ table.add_column("Resource", style="cyan", min_width=25)
49
+ table.add_column("Type", style="dim")
50
+ table.add_column("Status")
51
+
52
+ # Sort resources: in_progress first, then by name
53
+ def sort_key(item: tuple[str, ResourceStatus]) -> tuple[int, str]:
54
+ _, r = item
55
+ if "IN_PROGRESS" in r.status:
56
+ return (0, r.logical_id)
57
+ elif "COMPLETE" in r.status:
58
+ return (2, r.logical_id)
59
+ else:
60
+ return (1, r.logical_id)
61
+
62
+ sorted_resources = sorted(progress.resources.items(), key=sort_key)
63
+
64
+ for _logical_id, resource in sorted_resources:
65
+ icon = get_status_icon(resource.status)
66
+ style = get_status_style(resource.status)
67
+ friendly_type = get_friendly_type(resource.resource_type)
68
+
69
+ # Simplify status text
70
+ status_text = resource.status.replace("_", " ").title()
71
+
72
+ table.add_row(
73
+ icon,
74
+ resource.logical_id,
75
+ friendly_type,
76
+ f"[{style}]{status_text}[/{style}]",
77
+ )
78
+
79
+ # Add spinner header if still in progress
80
+ if not progress.is_complete and not progress.is_failed:
81
+ spinner = Spinner("dots", text=Text(" Creating infrastructure...", style="bold"))
82
+ return Group(spinner, Text(""), table)
83
+
84
+ return table
85
+
86
+
87
+ @click.command()
88
+ @click.pass_context
89
+ def setup(ctx: click.Context) -> None:
90
+ """Create AWS infrastructure for credential storage."""
91
+ config: Config = ctx.obj["config"]
92
+ console = Console()
93
+
94
+ if config.stack_name:
95
+ console.print(f"Already set up with stack: [cyan]{config.stack_name}[/cyan]")
96
+ console.print(f" Bucket: {config.bucket}")
97
+ console.print(f" Region: {config.region}")
98
+ console.print(f" Role ARN: {config.role_arn}")
99
+ console.print(f"\nTo reconfigure, delete {config.config_path}")
100
+ return
101
+
102
+ console.print("[bold]Exchange Keyshare Setup[/bold]")
103
+ console.print("=" * 40)
104
+ console.print()
105
+
106
+ principal_arn = click.prompt("Principal ARN (provided by the credential consumer)", type=str)
107
+ external_id = click.prompt("External ID (provided by the credential consumer)", type=str)
108
+
109
+ bucket_name = click.prompt(
110
+ "Bucket name (leave empty for auto-generated)",
111
+ default="",
112
+ show_default=False,
113
+ )
114
+ if not bucket_name:
115
+ bucket_name = None
116
+
117
+ default_region = get_default_region()
118
+ region = click.prompt("AWS region", default=default_region)
119
+
120
+ console.print()
121
+ console.print("[bold]Creating infrastructure...[/bold]")
122
+ console.print()
123
+
124
+ try:
125
+ stack_name, region, cfn = start_stack_creation(
126
+ external_id=external_id,
127
+ principal_arn=principal_arn,
128
+ bucket_name=bucket_name,
129
+ region=region,
130
+ )
131
+ except Exception as e:
132
+ console.print(f"[red]Error: {e}[/red]")
133
+ raise SystemExit(1)
134
+
135
+ # Poll with live display
136
+ final_progress: StackProgress | None = None
137
+
138
+ with Live(console=console, refresh_per_second=10) as live:
139
+ for progress in poll_stack_progress(stack_name, cfn):
140
+ display = build_progress_display(progress)
141
+ live.update(display)
142
+ final_progress = progress
143
+
144
+ if progress.is_complete or progress.is_failed:
145
+ break
146
+
147
+ if final_progress is None or final_progress.is_failed:
148
+ console.print()
149
+ console.print(f"[red]Error: {final_progress.failure_reason if final_progress else 'Unknown error'}[/red]")
150
+ raise SystemExit(1)
151
+
152
+ # Get outputs and save config
153
+ result = get_stack_outputs(stack_name, region)
154
+
155
+ config.bucket = result.bucket
156
+ config.region = result.region
157
+ config.stack_name = result.stack_name
158
+ config.role_arn = result.role_arn
159
+ config.external_id = result.external_id
160
+ config.kms_key_arn = result.kms_key_arn
161
+ config.save()
162
+
163
+ console.print()
164
+ console.print("[green bold]Setup complete![/green bold]")
165
+ console.print()
166
+ console.print("[bold]Connection details (provide to credential consumer):[/bold]")
167
+ console.print(f" Role ARN: [cyan]{result.role_arn}[/cyan]")
168
+ console.print(f" Bucket: [cyan]{result.bucket}[/cyan]")
169
+ console.print(f" External ID: [cyan]{result.external_id}[/cyan]")
170
+ console.print(f" Region: [cyan]{result.region}[/cyan]")