envfix 0.4.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.
- envfix/__init__.py +3 -0
- envfix/ai.py +261 -0
- envfix/cache.py +134 -0
- envfix/config.py +36 -0
- envfix/context.py +122 -0
- envfix/logger.py +147 -0
- envfix/main.py +485 -0
- envfix/preview.py +77 -0
- envfix/runner.py +26 -0
- envfix-0.4.0.dist-info/METADATA +450 -0
- envfix-0.4.0.dist-info/RECORD +15 -0
- envfix-0.4.0.dist-info/WHEEL +5 -0
- envfix-0.4.0.dist-info/entry_points.txt +2 -0
- envfix-0.4.0.dist-info/licenses/LICENSE +91 -0
- envfix-0.4.0.dist-info/top_level.txt +1 -0
envfix/logger.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""logger.py — Structured JSON logging + history reader for envfix."""
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_log_file() -> str:
|
|
12
|
+
"""
|
|
13
|
+
Return the path to this user's envfix log file.
|
|
14
|
+
|
|
15
|
+
The filename is derived from the OS username so that multiple
|
|
16
|
+
users on a shared machine each get their own separate history.
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
alice -> envfix_log_alice.json
|
|
20
|
+
bob -> envfix_log_bob.json
|
|
21
|
+
"""
|
|
22
|
+
# Sanitise the username so it's always safe to use in a filename
|
|
23
|
+
raw = getpass.getuser()
|
|
24
|
+
safe = re.sub(r"[^A-Za-z0-9_-]", "_", raw)
|
|
25
|
+
return f"envfix_log_{safe}.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Legacy constant kept for backward compat (tests, cache.py default arg)
|
|
29
|
+
LOG_FILE = get_log_file()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def log_attempt(
|
|
33
|
+
original_command: str,
|
|
34
|
+
error_text: str,
|
|
35
|
+
diagnosis: str,
|
|
36
|
+
fix_command: str,
|
|
37
|
+
user_approved: bool,
|
|
38
|
+
fix_worked: Optional[bool],
|
|
39
|
+
source: str = "ollama",
|
|
40
|
+
category: str = "general",
|
|
41
|
+
context_included: bool = False,
|
|
42
|
+
provider: str = "ollama",
|
|
43
|
+
) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Append one attempt record to the user's personal log file.
|
|
46
|
+
|
|
47
|
+
Phase 4 schema
|
|
48
|
+
──────────────
|
|
49
|
+
{
|
|
50
|
+
"timestamp": ISO-8601 UTC string,
|
|
51
|
+
"original_command": the shell command that failed,
|
|
52
|
+
"error_text": captured stderr (or stdout) from that command,
|
|
53
|
+
"diagnosis": AI-generated or cache-sourced diagnosis,
|
|
54
|
+
"fix_command": the suggested fix command,
|
|
55
|
+
"user_approved": whether the user said y to apply it,
|
|
56
|
+
"fix_worked": True/False after retry, None if not approved,
|
|
57
|
+
"source": "ollama" | "cache",
|
|
58
|
+
"category": ecosystem category (e.g. "python", "node", "general"),
|
|
59
|
+
"context_included": whether a code snippet was injected into the prompt,
|
|
60
|
+
"provider": the AI provider used (e.g., "ollama", "groq", "gemini")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
original_command: The failing shell command.
|
|
65
|
+
error_text: Captured stderr/stdout of the failure.
|
|
66
|
+
diagnosis: Diagnosis text (from model or cache).
|
|
67
|
+
fix_command: Suggested fix command.
|
|
68
|
+
user_approved: True if the user approved running the fix.
|
|
69
|
+
fix_worked: True/False after re-run, None if not applied.
|
|
70
|
+
source: Where the fix came from: "ollama" or "cache".
|
|
71
|
+
category: The ecosystem category.
|
|
72
|
+
context_included: Whether a code snippet was included in the prompt.
|
|
73
|
+
provider: The AI provider used.
|
|
74
|
+
"""
|
|
75
|
+
record: dict[str, Any] = {
|
|
76
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
77
|
+
"original_command": original_command,
|
|
78
|
+
"error_text": error_text,
|
|
79
|
+
"diagnosis": diagnosis,
|
|
80
|
+
"fix_command": fix_command,
|
|
81
|
+
"user_approved": user_approved,
|
|
82
|
+
"fix_worked": fix_worked,
|
|
83
|
+
"source": source,
|
|
84
|
+
"category": category,
|
|
85
|
+
"context_included": context_included,
|
|
86
|
+
"provider": provider,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
log_data = _load_log(get_log_file())
|
|
90
|
+
log_data.append(record)
|
|
91
|
+
_save_log(log_data, get_log_file())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_history(log_file: str = LOG_FILE) -> list[dict[str, Any]]:
|
|
95
|
+
"""
|
|
96
|
+
Load and return all log entries, newest-first.
|
|
97
|
+
|
|
98
|
+
Transparently reads both Phase 1 (legacy) and Phase 2 schemas.
|
|
99
|
+
Each returned dict is guaranteed to have these keys:
|
|
100
|
+
timestamp, original_command, error_text, diagnosis,
|
|
101
|
+
fix_command, user_approved, fix_worked, source, category
|
|
102
|
+
"""
|
|
103
|
+
raw = _load_log(log_file)
|
|
104
|
+
normalised = []
|
|
105
|
+
for entry in raw:
|
|
106
|
+
normalised.append(_normalise_entry(entry))
|
|
107
|
+
return list(reversed(normalised)) # newest first
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── Private helpers ───────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
def _load_log(log_file: str = LOG_FILE) -> list[dict[str, Any]]:
|
|
113
|
+
"""Load JSON log; return empty list on any error."""
|
|
114
|
+
if not os.path.exists(log_file):
|
|
115
|
+
return []
|
|
116
|
+
try:
|
|
117
|
+
with open(log_file, "r", encoding="utf-8") as f:
|
|
118
|
+
data = json.load(f)
|
|
119
|
+
return data if isinstance(data, list) else []
|
|
120
|
+
except (json.JSONDecodeError, OSError):
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _save_log(log_data: list[dict[str, Any]], log_file: str = LOG_FILE) -> None:
|
|
125
|
+
"""Write the log list to disk as pretty-printed JSON."""
|
|
126
|
+
with open(log_file, "w", encoding="utf-8") as f:
|
|
127
|
+
json.dump(log_data, f, indent=2, ensure_ascii=False)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _normalise_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
|
131
|
+
"""
|
|
132
|
+
Convert a Phase 1 log entry into the Phase 2 schema.
|
|
133
|
+
Phase 2 entries pass through unchanged.
|
|
134
|
+
"""
|
|
135
|
+
return {
|
|
136
|
+
"timestamp": entry.get("timestamp", ""),
|
|
137
|
+
"original_command": entry.get("original_command") or entry.get("command", ""),
|
|
138
|
+
"error_text": entry.get("error_text") or entry.get("stderr", ""),
|
|
139
|
+
"diagnosis": entry.get("diagnosis", ""),
|
|
140
|
+
"fix_command": entry.get("fix_command") or entry.get("fix", ""),
|
|
141
|
+
"user_approved": entry.get("user_approved") if "user_approved" in entry else entry.get("approved", False),
|
|
142
|
+
"fix_worked": entry.get("fix_worked") if "fix_worked" in entry else entry.get("worked"),
|
|
143
|
+
"source": entry.get("source", "ollama"),
|
|
144
|
+
"category": entry.get("category", "general"),
|
|
145
|
+
"context_included": bool(entry.get("context_included", False)),
|
|
146
|
+
"provider": entry.get("provider", "ollama"),
|
|
147
|
+
}
|
envfix/main.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""main.py — Typer CLI entry point for envfix (Phase 2)."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.prompt import Confirm, Prompt
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from envfix.ai import _clean_fix, get_actual_model, get_diagnosis
|
|
15
|
+
from envfix.cache import find_cached_fix
|
|
16
|
+
from envfix.config import load_config, save_config, reset_config
|
|
17
|
+
from envfix.context import extract_context
|
|
18
|
+
from envfix.logger import LOG_FILE, get_history, get_log_file, log_attempt
|
|
19
|
+
from envfix.preview import get_fix_preview, is_destructive
|
|
20
|
+
from envfix.runner import run_command
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="envfix",
|
|
24
|
+
help="Diagnose and auto-fix Python/ML environment errors using a local LLM.",
|
|
25
|
+
add_completion=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
console = Console()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
def _quote_join(tokens: List[str]) -> str:
|
|
34
|
+
"""
|
|
35
|
+
Join command tokens back into a shell string, re-quoting any token
|
|
36
|
+
that contains spaces with double quotes.
|
|
37
|
+
|
|
38
|
+
PowerShell strips quotes when it passes arguments, so:
|
|
39
|
+
envfix run python -c "import torch"
|
|
40
|
+
arrives as tokens: ["python", "-c", "import torch"]
|
|
41
|
+
|
|
42
|
+
Without re-quoting we'd produce: python -c import torch (SyntaxError).
|
|
43
|
+
With re-quoting we produce: python -c "import torch" (correct).
|
|
44
|
+
"""
|
|
45
|
+
if len(tokens) == 1:
|
|
46
|
+
return tokens[0]
|
|
47
|
+
|
|
48
|
+
parts = []
|
|
49
|
+
for tok in tokens:
|
|
50
|
+
if " " in tok:
|
|
51
|
+
tok = '"' + tok.replace('"', '\\"') + '"'
|
|
52
|
+
parts.append(tok)
|
|
53
|
+
return " ".join(parts)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _show_fix_panel(
|
|
57
|
+
diagnosis: str,
|
|
58
|
+
fix: str,
|
|
59
|
+
source: str,
|
|
60
|
+
cache_score: Optional[float] = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Render the DIAGNOSIS + FIX suggestion panel."""
|
|
63
|
+
source_tag = (
|
|
64
|
+
f"[dim] (from cache — {cache_score:.0%} match)[/dim]"
|
|
65
|
+
if source == "cache" and cache_score is not None
|
|
66
|
+
else ""
|
|
67
|
+
)
|
|
68
|
+
console.print(
|
|
69
|
+
Panel(
|
|
70
|
+
Text.assemble(
|
|
71
|
+
("DIAGNOSIS\n", "bold magenta"),
|
|
72
|
+
(diagnosis + "\n\n", "white"),
|
|
73
|
+
("FIX\n", "bold cyan"),
|
|
74
|
+
(fix, "bold white"),
|
|
75
|
+
),
|
|
76
|
+
title=f"[bold]envfix Suggestion[/bold]{source_tag}",
|
|
77
|
+
border_style="cyan",
|
|
78
|
+
expand=False,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── Commands ──────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
@app.command("config")
|
|
86
|
+
def config_cmd(
|
|
87
|
+
show: bool = typer.Option(False, "--show", help="Show current configuration."),
|
|
88
|
+
reset: bool = typer.Option(False, "--reset", help="Reset configuration to defaults."),
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Manage envfix global configuration interactively."""
|
|
91
|
+
if reset:
|
|
92
|
+
reset_config()
|
|
93
|
+
console.print("[green]Configuration reset to defaults.[/green]")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
if show:
|
|
97
|
+
config = load_config()
|
|
98
|
+
if not config:
|
|
99
|
+
console.print("[yellow]No configuration found. Using hardcoded defaults.[/yellow]")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
table = Table(title="Global Configuration")
|
|
103
|
+
table.add_column("Key", style="cyan")
|
|
104
|
+
table.add_column("Value", style="magenta")
|
|
105
|
+
|
|
106
|
+
for k, v in config.items():
|
|
107
|
+
table.add_row(k, str(v))
|
|
108
|
+
|
|
109
|
+
console.print(table)
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
console.print("[bold cyan]envfix Configuration Setup[/bold cyan]\n")
|
|
113
|
+
|
|
114
|
+
while True:
|
|
115
|
+
provider = typer.prompt("Default provider [ollama/groq/gemini]", default="ollama")
|
|
116
|
+
if provider in ["ollama", "groq", "gemini"]:
|
|
117
|
+
break
|
|
118
|
+
console.print("[red]Invalid choice. Must be ollama, groq, or gemini.[/red]")
|
|
119
|
+
|
|
120
|
+
while True:
|
|
121
|
+
category = typer.prompt("Default category [general/python/node/docker]", default="general")
|
|
122
|
+
if category in ["general", "python", "node", "docker"]:
|
|
123
|
+
break
|
|
124
|
+
console.print("[red]Invalid choice. Must be general, python, node, or docker.[/red]")
|
|
125
|
+
|
|
126
|
+
save_config({
|
|
127
|
+
"default_provider": provider,
|
|
128
|
+
"default_category": category,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
console.print("\n[bold green]Configuration saved successfully![/bold green]")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.command(
|
|
135
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
136
|
+
)
|
|
137
|
+
def run(
|
|
138
|
+
ctx: typer.Context,
|
|
139
|
+
model: str = typer.Option(
|
|
140
|
+
"llama3.1:8b",
|
|
141
|
+
"--model",
|
|
142
|
+
help="Ollama model tag to use for diagnosis.",
|
|
143
|
+
show_default=True,
|
|
144
|
+
),
|
|
145
|
+
category: Optional[str] = typer.Option(
|
|
146
|
+
None,
|
|
147
|
+
"--category",
|
|
148
|
+
help="Ecosystem category (e.g. python, node, docker) to tailor the diagnosis. [default: general (or config)]",
|
|
149
|
+
),
|
|
150
|
+
provider: Optional[str] = typer.Option(
|
|
151
|
+
None,
|
|
152
|
+
"--provider",
|
|
153
|
+
help="AI provider to use (ollama, groq, gemini). [default: ollama (or config)]",
|
|
154
|
+
),
|
|
155
|
+
no_cache: bool = typer.Option(
|
|
156
|
+
False,
|
|
157
|
+
"--no-cache",
|
|
158
|
+
help="Bypass the local cache and force a new AI diagnosis.",
|
|
159
|
+
),
|
|
160
|
+
) -> None:
|
|
161
|
+
"""
|
|
162
|
+
Run COMMAND. If it fails, diagnose the error with a local LLM (or cache),
|
|
163
|
+
propose a fix, and (with your approval) apply it and retry.
|
|
164
|
+
|
|
165
|
+
Everything after [OPTIONS] is treated as the command to run:
|
|
166
|
+
|
|
167
|
+
\b
|
|
168
|
+
envfix run python -m non_existent_module_xyz
|
|
169
|
+
envfix run python -c "import torch"
|
|
170
|
+
envfix run python train.py --gpu 0
|
|
171
|
+
envfix run --model qwen2.5:3b python train.py
|
|
172
|
+
"""
|
|
173
|
+
# ── Parse args (strip Typer 0.27 subcommand-name leak) ───────────────
|
|
174
|
+
args = list(ctx.args)
|
|
175
|
+
if args and args[0] == "run":
|
|
176
|
+
args.pop(0)
|
|
177
|
+
cmd = _quote_join(args)
|
|
178
|
+
|
|
179
|
+
if not cmd.strip():
|
|
180
|
+
console.print(
|
|
181
|
+
"[bold red]Error:[/bold red] No command provided.\n"
|
|
182
|
+
"Usage: [bold]envfix run[/bold] [OPTIONS] COMMAND...\n"
|
|
183
|
+
"Example:[bold] envfix run python -m non_existent_module_xyz[/bold]"
|
|
184
|
+
)
|
|
185
|
+
raise typer.Exit(code=1)
|
|
186
|
+
|
|
187
|
+
# Resolve defaults from config if omitted
|
|
188
|
+
config = load_config()
|
|
189
|
+
if not provider:
|
|
190
|
+
provider = config.get("default_provider", "ollama")
|
|
191
|
+
if not category:
|
|
192
|
+
category = config.get("default_category", "general")
|
|
193
|
+
|
|
194
|
+
# ── Step 1: Run the original command ─────────────────────────────────
|
|
195
|
+
console.print(f"\n[bold cyan]▶ Running:[/bold cyan] {cmd}\n")
|
|
196
|
+
stdout, stderr, returncode = run_command(cmd)
|
|
197
|
+
|
|
198
|
+
if stdout:
|
|
199
|
+
console.print(stdout, end="")
|
|
200
|
+
|
|
201
|
+
if returncode == 0:
|
|
202
|
+
console.print("\n[bold green]✓ Command succeeded — nothing to fix![/bold green]")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
# ── Step 2: Command failed — show the error ───────────────────────────
|
|
206
|
+
error_text = stderr.strip() or stdout.strip() or "(no output captured)"
|
|
207
|
+
console.print(
|
|
208
|
+
Panel(
|
|
209
|
+
error_text,
|
|
210
|
+
title="[bold red]✗ Command Failed[/bold red]",
|
|
211
|
+
border_style="red",
|
|
212
|
+
expand=False,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# ── Step 3: Extract code context from the stack trace ─────────────────
|
|
217
|
+
code_context = extract_context(error_text)
|
|
218
|
+
if code_context:
|
|
219
|
+
console.print(
|
|
220
|
+
f"[dim]📎 Context found: {code_context.filepath} "
|
|
221
|
+
f"(lines {code_context.start_line}–{code_context.end_line})[/dim]"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# ── Step 4: Check known-fix cache before calling the model ───────────
|
|
225
|
+
if not no_cache:
|
|
226
|
+
cache_hit = find_cached_fix(error_text, category=category)
|
|
227
|
+
else:
|
|
228
|
+
cache_hit = None
|
|
229
|
+
|
|
230
|
+
source = provider
|
|
231
|
+
|
|
232
|
+
if cache_hit:
|
|
233
|
+
if cache_hit.previously_worked:
|
|
234
|
+
banner = (
|
|
235
|
+
f"⚡ Found a previously [bold green]verified[/bold green] fix "
|
|
236
|
+
f"for a similar error ({cache_hit.score:.0%} match) — skipping model call."
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
banner = (
|
|
240
|
+
f"⚡ Found a previously [bold yellow]attempted[/bold yellow] fix "
|
|
241
|
+
f"for a similar error ({cache_hit.score:.0%} match) — "
|
|
242
|
+
"skipping model call. [dim](fix didn't fully resolve it last time)[/dim]"
|
|
243
|
+
)
|
|
244
|
+
console.print(f"\n{banner}")
|
|
245
|
+
diagnosis = cache_hit.diagnosis
|
|
246
|
+
# Normalise cached fix commands — old log entries may have bare 'pip install'
|
|
247
|
+
# which doesn't work on Windows. _clean_fix() converts it to 'python -m pip'.
|
|
248
|
+
fix = _clean_fix(cache_hit.fix)
|
|
249
|
+
source = "cache"
|
|
250
|
+
_show_fix_panel(diagnosis, fix, source, cache_hit.score)
|
|
251
|
+
else:
|
|
252
|
+
# ── Step 4a: Ask the LLM ─────────────────────────────────────────
|
|
253
|
+
provider_name = provider.capitalize()
|
|
254
|
+
display_model = get_actual_model(model, provider)
|
|
255
|
+
console.print(
|
|
256
|
+
f"\n[bold yellow]🤖 Asking {provider_name} ({display_model}) for a diagnosis…[/bold yellow]"
|
|
257
|
+
)
|
|
258
|
+
try:
|
|
259
|
+
result = get_diagnosis(
|
|
260
|
+
stderr=error_text,
|
|
261
|
+
model=model,
|
|
262
|
+
category=category,
|
|
263
|
+
code_context=code_context,
|
|
264
|
+
provider=provider,
|
|
265
|
+
)
|
|
266
|
+
except RuntimeError as exc:
|
|
267
|
+
console.print(f"\n[bold red]Error reaching {provider_name}:[/bold red] {exc}")
|
|
268
|
+
raise typer.Exit(code=1)
|
|
269
|
+
|
|
270
|
+
if not result.parsed_ok:
|
|
271
|
+
console.print(
|
|
272
|
+
"\n[bold yellow]⚠ Could not parse a structured response. "
|
|
273
|
+
"Showing raw model output:[/bold yellow]"
|
|
274
|
+
)
|
|
275
|
+
console.print(
|
|
276
|
+
Panel(result.raw_response, border_style="yellow", expand=False)
|
|
277
|
+
)
|
|
278
|
+
log_attempt(
|
|
279
|
+
original_command=cmd,
|
|
280
|
+
error_text=error_text,
|
|
281
|
+
diagnosis=result.diagnosis,
|
|
282
|
+
fix_command=result.fix,
|
|
283
|
+
user_approved=False,
|
|
284
|
+
fix_worked=None,
|
|
285
|
+
source=provider,
|
|
286
|
+
category=category,
|
|
287
|
+
context_included=code_context is not None,
|
|
288
|
+
provider=provider,
|
|
289
|
+
)
|
|
290
|
+
raise typer.Exit(code=1)
|
|
291
|
+
|
|
292
|
+
diagnosis = result.diagnosis
|
|
293
|
+
fix = result.fix
|
|
294
|
+
_show_fix_panel(diagnosis, fix, source)
|
|
295
|
+
|
|
296
|
+
# ── Step 4b: Dry-run preview ──────────────────────────────────────────
|
|
297
|
+
preview = get_fix_preview(fix)
|
|
298
|
+
if preview:
|
|
299
|
+
console.print(
|
|
300
|
+
f"\n[bold yellow]📋 What this will do:[/bold yellow] {preview}"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# ── Step 5: Ask for approval ──────────────────────────────────────────
|
|
304
|
+
if is_destructive(fix):
|
|
305
|
+
console.print(
|
|
306
|
+
"\n[bold red]⚠️ DANGEROUS COMMAND DETECTED[/bold red]\n"
|
|
307
|
+
"[yellow]This command may delete files, change permissions, or execute remote code.[/yellow]"
|
|
308
|
+
)
|
|
309
|
+
response = Prompt.ask("[bold]Type 'yes' to run this fix[/bold]", default="no")
|
|
310
|
+
approved = (response.strip().lower() == "yes")
|
|
311
|
+
else:
|
|
312
|
+
approved = Confirm.ask("\n[bold]Run this fix?[/bold]", default=False)
|
|
313
|
+
|
|
314
|
+
if not approved:
|
|
315
|
+
console.print("[dim]Exiting without changes.[/dim]")
|
|
316
|
+
log_attempt(
|
|
317
|
+
original_command=cmd,
|
|
318
|
+
error_text=error_text,
|
|
319
|
+
diagnosis=diagnosis,
|
|
320
|
+
fix_command=fix,
|
|
321
|
+
user_approved=False,
|
|
322
|
+
fix_worked=None,
|
|
323
|
+
source=source,
|
|
324
|
+
category=category,
|
|
325
|
+
context_included=code_context is not None,
|
|
326
|
+
provider=provider,
|
|
327
|
+
)
|
|
328
|
+
raise typer.Exit(code=0)
|
|
329
|
+
|
|
330
|
+
# ── Step 6: Run the fix, then re-run the original command ─────────────
|
|
331
|
+
console.print(f"\n[bold cyan]⚙ Applying fix:[/bold cyan] {fix}\n")
|
|
332
|
+
fix_stdout, fix_stderr, fix_rc = run_command(fix)
|
|
333
|
+
|
|
334
|
+
if fix_stdout:
|
|
335
|
+
console.print(fix_stdout, end="")
|
|
336
|
+
if fix_stderr:
|
|
337
|
+
console.print(fix_stderr, end="")
|
|
338
|
+
|
|
339
|
+
if fix_rc != 0:
|
|
340
|
+
console.print(
|
|
341
|
+
"[bold red]✗ Fix command itself failed "
|
|
342
|
+
f"(exit code {fix_rc}). Aborting retry.[/bold red]"
|
|
343
|
+
)
|
|
344
|
+
log_attempt(
|
|
345
|
+
original_command=cmd,
|
|
346
|
+
error_text=error_text,
|
|
347
|
+
diagnosis=diagnosis,
|
|
348
|
+
fix_command=fix,
|
|
349
|
+
user_approved=True,
|
|
350
|
+
fix_worked=False,
|
|
351
|
+
source=source,
|
|
352
|
+
category=category,
|
|
353
|
+
context_included=code_context is not None,
|
|
354
|
+
provider=provider,
|
|
355
|
+
)
|
|
356
|
+
raise typer.Exit(code=1)
|
|
357
|
+
|
|
358
|
+
console.print(
|
|
359
|
+
f"\n[bold cyan]🔄 Re-running original command:[/bold cyan] {cmd}\n"
|
|
360
|
+
)
|
|
361
|
+
retry_stdout, retry_stderr, retry_rc = run_command(cmd)
|
|
362
|
+
|
|
363
|
+
if retry_stdout:
|
|
364
|
+
console.print(retry_stdout, end="")
|
|
365
|
+
if retry_stderr:
|
|
366
|
+
console.print(retry_stderr, end="")
|
|
367
|
+
|
|
368
|
+
# ── Step 7: Report result ─────────────────────────────────────────────
|
|
369
|
+
worked = retry_rc == 0
|
|
370
|
+
if worked:
|
|
371
|
+
console.print(
|
|
372
|
+
"\n[bold green]✓ Success! The fix resolved the issue.[/bold green]"
|
|
373
|
+
)
|
|
374
|
+
else:
|
|
375
|
+
console.print(
|
|
376
|
+
"\n[bold red]✗ Original command still failed after the fix "
|
|
377
|
+
f"(exit code {retry_rc}). "
|
|
378
|
+
"You may need to try a different approach.[/bold red]"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# ── Step 8: Log everything ────────────────────────────────────────────
|
|
382
|
+
log_attempt(
|
|
383
|
+
original_command=cmd,
|
|
384
|
+
error_text=error_text,
|
|
385
|
+
diagnosis=diagnosis,
|
|
386
|
+
fix_command=fix,
|
|
387
|
+
user_approved=True,
|
|
388
|
+
fix_worked=worked,
|
|
389
|
+
source=source,
|
|
390
|
+
category=category,
|
|
391
|
+
context_included=code_context is not None,
|
|
392
|
+
provider=provider,
|
|
393
|
+
)
|
|
394
|
+
console.print(f"\n[dim]📝 Attempt logged to {get_log_file()}[/dim]")
|
|
395
|
+
raise typer.Exit(code=0 if worked else 1)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@app.command()
|
|
399
|
+
def history(
|
|
400
|
+
last: int = typer.Option(20, "--last", "-n", help="Show the last N attempts."),
|
|
401
|
+
) -> None:
|
|
402
|
+
"""
|
|
403
|
+
Print a readable summary of past envfix attempts from the current user's log file.
|
|
404
|
+
|
|
405
|
+
\b
|
|
406
|
+
envfix history # show last 20
|
|
407
|
+
envfix history --last 5 # show last 5
|
|
408
|
+
"""
|
|
409
|
+
entries = get_history()
|
|
410
|
+
|
|
411
|
+
if not entries:
|
|
412
|
+
console.print(
|
|
413
|
+
"[yellow]No history found.[/yellow] "
|
|
414
|
+
f"Run [bold]envfix run[/bold] on a failing command first.\n"
|
|
415
|
+
f"(Looking for [dim]{get_log_file()}[/dim] in the current directory)"
|
|
416
|
+
)
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
shown = entries[:last]
|
|
420
|
+
total = len(entries)
|
|
421
|
+
|
|
422
|
+
table = Table(
|
|
423
|
+
title=f"envfix History (showing {len(shown)} of {total} attempts)",
|
|
424
|
+
border_style="cyan",
|
|
425
|
+
show_lines=True,
|
|
426
|
+
expand=False,
|
|
427
|
+
)
|
|
428
|
+
table.add_column("#", no_wrap=True)
|
|
429
|
+
table.add_column("When", style="white", width=16, no_wrap=True)
|
|
430
|
+
table.add_column("Command", style="bold white", width=30)
|
|
431
|
+
table.add_column("Fix", style="cyan", width=30)
|
|
432
|
+
table.add_column("Category", style="magenta", width=10, no_wrap=True)
|
|
433
|
+
table.add_column("Approved", style="white", width=8, no_wrap=True)
|
|
434
|
+
table.add_column("Worked", style="white", width=8, no_wrap=True)
|
|
435
|
+
table.add_column("Source", style="dim", width=7, no_wrap=True)
|
|
436
|
+
|
|
437
|
+
for idx, entry in enumerate(shown, start=1):
|
|
438
|
+
# Format timestamp nicely
|
|
439
|
+
ts = entry.get("timestamp", "")
|
|
440
|
+
try:
|
|
441
|
+
dt = datetime.fromisoformat(ts)
|
|
442
|
+
when = dt.strftime("%b %d %H:%M")
|
|
443
|
+
except (ValueError, TypeError):
|
|
444
|
+
when = ts[:16]
|
|
445
|
+
|
|
446
|
+
orig_cmd = entry.get("original_command", "—")
|
|
447
|
+
fix_cmd = entry.get("fix_command", "—")
|
|
448
|
+
|
|
449
|
+
approved_val = entry.get("user_approved")
|
|
450
|
+
worked_val = entry.get("fix_worked")
|
|
451
|
+
|
|
452
|
+
approved_str = "[green]y[/green]" if approved_val else "[red]n[/red]"
|
|
453
|
+
|
|
454
|
+
if worked_val is True:
|
|
455
|
+
worked_str = "[green]✓ yes[/green]"
|
|
456
|
+
elif worked_val is False:
|
|
457
|
+
worked_str = "[red]✗ no[/red]"
|
|
458
|
+
else:
|
|
459
|
+
worked_str = "[dim]—[/dim]"
|
|
460
|
+
|
|
461
|
+
source_str = entry.get("source", "ollama")
|
|
462
|
+
cat_str = entry.get("category", "general")
|
|
463
|
+
|
|
464
|
+
table.add_row(
|
|
465
|
+
str(idx),
|
|
466
|
+
when,
|
|
467
|
+
orig_cmd,
|
|
468
|
+
fix_cmd,
|
|
469
|
+
cat_str,
|
|
470
|
+
approved_str,
|
|
471
|
+
worked_str,
|
|
472
|
+
source_str,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
console.print()
|
|
476
|
+
console.print(table)
|
|
477
|
+
console.print()
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def main() -> None: # pragma: no cover
|
|
481
|
+
app()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
if __name__ == "__main__": # pragma: no cover
|
|
485
|
+
main()
|