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/__init__.py +11 -0
- cloudshellgpt/audit.py +175 -0
- cloudshellgpt/bedrock_translator.py +525 -0
- cloudshellgpt/cli.py +612 -0
- cloudshellgpt/config.py +296 -0
- cloudshellgpt/cost.py +443 -0
- cloudshellgpt/executor.py +382 -0
- cloudshellgpt/formatter.py +328 -0
- cloudshellgpt/i18n.py +203 -0
- cloudshellgpt/intent.py +1080 -0
- cloudshellgpt/learning.py +969 -0
- cloudshellgpt/mcp_server.py +264 -0
- cloudshellgpt/safety.py +952 -0
- cloudshellgpt-1.0.0.dist-info/METADATA +426 -0
- cloudshellgpt-1.0.0.dist-info/RECORD +18 -0
- cloudshellgpt-1.0.0.dist-info/WHEEL +4 -0
- cloudshellgpt-1.0.0.dist-info/entry_points.txt +2 -0
- cloudshellgpt-1.0.0.dist-info/licenses/LICENSE +198 -0
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)
|