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 +3 -0
- aaws/cli.py +290 -0
- aaws/config.py +142 -0
- aaws/errors.py +161 -0
- aaws/executor.py +53 -0
- aaws/formatter.py +196 -0
- aaws/providers/__init__.py +62 -0
- aaws/providers/base.py +100 -0
- aaws/providers/bedrock_provider.py +91 -0
- aaws/providers/openai_provider.py +62 -0
- aaws/safety/__init__.py +18 -0
- aaws/safety/classifier.py +196 -0
- aaws/safety/tier_table.py +260 -0
- aaws/session.py +111 -0
- aaws/translator.py +116 -0
- aaws-0.1.0.dist-info/METADATA +862 -0
- aaws-0.1.0.dist-info/RECORD +20 -0
- aaws-0.1.0.dist-info/WHEEL +4 -0
- aaws-0.1.0.dist-info/entry_points.txt +2 -0
- aaws-0.1.0.dist-info/licenses/LICENSE +201 -0
aaws/__init__.py
ADDED
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
|
+
)
|