cloudshellgpt 1.0.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.
cloudshellgpt/cli.py ADDED
@@ -0,0 +1,612 @@
1
+ """CloudShellGPT — AWS CLI that speaks your language.
2
+
3
+ Entry point for the CLI. Built with Typer for clean command structure.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from typing import TYPE_CHECKING, cast
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+ from rich.text import Text
15
+
16
+ from cloudshellgpt import __version__
17
+
18
+ if TYPE_CHECKING:
19
+ from cloudshellgpt.bedrock_translator import BedrockError, Translation
20
+ from cloudshellgpt.safety import SafetyCheck, SafetyLayer
21
+
22
+ app = typer.Typer(
23
+ name="csgpt",
24
+ help="AWS CLI that speaks your language. Natural language to AWS operations via Amazon Bedrock.",
25
+ add_completion=True,
26
+ no_args_is_help=True,
27
+ rich_markup_mode="rich",
28
+ )
29
+
30
+ console = Console()
31
+
32
+
33
+ def _show_banner() -> None:
34
+ """Show the CloudShellGPT banner on first run."""
35
+ banner = Text()
36
+ banner.append("⚡ ", style="bold yellow")
37
+ banner.append("CloudShellGPT", style="bold cyan")
38
+ banner.append(f" v{__version__}", style="dim")
39
+ banner.append(" — AWS CLI that speaks your language", style="dim italic")
40
+ console.print(banner)
41
+
42
+
43
+ @app.command()
44
+ def ask(
45
+ intent: str = typer.Argument(..., help="Your request in natural language (any language)"),
46
+ dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Preview without executing"),
47
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation for safe commands"),
48
+ output: str = typer.Option(
49
+ "table", "--output", "-o", help="Output format: table|json|yaml|csv"
50
+ ),
51
+ region: str | None = typer.Option(None, "--region", "-r", help="AWS region override"),
52
+ explain: bool = typer.Option(
53
+ False, "--explain", "-e", help="Show what the command does after execution"
54
+ ),
55
+ cost_only: bool = typer.Option(
56
+ False, "--cost-only", help="Show cost preview without executing"
57
+ ),
58
+ ) -> None:
59
+ """Execute AWS operations using natural language.
60
+
61
+ Examples:
62
+ csgpt "lista los buckets de S3"
63
+ csgpt "muéstrame las lambdas que fallaron en las últimas 24h"
64
+ csgpt "create a t3.micro ec2 with a security group that allows SSH"
65
+ """
66
+ _show_banner()
67
+
68
+ with console.status("[bold green]Thinking...[/bold green]"):
69
+ # Lazy imports for command execution
70
+ from cloudshellgpt.audit import AuditLogger
71
+ from cloudshellgpt.bedrock_translator import BedrockError, BedrockTranslator
72
+ from cloudshellgpt.config import Config
73
+ from cloudshellgpt.cost import CostEstimator
74
+ from cloudshellgpt.executor import AWSExecutor
75
+ from cloudshellgpt.formatter import Formatter, FormatType
76
+ from cloudshellgpt.i18n import get_labels
77
+ from cloudshellgpt.intent import IntentParser
78
+ from cloudshellgpt.safety import SafetyLayer
79
+
80
+ # Load user configuration
81
+ cfg = Config()
82
+ effective_region = region or cfg.region
83
+
84
+ # 1. Parse intent
85
+ parser = IntentParser()
86
+ parsed = parser.parse(intent, region=region)
87
+
88
+ # Get UI labels based on detected language
89
+ ui_lang = (
90
+ parsed.detected_language if parsed.detected_language != "unknown" else cfg.language
91
+ )
92
+ labels = get_labels(ui_lang)
93
+
94
+ if not parsed.confidence or parsed.confidence < 0.5:
95
+ console.print(f"[red]{labels['could_not_understand'].format(intent=intent)}[/red]")
96
+ console.print(f"[yellow]{labels['did_you_mean']}[/yellow]")
97
+ raise typer.Exit(1)
98
+
99
+ # 2. Translate to AWS CLI via Bedrock
100
+ translator = BedrockTranslator(region=effective_region, model_id=cfg.bedrock_model)
101
+ try:
102
+ translation = translator.translate(parsed)
103
+ except BedrockError as e:
104
+ _show_bedrock_error(e)
105
+ raise typer.Exit(1) from None
106
+
107
+ # 3. Cost estimation (graceful fallback if Cost Explorer fails)
108
+ cost_estimator = CostEstimator(region=effective_region, max_cost_alert=cfg.max_cost_alert)
109
+ cost_estimate = cost_estimator.estimate(translation.command)
110
+
111
+ # 4. Safety check (with cost estimate integrated)
112
+ safety = SafetyLayer(region=effective_region, max_cost_alert=cfg.max_cost_alert)
113
+ check = safety.assess(translation, cost_estimate=cost_estimate)
114
+
115
+ # --- From here on, no spinner (allows interactive input) ---
116
+
117
+ if cost_only:
118
+ if cost_estimate.status == "unknown":
119
+ console.print(
120
+ Panel(
121
+ "[bold yellow]Cost estimation unavailable[/bold yellow]\n"
122
+ "[dim]Cost Explorer API could not be reached.[/dim]",
123
+ title="[bold]Cost Preview[/bold]",
124
+ border_style="yellow",
125
+ )
126
+ )
127
+ else:
128
+ console.print(
129
+ Panel(
130
+ f"Estimated cost: {check.estimated_cost}",
131
+ title="[bold]Cost Preview[/bold]",
132
+ )
133
+ )
134
+ return
135
+
136
+ # 5. Show warning if cost estimation is unavailable
137
+ if cost_estimate.status == "unknown":
138
+ console.print(
139
+ Text.from_markup(
140
+ "[bold yellow]\u26a0\ufe0f Cost estimation unavailable "
141
+ "\u2014 proceed with caution[/bold yellow]"
142
+ )
143
+ )
144
+
145
+ # 6. Show what we're about to do
146
+ console.print(
147
+ Panel(
148
+ f"[bold]{labels['command_label']}:[/bold]\n[cyan]{translation.command}[/cyan]\n\n"
149
+ f"[bold]{labels['explanation_label']}:[/bold]\n{translation.explanation}\n\n"
150
+ f"[bold]{labels['risk_label']}:[/bold] [{_risk_color(check.risk_level)}]{check.risk_level}[/{_risk_color(check.risk_level)}]\n"
151
+ f"[bold]{labels['cost_label']}:[/bold] {check.estimated_cost}",
152
+ title=f"[bold]{labels['plan_title']}[/bold]",
153
+ border_style="blue",
154
+ )
155
+ )
156
+
157
+ # 6b. Flag explanations (learning mode)
158
+ if cfg.enable_learning_mode:
159
+ _show_flag_explanations(translation.command, labels)
160
+
161
+ # 7. Confirmation flow (varies by risk level)
162
+ _handle_confirmation(check, safety, translation, yes, labels)
163
+
164
+ # 8. Audit BEFORE execution (safety: record intent even if process crashes)
165
+ audit = AuditLogger()
166
+ entry_id = audit.log_before(
167
+ intent=intent,
168
+ command=translation.command,
169
+ risk=check.risk_level,
170
+ dry_run=dry_run,
171
+ )
172
+
173
+ # 9. Execute (dry_run only if user explicitly passed --dry-run flag;
174
+ # critical operations already did their dry-run in confirmation flow)
175
+ executor = AWSExecutor(dry_run=dry_run, timeout=cfg.timeout)
176
+ result = executor.run(translation.command)
177
+
178
+ # 10. Audit AFTER execution (record outcome)
179
+ audit.log_after(entry_id, result)
180
+
181
+ # 11. Format output
182
+ format_type: FormatType = cast(
183
+ FormatType,
184
+ output if output in ("table", "json", "yaml", "csv", "raw") else "table",
185
+ )
186
+ formatter = Formatter(format_type=format_type)
187
+ formatter.render(result)
188
+
189
+ # 12. Post-execution educational tip
190
+ if result.exit_code == 0 and cfg.enable_learning_mode:
191
+ # Prefer LLM-generated tip (localized) over static dictionary
192
+ tip = translation.tip
193
+ if not tip:
194
+ from cloudshellgpt.learning import PostExecutionTips
195
+
196
+ tips = PostExecutionTips()
197
+ tip = tips.get_tip(translation.command)
198
+ if tip:
199
+ console.print(
200
+ Panel(
201
+ f"[dim]{tip}[/dim]",
202
+ title=f"[bold green]{labels['tip_title']}[/bold green]",
203
+ border_style="green",
204
+ padding=(0, 1),
205
+ )
206
+ )
207
+
208
+ # 13. Related command suggestions (learning mode)
209
+ if result.exit_code == 0 and cfg.enable_learning_mode:
210
+ # Prefer LLM-generated related commands (localized) over static dictionary
211
+ if translation.related_commands:
212
+ lines: list[str] = []
213
+ for rc in translation.related_commands:
214
+ cmd = rc.get("command", "")
215
+ desc = rc.get("description", "")
216
+ lines.append(f" [cyan]{cmd}[/cyan] {desc}")
217
+ if lines:
218
+ console.print(
219
+ Panel(
220
+ "\n".join(lines),
221
+ title=f"[bold magenta]{labels['related_title']}[/bold magenta]",
222
+ border_style="magenta",
223
+ padding=(0, 1),
224
+ )
225
+ )
226
+ else:
227
+ from cloudshellgpt.learning import RelatedCommands
228
+
229
+ related = RelatedCommands()
230
+ suggestions = related.suggest(translation.command)
231
+ if suggestions:
232
+ lines = []
233
+ for suggestion in suggestions:
234
+ lines.append(f" [cyan]{suggestion.command}[/cyan] {suggestion.description}")
235
+ console.print(
236
+ Panel(
237
+ "\n".join(lines),
238
+ title=f"[bold magenta]{labels['related_title']}[/bold magenta]",
239
+ border_style="magenta",
240
+ padding=(0, 1),
241
+ )
242
+ )
243
+
244
+ if explain:
245
+ console.print(
246
+ Panel(
247
+ translation.detailed_explanation,
248
+ title="[bold]Learn: What just happened?[/bold]",
249
+ border_style="green",
250
+ )
251
+ )
252
+
253
+
254
+ @app.command()
255
+ def learn(
256
+ topic: str = typer.Argument(
257
+ ..., help="AWS service to learn: s3, ec2, lambda, dynamodb, iam, vpc"
258
+ ),
259
+ ) -> None:
260
+ """Interactive tutorial for an AWS service."""
261
+ from cloudshellgpt.learning import TutorialRunner
262
+
263
+ runner = TutorialRunner(topic)
264
+ runner.run()
265
+
266
+
267
+ @app.command()
268
+ def explain(
269
+ command: str | None = typer.Argument(None, help="AWS CLI command to explain"),
270
+ last: bool = typer.Option(False, "--last", help="Explain the last executed command"),
271
+ ) -> None:
272
+ """Explain what an AWS CLI command does in detail."""
273
+ from cloudshellgpt.config import Config
274
+ from cloudshellgpt.learning import Explainer
275
+
276
+ cfg = Config()
277
+ explainer = Explainer(region=cfg.region, model_id=cfg.bedrock_model)
278
+ if last:
279
+ explainer.explain_last()
280
+ elif command:
281
+ explainer.explain(command)
282
+ else:
283
+ console.print("[red]Provide a command or use --last[/red]")
284
+ raise typer.Exit(1)
285
+
286
+
287
+ @app.command()
288
+ def cost_summary() -> None:
289
+ """Show the cumulative estimated cost of resources created in this session."""
290
+ from cloudshellgpt.cost import CostTracker
291
+
292
+ tracker = CostTracker()
293
+ summary = tracker.session_summary()
294
+ console.print(Panel(summary, title="[bold]Session Cost Summary[/bold]", border_style="yellow"))
295
+
296
+
297
+ @app.command()
298
+ def mcp(
299
+ action: str = typer.Argument("serve", help="MCP action: serve"),
300
+ ) -> None:
301
+ """Run CloudShellGPT as an MCP server (for Kiro, Claude, Cursor)."""
302
+ if action == "serve":
303
+ console.print("[dim]Starting MCP server on stdio...[/dim]")
304
+ from cloudshellgpt.mcp_server import serve_mcp
305
+
306
+ serve_mcp()
307
+ else:
308
+ console.print(f"[red]Unknown MCP action:[/red] {action}")
309
+ raise typer.Exit(1)
310
+
311
+
312
+ @app.command()
313
+ def config(
314
+ show: bool = typer.Option(False, "--show", help="Show current configuration"),
315
+ init: bool = typer.Option(False, "--init", help="Create or reset config file with defaults"),
316
+ set_region: str | None = typer.Option(None, "--set-region", help="Set default region"),
317
+ set_language: str | None = typer.Option(
318
+ None, "--set-language", help="Set default output language"
319
+ ),
320
+ ) -> None:
321
+ """Configure CloudShellGPT."""
322
+ from cloudshellgpt.config import ConfigManager
323
+
324
+ cfg = ConfigManager()
325
+
326
+ if init:
327
+ cfg.reset_defaults()
328
+ console.print(f"[green]Config file created with defaults at {cfg.config_path}[/green]")
329
+ elif show:
330
+ console.print(Panel(cfg.to_yaml(), title="[bold]Configuration[/bold]"))
331
+ active_lang = cfg.get("language")
332
+ if active_lang == "auto":
333
+ console.print("[dim]Language: auto (detected from input)[/dim]")
334
+ else:
335
+ console.print(f"[dim]Language: {active_lang}[/dim]")
336
+ elif set_region:
337
+ cfg.set("region", set_region)
338
+ cfg.save()
339
+ console.print(f"[green]Region set to {set_region}[/green]")
340
+ elif set_language:
341
+ cfg.set("language", set_language)
342
+ cfg.save()
343
+ console.print(f"[green]Language set to {set_language}[/green]")
344
+
345
+
346
+ def _version_callback(value: bool) -> None:
347
+ """Print version and exit."""
348
+ if value:
349
+ console.print(f"csgpt version {__version__}")
350
+ raise typer.Exit()
351
+
352
+
353
+ @app.callback()
354
+ def main(
355
+ version: bool = typer.Option(
356
+ False,
357
+ "--version",
358
+ "-V",
359
+ help="Show version and exit",
360
+ callback=_version_callback,
361
+ is_eager=True,
362
+ ),
363
+ ) -> None:
364
+ """CloudShellGPT — AWS CLI that speaks your language."""
365
+
366
+
367
+ def _show_flag_explanations(command: str, labels: dict[str, str]) -> None:
368
+ """Display flag explanations for the translated command.
369
+
370
+ Shows a Rich panel with each recognized flag and its description,
371
+ helping users learn what each flag does before confirming execution.
372
+
373
+ Args:
374
+ command: The translated AWS CLI command containing flags to explain.
375
+ labels: UI labels dictionary for i18n.
376
+ """
377
+ from cloudshellgpt.learning import FlagExplainer
378
+
379
+ explainer = FlagExplainer()
380
+ explanations = explainer.explain_flags(command)
381
+
382
+ if not explanations:
383
+ return
384
+
385
+ lines: list[str] = []
386
+ for item in explanations:
387
+ lines.append(f" [cyan]{item.flag}[/cyan] {item.explanation}")
388
+
389
+ console.print(
390
+ Panel(
391
+ "\n".join(lines),
392
+ title=f"[bold blue]{labels['flags_title']}[/bold blue]",
393
+ border_style="blue",
394
+ padding=(0, 1),
395
+ )
396
+ )
397
+
398
+
399
+ def _show_bedrock_error(error: BedrockError) -> None:
400
+ """Display a BedrockError as a Rich panel with actionable info.
401
+
402
+ Args:
403
+ error: The structured BedrockError to display.
404
+ """
405
+ lines: list[str] = []
406
+ lines.append(f"[bold red]Error:[/bold red] {error.user_message}")
407
+ if error.suggestion:
408
+ lines.append(f"\n[bold yellow]Suggestion:[/bold yellow] {error.suggestion}")
409
+ if error.technical_detail:
410
+ lines.append(f"\n[dim]Detail: {error.technical_detail}[/dim]")
411
+
412
+ console.print(
413
+ Panel(
414
+ "\n".join(lines),
415
+ title=f"[bold red]Bedrock Error ({error.error_type.value})[/bold red]",
416
+ border_style="red",
417
+ )
418
+ )
419
+
420
+
421
+ def _risk_color(level: str) -> str:
422
+ """Map risk level to Rich color."""
423
+ return {
424
+ "low": "green",
425
+ "medium": "yellow",
426
+ "high": "red",
427
+ "critical": "bold red",
428
+ }.get(level, "white")
429
+
430
+
431
+ def _handle_confirmation(
432
+ check: SafetyCheck,
433
+ safety: SafetyLayer,
434
+ translation: Translation,
435
+ yes: bool,
436
+ labels: dict[str, str],
437
+ ) -> None:
438
+ """Handle confirmation flow based on risk level.
439
+
440
+ Implements the confirmation ladder:
441
+ - low: execute directly (no confirmation)
442
+ - medium: show command + explanation, ask Y/N (--yes can bypass)
443
+ - high: show command + affected resources + cost, require typed confirmation
444
+ - critical: force dry-run first, then require user to type "yes-i-understand"
445
+
446
+ The --yes flag only bypasses medium-level confirmations.
447
+
448
+ Args:
449
+ check: The SafetyCheck result from the safety layer.
450
+ safety: The SafetyLayer instance (for inject_dry_run on critical).
451
+ translation: The translation being confirmed.
452
+ yes: Whether the --yes flag was passed.
453
+ labels: UI labels dictionary for i18n.
454
+
455
+ Raises:
456
+ typer.Exit: If the user cancels or fails confirmation.
457
+ """
458
+ risk = check.risk_level
459
+
460
+ # low → execute directly
461
+ if risk == "low":
462
+ return
463
+
464
+ # medium → simple Y/N (--yes can bypass)
465
+ if risk == "medium":
466
+ if yes:
467
+ return
468
+ confirmed = typer.confirm(f"\n{check.confirmation_prompt}", default=False)
469
+ if not confirmed:
470
+ console.print(f"[yellow]{labels['cancelled']}[/yellow]")
471
+ raise typer.Exit(0)
472
+ return
473
+
474
+ # high → show affected resources + cost, require typed confirmation
475
+ if risk == "high":
476
+ _confirm_high_risk(check, translation, labels)
477
+ return
478
+
479
+ # critical → dry-run first, then "yes-i-understand"
480
+ if risk == "critical":
481
+ _confirm_critical_risk(check, safety, translation, labels)
482
+ return
483
+
484
+
485
+ def _confirm_high_risk(
486
+ check: SafetyCheck, translation: Translation, labels: dict[str, str]
487
+ ) -> None:
488
+ """Handle high-risk confirmation: require typed resource name."""
489
+ resources_display = ", ".join(check.affected_resources) if check.affected_resources else "N/A"
490
+ console.print(
491
+ Panel(
492
+ f"[bold red]{labels['confirm_high_banner']}[/bold red]\n\n"
493
+ f"[bold]{labels['command_label']}:[/bold] [cyan]{translation.command}[/cyan]\n"
494
+ f"[bold]{labels['affected_resources']}:[/bold] {resources_display}\n"
495
+ f"[bold]{labels['estimated_cost']}:[/bold] {check.estimated_cost}",
496
+ title=f"[bold red]{labels['confirm_high_title']}[/bold red]",
497
+ border_style="red",
498
+ )
499
+ )
500
+
501
+ if check.affected_resources:
502
+ expected = check.affected_resources[0]
503
+ prompt_text = f"\n{labels['type_resource'].format(resource=expected)}"
504
+ else:
505
+ expected = "confirm"
506
+ prompt_text = f"\n{labels['type_confirm']}"
507
+
508
+ user_input = typer.prompt(prompt_text)
509
+ if user_input.strip() != expected:
510
+ console.print(f"[yellow]{labels['confirmation_mismatch']}[/yellow]")
511
+ raise typer.Exit(0)
512
+
513
+
514
+ def _confirm_critical_risk(
515
+ check: SafetyCheck,
516
+ safety: SafetyLayer,
517
+ translation: Translation,
518
+ labels: dict[str, str],
519
+ ) -> None:
520
+ """Handle critical-risk confirmation: dry-run first, then "yes-i-understand".
521
+
522
+ Flow:
523
+ 1. Show a warning banner with affected resources and cost
524
+ 2. Perform automatic dry-run (inject dry-run, execute, show results)
525
+ 3. Ask the user to type exactly "yes-i-understand" to proceed
526
+ 4. If the user types anything else, cancel
527
+
528
+ Args:
529
+ check: The SafetyCheck with resource and cost info.
530
+ safety: The SafetyLayer instance for dry-run injection.
531
+ translation: The translation being confirmed.
532
+
533
+ Raises:
534
+ typer.Exit: If the user does not type "yes-i-understand".
535
+ """
536
+ from cloudshellgpt.executor import AWSExecutor
537
+
538
+ # 1. Warning banner
539
+ resources_lines = (
540
+ "\n".join(f" • {r}" for r in check.affected_resources)
541
+ if check.affected_resources
542
+ else " • (unknown resources)"
543
+ )
544
+ console.print(
545
+ Panel(
546
+ f"[bold red]{labels['confirm_critical_banner']}[/bold red]\n\n"
547
+ f"[bold]{labels['command_label']}:[/bold]\n [cyan]{translation.command}[/cyan]\n\n"
548
+ f"[bold]{labels['affected_resources']}:[/bold]\n{resources_lines}\n\n"
549
+ f"[bold]{labels['estimated_cost']}:[/bold] {check.estimated_cost}\n\n"
550
+ f"[dim]{labels['dry_run_performing']}[/dim]",
551
+ title=f"[bold red]{labels['confirm_critical_title']}[/bold red]",
552
+ border_style="bold red",
553
+ )
554
+ )
555
+
556
+ # 2. Automatic dry-run
557
+ console.print(f"\n[bold]{labels['dry_run_performing']}[/bold]")
558
+ dry_run_result = safety.inject_dry_run(translation.command)
559
+
560
+ if dry_run_result.preview_only:
561
+ # Service doesn't support native dry-run — show preview
562
+ console.print(
563
+ Panel(
564
+ f"[dim]{dry_run_result.dry_run_notes}[/dim]\n\n"
565
+ f"[cyan]{dry_run_result.command}[/cyan]",
566
+ title=f"[bold]{labels['dry_run_preview']}[/bold]",
567
+ border_style="yellow",
568
+ )
569
+ )
570
+ else:
571
+ # Execute the dry-run command
572
+ executor = AWSExecutor(dry_run=False, timeout=30)
573
+ dr_exec_result = executor.run(dry_run_result.command)
574
+
575
+ # AWS dry-run returns non-zero exit code with "DryRunOperation" error
576
+ # when the operation WOULD have succeeded. This is a success signal.
577
+ dry_run_success = dr_exec_result.exit_code == 0 or "DryRunOperation" in (
578
+ dr_exec_result.stderr or ""
579
+ )
580
+
581
+ if dry_run_success:
582
+ console.print(
583
+ Panel(
584
+ f"[green]{labels['dry_run_success']}[/green]\n\n"
585
+ f"[dim]{dry_run_result.dry_run_notes}[/dim]",
586
+ title=f"[bold]{labels['dry_run_result']}[/bold]",
587
+ border_style="green",
588
+ )
589
+ )
590
+ else:
591
+ console.print(
592
+ Panel(
593
+ f"[red]Dry-run returned errors:[/red]\n\n"
594
+ f"{dr_exec_result.stderr or dr_exec_result.stdout or '(no output)'}",
595
+ title=f"[bold red]{labels['dry_run_failed']}[/bold red]",
596
+ border_style="red",
597
+ )
598
+ )
599
+ console.print(f"[yellow]{labels['dry_run_failed']}[/yellow]")
600
+ raise typer.Exit(1)
601
+
602
+ # 3. Require typed confirmation
603
+ console.print(f"\n[bold]{labels['type_yes_i_understand']}[/bold]")
604
+ user_input = typer.prompt("Confirm")
605
+ if user_input.strip() != "yes-i-understand":
606
+ console.print(f"[yellow]{labels['confirmation_mismatch']}[/yellow]")
607
+ raise typer.Exit(0)
608
+
609
+
610
+ if __name__ == "__main__":
611
+ app()
612
+ sys.exit(0)