aaws 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.
aaws/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """aaws — AI-assisted AWS CLI."""
2
+
3
+ __version__ = "0.1.0"
aaws/cli.py ADDED
@@ -0,0 +1,290 @@
1
+ """aaws CLI — entry point for all commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ app = typer.Typer(
11
+ name="aaws",
12
+ help="AI-assisted AWS CLI — natural language in, aws command out.",
13
+ no_args_is_help=True,
14
+ rich_markup_mode="rich",
15
+ )
16
+
17
+ config_app = typer.Typer(help="Manage aaws configuration.")
18
+ app.add_typer(config_app, name="config")
19
+
20
+ console = Console()
21
+
22
+
23
+ # ── Helpers ───────────────────────────────────────────────────────────────────
24
+
25
+ def _resolve_aws_context(
26
+ profile: str | None,
27
+ region: str | None,
28
+ config: object,
29
+ ) -> tuple[str, str]:
30
+ """Return the effective AWS profile and region."""
31
+ import boto3 # noqa: PLC0415
32
+
33
+ aws = getattr(config, "aws", None)
34
+ effective_profile = profile or getattr(aws, "default_profile", None) or "default"
35
+
36
+ if region:
37
+ effective_region = region
38
+ elif getattr(aws, "default_region", None):
39
+ effective_region = aws.default_region # type: ignore[union-attr]
40
+ else:
41
+ try:
42
+ session = boto3.Session(profile_name=effective_profile)
43
+ effective_region = session.region_name or "us-east-1"
44
+ except Exception:
45
+ effective_region = "us-east-1"
46
+
47
+ return effective_profile, effective_region
48
+
49
+
50
+ def _load_or_exit() -> object:
51
+ """Load config or print actionable error and exit."""
52
+ from .config import load_config # noqa: PLC0415
53
+ from .errors import ConfigNotFoundError # noqa: PLC0415
54
+
55
+ try:
56
+ return load_config()
57
+ except ConfigNotFoundError:
58
+ console.print(
59
+ "[bold red]No configuration found.[/bold red] "
60
+ "Run [cyan]aaws config init[/cyan] to set up."
61
+ )
62
+ raise typer.Exit(1)
63
+
64
+
65
+ # ── Root command ──────────────────────────────────────────────────────────────
66
+
67
+ @app.callback(invoke_without_command=True)
68
+ def main(
69
+ ctx: typer.Context,
70
+ request: Annotated[
71
+ Optional[str], typer.Argument(help="Natural language AWS request")
72
+ ] = None,
73
+ profile: Annotated[
74
+ Optional[str], typer.Option("--profile", "-p", help="AWS profile to use")
75
+ ] = None,
76
+ region: Annotated[
77
+ Optional[str], typer.Option("--region", "-r", help="AWS region to use")
78
+ ] = None,
79
+ raw: Annotated[
80
+ bool, typer.Option("--raw", help="Print raw JSON without formatting")
81
+ ] = False,
82
+ dry_run: Annotated[
83
+ bool, typer.Option("--dry-run", help="Show generated command without executing it")
84
+ ] = False,
85
+ accept_responsibility: Annotated[
86
+ bool,
87
+ typer.Option(
88
+ "--i-accept-responsibility",
89
+ help="Override tier-3 (catastrophic) operation refusal",
90
+ ),
91
+ ] = False,
92
+ ) -> None:
93
+ """Translate a natural language request into an AWS CLI command and run it."""
94
+ if ctx.invoked_subcommand is not None:
95
+ return
96
+
97
+ if request is None:
98
+ console.print(
99
+ "Usage: aaws [OPTIONS] REQUEST\n\nRun [cyan]aaws --help[/cyan] for more information."
100
+ )
101
+ raise typer.Exit()
102
+
103
+ from .errors import AawsError, ProtectedProfileError, TranslationError, handle_error # noqa: PLC0415
104
+ from .executor import check_aws_cli, execute # noqa: PLC0415
105
+ from .formatter import format_output # noqa: PLC0415
106
+ from .providers import get_provider # noqa: PLC0415
107
+ from .safety.classifier import apply_safety_gate, classify, was_dry_run_requested # noqa: PLC0415
108
+ from .translator import translate # noqa: PLC0415
109
+
110
+ check_aws_cli()
111
+ config = _load_or_exit()
112
+ effective_profile, effective_region = _resolve_aws_context(profile, region, config)
113
+ effective_raw = raw or getattr(getattr(config, "output", None), "raw", False)
114
+
115
+ provider = get_provider(config)
116
+
117
+ # Translate
118
+ try:
119
+ response = translate(request, effective_profile, effective_region, [], provider)
120
+ except TranslationError as e:
121
+ console.print(f"[bold red]Translation failed:[/bold red] {e}")
122
+ raise typer.Exit(1)
123
+ except AawsError as e:
124
+ console.print(f"[bold red]Error:[/bold red] {e}")
125
+ raise typer.Exit(1)
126
+
127
+ # Handle clarification
128
+ if response.clarification:
129
+ console.print(f"[bold yellow]?[/bold yellow] {response.clarification}")
130
+ raise typer.Exit()
131
+
132
+ tier = classify(response.command, response.risk_tier)
133
+
134
+ # Dry-run flag: show command; don't execute
135
+ if dry_run:
136
+ console.print(f"\n[bold]Generated command:[/bold] [cyan]{response.command}[/cyan]")
137
+ console.print(f"[dim]{response.explanation}[/dim]")
138
+ console.print(f"[dim]Risk tier: {tier}[/dim]")
139
+ raise typer.Exit()
140
+
141
+ # Safety gate
142
+ try:
143
+ should_run = apply_safety_gate(
144
+ response.command,
145
+ tier,
146
+ response.explanation,
147
+ effective_profile,
148
+ config,
149
+ accept_responsibility=accept_responsibility,
150
+ )
151
+ except ProtectedProfileError as e:
152
+ console.print(f"[bold red]Blocked:[/bold red] {e}")
153
+ raise typer.Exit(1)
154
+
155
+ if was_dry_run_requested():
156
+ # User chose --dry-run from the confirmation prompt — re-run with --dry-run appended
157
+ dry_command = response.command + " --dry-run"
158
+ console.print(f"\n[bold]Running:[/bold] [cyan]{dry_command}[/cyan]")
159
+ result = execute(dry_command)
160
+ if result.success:
161
+ console.print("[green]Dry run succeeded — no changes were made.[/green]")
162
+ console.print(result.stdout or "")
163
+ else:
164
+ handle_error(dry_command, result, effective_profile, provider)
165
+ raise typer.Exit()
166
+
167
+ if not should_run:
168
+ console.print("[dim]Cancelled.[/dim]")
169
+ raise typer.Exit()
170
+
171
+ result = execute(response.command)
172
+ if result.success:
173
+ format_output(result.stdout, raw=effective_raw)
174
+ else:
175
+ handle_error(response.command, result, effective_profile, provider)
176
+ raise typer.Exit(result.exit_code)
177
+
178
+
179
+ # ── explain command ───────────────────────────────────────────────────────────
180
+
181
+ @app.command("explain")
182
+ def explain_command(
183
+ command: Annotated[str, typer.Argument(help="AWS CLI command to explain")],
184
+ ) -> None:
185
+ """Explain what an existing AWS CLI command does."""
186
+ from .errors import AawsError # noqa: PLC0415
187
+ from .providers import get_provider # noqa: PLC0415
188
+ from .providers.base import Message, TOOL_SCHEMA # noqa: PLC0415
189
+
190
+ config = _load_or_exit()
191
+ provider = get_provider(config)
192
+
193
+ messages = [
194
+ Message(
195
+ role="user",
196
+ content=(
197
+ f"Explain the following AWS CLI command in plain English. "
198
+ f"Describe what it does, what each flag means, and any important "
199
+ f"safety considerations or caveats:\n\n {command}"
200
+ ),
201
+ )
202
+ ]
203
+
204
+ try:
205
+ response = provider.complete(messages, TOOL_SCHEMA)
206
+ console.print(f"\n[bold]Command:[/bold] [cyan]{command}[/cyan]\n")
207
+ console.print(response.explanation or "No explanation available.")
208
+ except AawsError as e:
209
+ console.print(f"[bold red]Error:[/bold red] {e}")
210
+ raise typer.Exit(1)
211
+
212
+
213
+ # ── session command ───────────────────────────────────────────────────────────
214
+
215
+ @app.command("session")
216
+ def session_command(
217
+ profile: Annotated[
218
+ Optional[str], typer.Option("--profile", "-p", help="AWS profile to use")
219
+ ] = None,
220
+ region: Annotated[
221
+ Optional[str], typer.Option("--region", "-r", help="AWS region to use")
222
+ ] = None,
223
+ ) -> None:
224
+ """Start an interactive session with conversation context."""
225
+ from .executor import check_aws_cli # noqa: PLC0415
226
+ from .session import run_session # noqa: PLC0415
227
+
228
+ check_aws_cli()
229
+ config = _load_or_exit()
230
+ effective_profile, effective_region = _resolve_aws_context(profile, region, config)
231
+ run_session(config, effective_profile, effective_region)
232
+
233
+
234
+ # ── config commands ───────────────────────────────────────────────────────────
235
+
236
+ @config_app.command("init")
237
+ def config_init() -> None:
238
+ """Run the first-time configuration wizard."""
239
+ from rich.prompt import Confirm, Prompt # noqa: PLC0415
240
+
241
+ from .config import AawsConfig, LLMConfig, write_config # noqa: PLC0415
242
+
243
+ console.print("[bold cyan]aaws configuration wizard[/bold cyan]\n")
244
+
245
+ provider_choice = Prompt.ask(
246
+ "LLM provider",
247
+ choices=["bedrock", "openai"],
248
+ default="bedrock",
249
+ )
250
+
251
+ if provider_choice == "bedrock":
252
+ model = Prompt.ask(
253
+ "Bedrock model ID",
254
+ default="anthropic.claude-3-5-haiku-20241022-v1:0",
255
+ )
256
+ api_key = None
257
+ else:
258
+ model = Prompt.ask("OpenAI model", default="gpt-4o-mini")
259
+ api_key = Prompt.ask(
260
+ "OpenAI API key (or set OPENAI_API_KEY env var)",
261
+ password=True,
262
+ )
263
+
264
+ default_profile = Prompt.ask("Default AWS profile", default="default")
265
+ default_region = Prompt.ask("Default AWS region", default="us-east-1")
266
+
267
+ from .config import AWSConfig # noqa: PLC0415
268
+
269
+ config = AawsConfig(
270
+ llm=LLMConfig(provider=provider_choice, model=model, api_key=api_key or None),
271
+ aws=AWSConfig(default_profile=default_profile, default_region=default_region),
272
+ )
273
+ path = write_config(config)
274
+ console.print(f"\n[green]✓[/green] Configuration saved to [cyan]{path}[/cyan]")
275
+ console.print('Run [cyan]aaws "list my S3 buckets"[/cyan] to test.')
276
+
277
+
278
+ @config_app.command("show")
279
+ def config_show() -> None:
280
+ """Show the resolved effective configuration (secrets masked)."""
281
+ import json # noqa: PLC0415
282
+
283
+ config = _load_or_exit()
284
+ data = config.model_dump() # type: ignore[union-attr]
285
+
286
+ # Mask sensitive fields
287
+ if data.get("llm", {}).get("api_key"):
288
+ data["llm"]["api_key"] = "***"
289
+
290
+ console.print_json(json.dumps(data, indent=2))
aaws/config.py ADDED
@@ -0,0 +1,142 @@
1
+ """Configuration loading, validation, and persistence for aaws."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import yaml
11
+ from platformdirs import user_config_dir
12
+ from pydantic import BaseModel
13
+
14
+ from .errors import ConfigNotFoundError
15
+
16
+
17
+ # ── Pydantic models ────────────────────────────────────────────────────────────
18
+
19
+ class LLMConfig(BaseModel):
20
+ provider: str = "bedrock"
21
+ model: str = "anthropic.claude-3-5-haiku-20241022-v1:0"
22
+ api_key: Optional[str] = None
23
+ temperature: float = 0.1
24
+ timeout: int = 30
25
+
26
+
27
+ class AWSConfig(BaseModel):
28
+ default_profile: Optional[str] = None
29
+ default_region: Optional[str] = None
30
+
31
+
32
+ class SafetyConfig(BaseModel):
33
+ auto_execute_tier: int = 0
34
+ protected_profiles: list[str] = []
35
+
36
+
37
+ class OutputConfig(BaseModel):
38
+ format: str = "auto"
39
+ raw: bool = False
40
+ color: bool = True
41
+
42
+
43
+ class AawsConfig(BaseModel):
44
+ llm: LLMConfig = LLMConfig()
45
+ aws: AWSConfig = AWSConfig()
46
+ safety: SafetyConfig = SafetyConfig()
47
+ output: OutputConfig = OutputConfig()
48
+
49
+
50
+ # ── Env var helpers ───────────────────────────────────────────────────────────
51
+
52
+ _ENV_VAR_RE = re.compile(r"\$\{([^}]+)\}")
53
+
54
+
55
+ def _resolve_env_vars(value: str) -> str:
56
+ """Resolve ${ENV_VAR} references in a string. Leaves unset vars as-is."""
57
+
58
+ def replacer(match: re.Match[str]) -> str:
59
+ var_name = match.group(1)
60
+ return os.environ.get(var_name, match.group(0))
61
+
62
+ return _ENV_VAR_RE.sub(replacer, value)
63
+
64
+
65
+ def _resolve_recursive(obj: object) -> object:
66
+ """Recursively resolve ${ENV_VAR} in all string values of a nested structure."""
67
+ if isinstance(obj, str):
68
+ return _resolve_env_vars(obj)
69
+ if isinstance(obj, dict):
70
+ return {k: _resolve_recursive(v) for k, v in obj.items()}
71
+ if isinstance(obj, list):
72
+ return [_resolve_recursive(i) for i in obj]
73
+ return obj
74
+
75
+
76
+ # Map from AAWS_ env var → (section, field) in the config dict
77
+ _ENV_OVERRIDES: dict[str, tuple[str, str]] = {
78
+ "AAWS_LLM_PROVIDER": ("llm", "provider"),
79
+ "AAWS_LLM_MODEL": ("llm", "model"),
80
+ "AAWS_LLM_API_KEY": ("llm", "api_key"),
81
+ "AAWS_LLM_TEMPERATURE": ("llm", "temperature"),
82
+ "AAWS_LLM_TIMEOUT": ("llm", "timeout"),
83
+ "AAWS_AWS_PROFILE": ("aws", "default_profile"),
84
+ "AAWS_AWS_REGION": ("aws", "default_region"),
85
+ "AAWS_SAFETY_AUTO_EXECUTE_TIER": ("safety", "auto_execute_tier"),
86
+ "AAWS_OUTPUT_FORMAT": ("output", "format"),
87
+ "AAWS_OUTPUT_RAW": ("output", "raw"),
88
+ "AAWS_OUTPUT_COLOR": ("output", "color"),
89
+ }
90
+
91
+
92
+ def _apply_env_overrides(config_dict: dict) -> dict: # type: ignore[type-arg]
93
+ """Apply AAWS_-prefixed environment variables, overriding file-based config."""
94
+ for env_key, (section, field) in _ENV_OVERRIDES.items():
95
+ val = os.environ.get(env_key)
96
+ if val is not None:
97
+ if section not in config_dict:
98
+ config_dict[section] = {}
99
+ config_dict[section][field] = val
100
+ return config_dict
101
+
102
+
103
+ # ── Path helpers ──────────────────────────────────────────────────────────────
104
+
105
+ def config_path() -> Path:
106
+ """Return the OS-appropriate config file path."""
107
+ return Path(user_config_dir("aaws")) / "config.yaml"
108
+
109
+
110
+ # ── Public API ────────────────────────────────────────────────────────────────
111
+
112
+ def load_config(path: Path | None = None) -> AawsConfig:
113
+ """
114
+ Load config from YAML file, resolve ${ENV_VAR} references, apply
115
+ AAWS_-prefixed env var overrides, and return a validated AawsConfig.
116
+
117
+ Raises ConfigNotFoundError if the file does not exist.
118
+ """
119
+ resolved_path = path or config_path()
120
+
121
+ if not resolved_path.exists():
122
+ raise ConfigNotFoundError(str(resolved_path))
123
+
124
+ with resolved_path.open() as f:
125
+ raw: dict = yaml.safe_load(f) or {} # type: ignore[assignment]
126
+
127
+ raw = _resolve_recursive(raw) # type: ignore[assignment]
128
+ raw = _apply_env_overrides(raw) # type: ignore[arg-type]
129
+
130
+ return AawsConfig.model_validate(raw)
131
+
132
+
133
+ def write_config(config: AawsConfig, path: Path | None = None) -> Path:
134
+ """Serialize config to YAML, creating parent directories as needed."""
135
+ resolved_path = path or config_path()
136
+ resolved_path.parent.mkdir(parents=True, exist_ok=True)
137
+
138
+ data = config.model_dump(exclude_none=True)
139
+ with resolved_path.open("w") as f:
140
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
141
+
142
+ return resolved_path
aaws/errors.py ADDED
@@ -0,0 +1,161 @@
1
+ """Custom exceptions and error types for aaws."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from enum import Enum, auto
7
+
8
+
9
+ class ErrorType(Enum):
10
+ EXPIRED_TOKEN = auto()
11
+ NO_CREDENTIALS = auto()
12
+ ACCESS_DENIED = auto()
13
+ BUCKET_NOT_EMPTY = auto()
14
+ NO_SUCH_BUCKET = auto()
15
+ RESOURCE_NOT_FOUND = auto()
16
+ RESOURCE_CONFLICT = auto()
17
+ TIMEOUT = auto()
18
+ UNKNOWN = auto()
19
+
20
+
21
+ class AawsError(Exception):
22
+ """Base exception for aaws errors."""
23
+
24
+
25
+ class TranslationError(AawsError):
26
+ """Raised when the LLM fails to produce a valid AWS CLI command after retries."""
27
+
28
+
29
+ class ProtectedProfileError(AawsError):
30
+ """Raised when a write operation is attempted against a protected AWS profile."""
31
+
32
+ def __init__(self, profile: str) -> None:
33
+ super().__init__(
34
+ f"Profile '{profile}' is protected (read-only). Switch profiles to make changes."
35
+ )
36
+ self.profile = profile
37
+
38
+
39
+ class ConfigNotFoundError(AawsError):
40
+ """Raised when no aaws config file exists."""
41
+
42
+
43
+ # ── Credential / permission pattern matching ──────────────────────────────────
44
+
45
+ _CREDENTIAL_PATTERNS: list[tuple[re.Pattern[str], str]] = [
46
+ (
47
+ re.compile(r"ExpiredTokenException|ExpiredToken|Token.*expired", re.I),
48
+ "Your AWS session has expired. Refresh it with: aws sso login --profile {profile}",
49
+ ),
50
+ (
51
+ re.compile(r"Unable to locate credentials|NoCredentialsError|no credentials", re.I),
52
+ "No AWS credentials found. Configure them with: aws configure",
53
+ ),
54
+ ]
55
+
56
+ _ACCESS_DENIED_RE = re.compile(
57
+ r"is not authorized to perform:\s*(\S+)", re.I
58
+ )
59
+
60
+ _RESOURCE_PATTERNS: list[tuple[re.Pattern[str], ErrorType]] = [
61
+ (re.compile(r"BucketNotEmpty", re.I), ErrorType.BUCKET_NOT_EMPTY),
62
+ (re.compile(r"NoSuchBucket", re.I), ErrorType.NO_SUCH_BUCKET),
63
+ (re.compile(r"AccessDeniedException|is not authorized", re.I), ErrorType.ACCESS_DENIED),
64
+ (
65
+ re.compile(r"NoSuchKey|NoSuchEntity|does not exist|not found", re.I),
66
+ ErrorType.RESOURCE_NOT_FOUND,
67
+ ),
68
+ (
69
+ re.compile(r"AlreadyExists|BucketAlreadyOwned|ResourceConflict", re.I),
70
+ ErrorType.RESOURCE_CONFLICT,
71
+ ),
72
+ (
73
+ re.compile(r"ExpiredTokenException|ExpiredToken|Token.*expired", re.I),
74
+ ErrorType.EXPIRED_TOKEN,
75
+ ),
76
+ (
77
+ re.compile(r"Unable to locate credentials|NoCredentialsError", re.I),
78
+ ErrorType.NO_CREDENTIALS,
79
+ ),
80
+ ]
81
+
82
+
83
+ def classify_error(stderr: str) -> ErrorType:
84
+ """Classify an AWS CLI error from stderr content."""
85
+ for pattern, error_type in _RESOURCE_PATTERNS:
86
+ if pattern.search(stderr):
87
+ return error_type
88
+ return ErrorType.UNKNOWN
89
+
90
+
91
+ def get_credential_message(stderr: str, profile: str) -> str | None:
92
+ """Return a hardcoded actionable message for credential/auth errors, or None."""
93
+ for pattern, message in _CREDENTIAL_PATTERNS:
94
+ if pattern.search(stderr):
95
+ return message.format(profile=profile)
96
+
97
+ match = _ACCESS_DENIED_RE.search(stderr)
98
+ if match:
99
+ action = match.group(1) if match.lastindex and match.group(1) else "the required action"
100
+ return (
101
+ f"Access denied: {action}\n"
102
+ "Check your IAM permissions or switch to a profile with the required access."
103
+ )
104
+
105
+ return None
106
+
107
+
108
+ def interpret_error(command: str, stderr: str, provider: object) -> str:
109
+ """Use the LLM to interpret a resource error and suggest next steps."""
110
+ from .providers.base import Message, TOOL_SCHEMA # noqa: PLC0415
111
+
112
+ messages = [
113
+ Message(
114
+ role="user",
115
+ content=(
116
+ f"The following AWS CLI command failed:\n"
117
+ f" {command}\n\n"
118
+ f"Error output:\n {stderr.strip()}\n\n"
119
+ "Explain what went wrong in plain English and suggest the exact command(s) "
120
+ "needed to fix or work around this issue."
121
+ ),
122
+ )
123
+ ]
124
+
125
+ try:
126
+ response = provider.complete(messages, TOOL_SCHEMA) # type: ignore[union-attr]
127
+ return response.explanation or "Unable to interpret error."
128
+ except Exception:
129
+ return stderr.strip()
130
+
131
+
132
+ def handle_error(command: str, result: object, profile: str, provider: object) -> None:
133
+ """Route AWS CLI errors to appropriate handler and render for the user."""
134
+ from rich.console import Console # noqa: PLC0415
135
+
136
+ from .formatter import render_error # noqa: PLC0415
137
+
138
+ console = Console()
139
+ stderr: str = getattr(result, "stderr", "")
140
+
141
+ # Credential/auth errors → hardcoded actionable message (no LLM call)
142
+ cred_msg = get_credential_message(stderr, profile)
143
+ if cred_msg:
144
+ render_error(stderr, suggestion=cred_msg)
145
+ return
146
+
147
+ error_type = classify_error(stderr)
148
+
149
+ if error_type in (
150
+ ErrorType.BUCKET_NOT_EMPTY,
151
+ ErrorType.NO_SUCH_BUCKET,
152
+ ErrorType.RESOURCE_NOT_FOUND,
153
+ ErrorType.RESOURCE_CONFLICT,
154
+ ):
155
+ try:
156
+ interpretation = interpret_error(command, stderr, provider)
157
+ render_error(stderr, suggestion=interpretation)
158
+ except Exception:
159
+ render_error(stderr)
160
+ else:
161
+ render_error(stderr)
aaws/executor.py ADDED
@@ -0,0 +1,53 @@
1
+ """Subprocess execution of AWS CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass
13
+ class ExecutionResult:
14
+ stdout: str
15
+ stderr: str
16
+ exit_code: int
17
+
18
+ @property
19
+ def success(self) -> bool:
20
+ return self.exit_code == 0
21
+
22
+
23
+ def check_aws_cli() -> None:
24
+ """
25
+ Verify the aws CLI is present in PATH.
26
+ Exits with code 1 and an actionable message if not found.
27
+ """
28
+ if shutil.which("aws") is None:
29
+ from rich.console import Console # noqa: PLC0415
30
+
31
+ Console().print(
32
+ "[bold red]Error:[/bold red] AWS CLI not found in PATH.\n"
33
+ "Install it from: "
34
+ "https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html"
35
+ )
36
+ sys.exit(1)
37
+
38
+
39
+ def execute(command: str) -> ExecutionResult:
40
+ """
41
+ Execute an AWS CLI command via subprocess.
42
+
43
+ Security: never uses shell=True. The command string is tokenised with
44
+ shlex.split and passed as a list to subprocess.run, preventing shell
45
+ injection regardless of what the LLM produced.
46
+ """
47
+ tokens = shlex.split(command)
48
+ result = subprocess.run(tokens, capture_output=True, text=True)
49
+ return ExecutionResult(
50
+ stdout=result.stdout,
51
+ stderr=result.stderr,
52
+ exit_code=result.returncode,
53
+ )