logwise-cli 0.2.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.
- logwise/ai.py +190 -0
- logwise/analyze.py +122 -0
- logwise/capture.py +64 -0
- logwise/display.py +221 -0
- logwise/env.py +98 -0
- logwise/main.py +206 -0
- logwise/paths.py +34 -0
- logwise/providers.py +83 -0
- logwise/rules.py +42 -0
- logwise_cli-0.2.0.dist-info/METADATA +438 -0
- logwise_cli-0.2.0.dist-info/RECORD +15 -0
- logwise_cli-0.2.0.dist-info/WHEEL +5 -0
- logwise_cli-0.2.0.dist-info/entry_points.txt +2 -0
- logwise_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- logwise_cli-0.2.0.dist-info/top_level.txt +1 -0
logwise/ai.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""AI error analysis over any OpenAI-compatible chat API.
|
|
2
|
+
|
|
3
|
+
Single HTTP code path (stdlib ``urllib`` only — no vendor SDKs) for all
|
|
4
|
+
providers in :mod:`logwise.providers`. Gemini works through its
|
|
5
|
+
``.../v1beta/openai`` endpoint; Ollama/LM Studio work against localhost.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
from .providers import (
|
|
13
|
+
PROVIDERS,
|
|
14
|
+
key_hint,
|
|
15
|
+
resolve_api_key,
|
|
16
|
+
resolve_base_url,
|
|
17
|
+
resolve_model,
|
|
18
|
+
resolve_provider,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
TIMEOUT_S = 30
|
|
22
|
+
|
|
23
|
+
_PROMPT_TEMPLATE = """Analyze this command error:
|
|
24
|
+
Command: {command}
|
|
25
|
+
Exit code: {exit_code}
|
|
26
|
+
Stderr: {stderr}
|
|
27
|
+
|
|
28
|
+
Reply in exactly this format, under 200 words:
|
|
29
|
+
Reason: <one or two plain-language sentences>
|
|
30
|
+
Fixes:
|
|
31
|
+
1. <first step>
|
|
32
|
+
2. <second step>
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _extract_sse_content(body: str) -> str:
|
|
37
|
+
"""Assemble assistant text from a Server-Sent Events stream.
|
|
38
|
+
|
|
39
|
+
Some OpenAI-compatible servers stream even when asked not to.
|
|
40
|
+
Collects ``choices[0].delta.content`` from each ``data:`` chunk,
|
|
41
|
+
ignoring reasoning fields and the ``[DONE]`` terminator.
|
|
42
|
+
"""
|
|
43
|
+
parts = []
|
|
44
|
+
for line in body.splitlines():
|
|
45
|
+
line = line.strip()
|
|
46
|
+
if not line.startswith("data:"):
|
|
47
|
+
continue
|
|
48
|
+
data = line[5:].strip()
|
|
49
|
+
if not data or data == "[DONE]":
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
chunk = json.loads(data)
|
|
53
|
+
except ValueError:
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
delta = chunk["choices"][0].get("delta", {})
|
|
57
|
+
except (KeyError, IndexError, AttributeError):
|
|
58
|
+
continue
|
|
59
|
+
content = delta.get("content")
|
|
60
|
+
if content:
|
|
61
|
+
parts.append(content)
|
|
62
|
+
return "".join(parts).strip()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _post_chat_completions(base_url: str, api_key: str | None, model: str, prompt: str) -> str:
|
|
66
|
+
"""POST one chat-completions request, return the assistant text."""
|
|
67
|
+
body = {
|
|
68
|
+
"model": model,
|
|
69
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
70
|
+
"temperature": 0.7,
|
|
71
|
+
"max_tokens": 700,
|
|
72
|
+
"stream": False,
|
|
73
|
+
}
|
|
74
|
+
req = urllib.request.Request(
|
|
75
|
+
f"{base_url}/chat/completions",
|
|
76
|
+
data=json.dumps(body).encode("utf-8"),
|
|
77
|
+
headers={"Content-Type": "application/json"},
|
|
78
|
+
)
|
|
79
|
+
if api_key:
|
|
80
|
+
req.add_header("Authorization", f"Bearer {api_key}")
|
|
81
|
+
try:
|
|
82
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
|
|
83
|
+
raw = resp.read().decode("utf-8", "replace")
|
|
84
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
85
|
+
except urllib.error.HTTPError as e:
|
|
86
|
+
detail = e.read().decode("utf-8", "replace")[:300]
|
|
87
|
+
raise RuntimeError(f"HTTP {e.code} from {base_url}: {detail}")
|
|
88
|
+
except urllib.error.URLError as e:
|
|
89
|
+
raise RuntimeError(f"Cannot reach {base_url}: {e.reason}")
|
|
90
|
+
|
|
91
|
+
if "text/event-stream" in content_type:
|
|
92
|
+
text = _extract_sse_content(raw)
|
|
93
|
+
if not text:
|
|
94
|
+
raise RuntimeError(f"Empty SSE stream from {base_url}. Preview: {raw[:300]}")
|
|
95
|
+
return text
|
|
96
|
+
try:
|
|
97
|
+
payload = json.loads(raw)
|
|
98
|
+
except ValueError:
|
|
99
|
+
raise RuntimeError(
|
|
100
|
+
f"Non-JSON response from {base_url} "
|
|
101
|
+
f"(content-type: {content_type or 'unknown'}). "
|
|
102
|
+
f"Preview: {raw[:300]}"
|
|
103
|
+
)
|
|
104
|
+
try:
|
|
105
|
+
return payload["choices"][0]["message"]["content"].strip()
|
|
106
|
+
except (KeyError, IndexError, AttributeError):
|
|
107
|
+
raise RuntimeError(f"Unexpected API response shape: {str(payload)[:300]}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
#: Line markers (case-insensitive, markdown-bold tolerant) that start
|
|
111
|
+
#: the fixes section of a model response.
|
|
112
|
+
_FIXES_MARKERS = (
|
|
113
|
+
"step-by-step",
|
|
114
|
+
"steps to fix",
|
|
115
|
+
"fix steps",
|
|
116
|
+
"how to fix",
|
|
117
|
+
"fixes:",
|
|
118
|
+
"solution:",
|
|
119
|
+
"resolution:",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _split_reason_fixes(analysis: str) -> tuple[str, str]:
|
|
124
|
+
"""Split model output into (reason, fixes).
|
|
125
|
+
|
|
126
|
+
Finds the first line starting with a known fixes marker; everything
|
|
127
|
+
before it is the reason (minus a leading ``Reason:`` label), everything
|
|
128
|
+
from it onward is the fixes (minus a bare header line like
|
|
129
|
+
``**Fix Steps:**``). Falls back to whole-text-as-reason.
|
|
130
|
+
"""
|
|
131
|
+
lines = analysis.splitlines()
|
|
132
|
+
for i, line in enumerate(lines):
|
|
133
|
+
clean = line.strip().strip("*").strip().lower()
|
|
134
|
+
if not any(clean.startswith(m) for m in _FIXES_MARKERS):
|
|
135
|
+
continue
|
|
136
|
+
reason = "\n".join(lines[:i])
|
|
137
|
+
for label in ("a simple human-readable reason for the error:", "reason:"):
|
|
138
|
+
bare = reason.lstrip().lstrip("*")
|
|
139
|
+
if bare.lower().startswith(label):
|
|
140
|
+
reason = bare[len(label) :].lstrip("*").strip()
|
|
141
|
+
break
|
|
142
|
+
fix_lines = lines[i:]
|
|
143
|
+
if fix_lines[0].strip().strip("*").strip().endswith(":"):
|
|
144
|
+
fix_lines = fix_lines[1:] # bare header line, drop it
|
|
145
|
+
fixes = "\n".join(fix_lines).strip()
|
|
146
|
+
return reason.strip(), fixes or "No fixes suggested."
|
|
147
|
+
return analysis.strip(), "No fixes suggested."
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ai_analyze_err(
|
|
151
|
+
command: str, stderr: str, exit_code: int, provider: str | None = None, model: str | None = None
|
|
152
|
+
) -> dict:
|
|
153
|
+
"""
|
|
154
|
+
Analyze an error with the selected provider (default: gemini).
|
|
155
|
+
|
|
156
|
+
Returns {"reason", "fixes", "provider", "model"} or {"error": ...}.
|
|
157
|
+
Resolves provider → LOGWISE_PROVIDER env → "gemini";
|
|
158
|
+
model → LOGWISE_MODEL env → provider default;
|
|
159
|
+
key → LOGWISE_API_KEY env → provider key env.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
canonical, config = resolve_provider(provider)
|
|
163
|
+
except ValueError as e:
|
|
164
|
+
return {"error": str(e)}
|
|
165
|
+
use_model = resolve_model(config, model)
|
|
166
|
+
base_url = resolve_base_url(config)
|
|
167
|
+
api_key = resolve_api_key(canonical, config)
|
|
168
|
+
|
|
169
|
+
keyless_local = "localhost" in base_url or "127.0.0.1" in base_url
|
|
170
|
+
if config["key_env"] is not None and not api_key and not keyless_local:
|
|
171
|
+
return {"error": f"No API key for provider '{canonical}'. {key_hint(canonical, config)}."}
|
|
172
|
+
|
|
173
|
+
prompt = _PROMPT_TEMPLATE.format(command=command, exit_code=exit_code, stderr=stderr)
|
|
174
|
+
try:
|
|
175
|
+
analysis = _post_chat_completions(base_url, api_key, use_model, prompt)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
return {"error": f"AI analysis failed ({canonical}/{use_model}): {e}"}
|
|
178
|
+
|
|
179
|
+
reason, fixes = _split_reason_fixes(analysis)
|
|
180
|
+
return {
|
|
181
|
+
"reason": reason,
|
|
182
|
+
"fixes": fixes,
|
|
183
|
+
"provider": canonical,
|
|
184
|
+
"model": use_model,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def list_providers() -> list:
|
|
189
|
+
"""Provider names for CLI help / validation."""
|
|
190
|
+
return sorted(PROVIDERS)
|
logwise/analyze.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from .paths import get_log_dir
|
|
5
|
+
from .rules import apply_rules
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def analyze_log_in_memory(
|
|
9
|
+
log: dict, use_ai: bool = False, provider: str | None = None, model: str | None = None
|
|
10
|
+
) -> dict:
|
|
11
|
+
"""
|
|
12
|
+
Analyze log dict directly.
|
|
13
|
+
If use_ai=True → AI-only analysis (rules skipped)
|
|
14
|
+
"""
|
|
15
|
+
result = {"log": log}
|
|
16
|
+
|
|
17
|
+
# --- AI MODE (explicit override) ---
|
|
18
|
+
if use_ai:
|
|
19
|
+
from .ai import ai_analyze_err # lazy: no network imports unless needed
|
|
20
|
+
|
|
21
|
+
ai_result = ai_analyze_err(
|
|
22
|
+
log["command"],
|
|
23
|
+
log["stderr"],
|
|
24
|
+
log["exit_code"],
|
|
25
|
+
provider=provider,
|
|
26
|
+
model=model,
|
|
27
|
+
)
|
|
28
|
+
if "error" in ai_result:
|
|
29
|
+
result["summary"] = f"AI analysis failed: {ai_result['error']}"
|
|
30
|
+
else:
|
|
31
|
+
result["summary"] = "AI-based analysis requested."
|
|
32
|
+
result["ai_analysis"] = ai_result
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
# --- DEFAULT MODE (rules first, AI fallback) ---
|
|
36
|
+
issues = apply_rules(log)
|
|
37
|
+
result["issues"] = issues
|
|
38
|
+
|
|
39
|
+
if issues:
|
|
40
|
+
result["summary"] = f"Found {len(issues)} rule-based issue(s)."
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
from .ai import ai_analyze_err # lazy: no network imports unless needed
|
|
44
|
+
|
|
45
|
+
ai_result = ai_analyze_err(
|
|
46
|
+
log["command"],
|
|
47
|
+
log["stderr"],
|
|
48
|
+
log["exit_code"],
|
|
49
|
+
provider=provider,
|
|
50
|
+
model=model,
|
|
51
|
+
)
|
|
52
|
+
if "error" in ai_result:
|
|
53
|
+
result["summary"] = f"No rule-based issues. AI fallback failed: {ai_result['error']}"
|
|
54
|
+
else:
|
|
55
|
+
result["summary"] = "No rule-based issues. AI fallback used."
|
|
56
|
+
result["ai_analysis"] = ai_result
|
|
57
|
+
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def analyze_log(
|
|
62
|
+
log_file: str, use_ai: bool = False, provider: str | None = None, model: str | None = None
|
|
63
|
+
) -> dict:
|
|
64
|
+
full_path = os.path.join(get_log_dir(), log_file)
|
|
65
|
+
if not os.path.exists(full_path):
|
|
66
|
+
return {"error": "Log file not found."}
|
|
67
|
+
|
|
68
|
+
with open(full_path) as f:
|
|
69
|
+
log = json.load(f)
|
|
70
|
+
|
|
71
|
+
return analyze_log_in_memory(log, use_ai=use_ai, provider=provider, model=model)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def list_logs() -> list:
|
|
75
|
+
log_dir = get_log_dir()
|
|
76
|
+
if not os.path.isdir(log_dir):
|
|
77
|
+
return []
|
|
78
|
+
return [f for f in os.listdir(log_dir) if f.endswith(".json")]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def prune_logs(
|
|
82
|
+
keep: int | None = None, older_than_days: float | None = None, dry_run: bool = False
|
|
83
|
+
) -> dict:
|
|
84
|
+
"""Delete saved logs by age/count.
|
|
85
|
+
|
|
86
|
+
Returns {"deleted": [...], "kept": n}; with `dry_run=True` nothing is
|
|
87
|
+
removed and the doomed files are reported under "doomed" instead.
|
|
88
|
+
|
|
89
|
+
- `older_than_days` set: only files older than that are eligible.
|
|
90
|
+
- `keep` set: the newest `keep` eligible files are exempt.
|
|
91
|
+
- Both None: nothing is deleted.
|
|
92
|
+
Files are ordered by mtime (oldest first). Unreadable/deleted files
|
|
93
|
+
are skipped, never fatal.
|
|
94
|
+
"""
|
|
95
|
+
if keep is None and older_than_days is None:
|
|
96
|
+
return {"deleted": [], "doomed": [], "kept": len(list_logs())}
|
|
97
|
+
log_dir = get_log_dir()
|
|
98
|
+
entries = []
|
|
99
|
+
for name in list_logs():
|
|
100
|
+
try:
|
|
101
|
+
entries.append((os.path.getmtime(os.path.join(log_dir, name)), name))
|
|
102
|
+
except OSError:
|
|
103
|
+
continue
|
|
104
|
+
entries.sort() # oldest first
|
|
105
|
+
if older_than_days is not None:
|
|
106
|
+
import time
|
|
107
|
+
|
|
108
|
+
cutoff = time.time() - older_than_days * 86400
|
|
109
|
+
entries = [(mtime, name) for mtime, name in entries if mtime < cutoff]
|
|
110
|
+
doomed = [name for _, name in entries]
|
|
111
|
+
if keep is not None and keep >= 0:
|
|
112
|
+
doomed = doomed[: max(0, len(doomed) - keep)]
|
|
113
|
+
if dry_run:
|
|
114
|
+
return {"deleted": [], "doomed": doomed, "kept": len(list_logs())}
|
|
115
|
+
deleted = []
|
|
116
|
+
for name in doomed:
|
|
117
|
+
try:
|
|
118
|
+
os.remove(os.path.join(log_dir, name))
|
|
119
|
+
deleted.append(name)
|
|
120
|
+
except OSError:
|
|
121
|
+
continue
|
|
122
|
+
return {"deleted": deleted, "doomed": [], "kept": len(list_logs())}
|
logwise/capture.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from contextlib import nullcontext as _nullcontext
|
|
6
|
+
|
|
7
|
+
from . import display
|
|
8
|
+
from .analyze import analyze_log_in_memory
|
|
9
|
+
from .paths import get_log_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def capture_and_run(
|
|
13
|
+
command: str,
|
|
14
|
+
use_ai: bool = False,
|
|
15
|
+
provider: str | None = None,
|
|
16
|
+
model: str | None = None,
|
|
17
|
+
no_color: bool = False,
|
|
18
|
+
) -> str | None:
|
|
19
|
+
"""
|
|
20
|
+
Run command, print output if no error else analyze the error.
|
|
21
|
+
Returns the saved log filename on failure, None on success.
|
|
22
|
+
"""
|
|
23
|
+
start_time = datetime.datetime.now().isoformat()
|
|
24
|
+
try:
|
|
25
|
+
result = subprocess.run(command, shell=True, capture_output=True, text=True)
|
|
26
|
+
log_entry = {
|
|
27
|
+
"command": command,
|
|
28
|
+
"start_time": start_time,
|
|
29
|
+
"end_time": datetime.datetime.now().isoformat(),
|
|
30
|
+
"stdout": result.stdout.strip(),
|
|
31
|
+
"stderr": result.stderr.strip(),
|
|
32
|
+
"exit_code": result.returncode,
|
|
33
|
+
}
|
|
34
|
+
if result.returncode == 0 and not result.stderr:
|
|
35
|
+
print(result.stdout)
|
|
36
|
+
return None
|
|
37
|
+
else:
|
|
38
|
+
log_dir = get_log_dir()
|
|
39
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
40
|
+
log_name = f"log_{start_time.replace(':', '-')}.json"
|
|
41
|
+
log_file = os.path.join(log_dir, log_name)
|
|
42
|
+
with open(log_file, "w") as f:
|
|
43
|
+
json.dump(log_entry, f, indent=4)
|
|
44
|
+
# Analyze in memory
|
|
45
|
+
console = display.get_console(no_color=no_color)
|
|
46
|
+
plain = not display.use_rich(console)
|
|
47
|
+
with display.ai_progress(console, provider, model, plain) if use_ai else _nullcontext():
|
|
48
|
+
analysis = analyze_log_in_memory(
|
|
49
|
+
log_entry, use_ai=use_ai, provider=provider, model=model
|
|
50
|
+
)
|
|
51
|
+
display.error_header(console, result.returncode, plain)
|
|
52
|
+
display.stderr_block(console, result.stderr, plain)
|
|
53
|
+
display.analysis(
|
|
54
|
+
console,
|
|
55
|
+
analysis["summary"],
|
|
56
|
+
analysis.get("issues", []),
|
|
57
|
+
analysis.get("ai_analysis"),
|
|
58
|
+
plain,
|
|
59
|
+
)
|
|
60
|
+
return log_name
|
|
61
|
+
|
|
62
|
+
except Exception as e:
|
|
63
|
+
print("Execution failed: " + str(e))
|
|
64
|
+
return None
|
logwise/display.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Terminal rendering for LogWise (the only module that knows about color).
|
|
2
|
+
|
|
3
|
+
All failure/analysis output goes through here so `run` and `analyze`
|
|
4
|
+
never drift apart. Two modes:
|
|
5
|
+
|
|
6
|
+
- Rich (terminal): red error header, stderr panel, colored issue rows,
|
|
7
|
+
Markdown AI panel, table-backed `list`.
|
|
8
|
+
- Plain (piped/redirected, `NO_COLOR`/`LOGWISE_NO_COLOR`, or `--no-color`):
|
|
9
|
+
the exact legacy `print()` text, byte-for-byte, so scripts keep working.
|
|
10
|
+
|
|
11
|
+
Success-path stdout never touches this module — it stays a plain print.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from contextlib import nullcontext
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.markdown import Markdown
|
|
21
|
+
from rich.markup import escape
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
from rich.text import Text
|
|
25
|
+
|
|
26
|
+
#: Accent color per AI provider (AI panel, spinner). Unknown → magenta.
|
|
27
|
+
PROVIDER_THEMES = {
|
|
28
|
+
"gemini": "blue",
|
|
29
|
+
"openai": "green",
|
|
30
|
+
"deepseek": "violet",
|
|
31
|
+
"groq": "orange1",
|
|
32
|
+
"openrouter": "cyan",
|
|
33
|
+
"ollama": "grey62",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def provider_color(name: str | None) -> str:
|
|
38
|
+
"""Accent color for a provider name (case-insensitive)."""
|
|
39
|
+
return PROVIDER_THEMES.get((name or "").strip().lower(), "magenta")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_console(no_color: bool = False) -> Console:
|
|
43
|
+
"""Build a Console honoring `--no-color` / `LOGWISE_NO_COLOR` / `NO_COLOR`."""
|
|
44
|
+
return Console(
|
|
45
|
+
no_color=no_color or bool(os.getenv("LOGWISE_NO_COLOR")),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def use_rich(console: Console) -> bool:
|
|
50
|
+
"""Rich only when attached to a real terminal with color enabled."""
|
|
51
|
+
return bool(console.is_terminal) and not console.no_color
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def interactive(console: Console) -> bool:
|
|
55
|
+
"""True when safe to prompt: terminal output AND tty stdin."""
|
|
56
|
+
try:
|
|
57
|
+
stdin_tty = sys.stdin.isatty()
|
|
58
|
+
except Exception:
|
|
59
|
+
stdin_tty = False
|
|
60
|
+
return bool(console.is_terminal) and stdin_tty
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def ai_progress(console: Console, provider: str | None, model: str | None, plain: bool):
|
|
64
|
+
"""Spinner shown while waiting on an AI provider (no-op when plain).
|
|
65
|
+
|
|
66
|
+
Usage: `with ai_progress(console, provider, model, plain): ...`
|
|
67
|
+
"""
|
|
68
|
+
if plain:
|
|
69
|
+
return nullcontext()
|
|
70
|
+
accent = provider_color(provider)
|
|
71
|
+
label = escape(f"{provider or 'ai'} / {model or '?'}")
|
|
72
|
+
return console.status(f"[{accent}]Consulting {label}…[/]", spinner="dots")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def prompt_retry(console: Console) -> bool:
|
|
76
|
+
"""Ask whether to re-run the failed command (False when non-interactive)."""
|
|
77
|
+
if not interactive(console):
|
|
78
|
+
return False
|
|
79
|
+
try:
|
|
80
|
+
return typer.confirm("Retry command?", default=False)
|
|
81
|
+
except Exception:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def prompt_ai(console: Console) -> bool:
|
|
86
|
+
"""Offer AI analysis after a non-AI failure (False when non-interactive)."""
|
|
87
|
+
if not interactive(console):
|
|
88
|
+
return False
|
|
89
|
+
try:
|
|
90
|
+
return typer.confirm("Analyze with AI?", default=False)
|
|
91
|
+
except Exception:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def prompt_rerun(console: Console) -> bool:
|
|
96
|
+
"""Offer to re-run a logged command from `analyze`."""
|
|
97
|
+
if not interactive(console):
|
|
98
|
+
return False
|
|
99
|
+
try:
|
|
100
|
+
return typer.confirm("Re-run the logged command?", default=False)
|
|
101
|
+
except Exception:
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def prompt_edit_command(console: Console, current: str) -> str | None:
|
|
106
|
+
"""Let the user edit a command before re-running it.
|
|
107
|
+
|
|
108
|
+
Returns the (possibly unchanged) command, or None if the user aborts
|
|
109
|
+
or prompts are unavailable. Enter keeps `current` (typer default).
|
|
110
|
+
"""
|
|
111
|
+
if not interactive(console):
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
return typer.prompt("Edit command", default=current)
|
|
115
|
+
except KeyboardInterrupt:
|
|
116
|
+
return None
|
|
117
|
+
except Exception:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def error_header(console: Console, exit_code: int, plain: bool) -> None:
|
|
122
|
+
"""`Error occurred (exit code: N)` — loud in rich, legacy text in plain."""
|
|
123
|
+
if plain:
|
|
124
|
+
typer.echo(f"Error occurred (exit code: {exit_code})")
|
|
125
|
+
else:
|
|
126
|
+
console.print(f"[bold red]:x: Error occurred[/] [red](exit code: {exit_code})[/]")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def stderr_block(console: Console, stderr: str, plain: bool) -> None:
|
|
130
|
+
"""Captured stderr — red panel in rich, labeled text in plain."""
|
|
131
|
+
if plain:
|
|
132
|
+
typer.echo("Stderr captured:")
|
|
133
|
+
typer.echo(stderr)
|
|
134
|
+
else:
|
|
135
|
+
console.print(
|
|
136
|
+
Panel(
|
|
137
|
+
Text(stderr or "(empty)", overflow="fold"),
|
|
138
|
+
title="[red]stderr[/]",
|
|
139
|
+
border_style="red",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def analysis(
|
|
145
|
+
console: Console,
|
|
146
|
+
summary: str,
|
|
147
|
+
issues: list,
|
|
148
|
+
ai_analysis: dict | None,
|
|
149
|
+
plain: bool,
|
|
150
|
+
*,
|
|
151
|
+
show_summary: bool = True,
|
|
152
|
+
ai_label: str = "AI Enhanced Analysis:",
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Summary line + rule issues + optional AI panel.
|
|
155
|
+
|
|
156
|
+
`show_summary=False` skips the summary (for callers that already
|
|
157
|
+
printed it, e.g. `analyze` under its "Log Summary:" label). `ai_label`
|
|
158
|
+
keeps each command's legacy plain-text header byte-identical.
|
|
159
|
+
"""
|
|
160
|
+
if plain:
|
|
161
|
+
if show_summary:
|
|
162
|
+
typer.echo("\nAnalysis:")
|
|
163
|
+
typer.echo(summary)
|
|
164
|
+
for issue in issues:
|
|
165
|
+
typer.echo(f"- Reason: {issue['description']} ({issue['root_cause']})")
|
|
166
|
+
typer.echo(f" Steps to fix: {issue['fixes']}")
|
|
167
|
+
if ai_analysis:
|
|
168
|
+
typer.echo(f"\n{ai_label}")
|
|
169
|
+
typer.echo(f"Reason: {ai_analysis['reason']}")
|
|
170
|
+
typer.echo(f"Steps to fix: {ai_analysis['fixes']}")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
console.print(f"[bold]{escape(summary)}[/]")
|
|
174
|
+
for n, issue in enumerate(issues, 1):
|
|
175
|
+
console.print(
|
|
176
|
+
f"[yellow]:warning: {n}. {escape(issue['description'])}[/]"
|
|
177
|
+
f" [dim]({escape(issue['root_cause'])})[/]"
|
|
178
|
+
f" [dim][{escape(str(issue.get('rule_id', '?')))}][/]"
|
|
179
|
+
)
|
|
180
|
+
console.print(f" [green]->[/] {escape(issue['fixes'])}")
|
|
181
|
+
if ai_analysis:
|
|
182
|
+
provider = escape(str(ai_analysis.get("provider", "?")))
|
|
183
|
+
model = escape(str(ai_analysis.get("model", "?")))
|
|
184
|
+
accent = provider_color(ai_analysis.get("provider"))
|
|
185
|
+
console.print(
|
|
186
|
+
Panel(
|
|
187
|
+
Markdown(f"{ai_analysis['reason']}\n\n{ai_analysis['fixes']}"),
|
|
188
|
+
title=f"[bold {accent}]:sparkles: AI analysis[/]",
|
|
189
|
+
subtitle=f"[dim]{provider} / {model}[/]",
|
|
190
|
+
border_style=accent,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def log_table(console: Console, entries: list, plain: bool) -> None:
|
|
196
|
+
"""Saved-logs listing — table in rich, one-per-line in plain.
|
|
197
|
+
|
|
198
|
+
`entries` is a list of {"file", "command", "exit_code"} dicts; any of
|
|
199
|
+
the latter two may be None for unreadable files.
|
|
200
|
+
"""
|
|
201
|
+
if plain:
|
|
202
|
+
if not entries:
|
|
203
|
+
typer.echo("No logs found.")
|
|
204
|
+
for entry in entries:
|
|
205
|
+
typer.echo(entry["file"])
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
if not entries:
|
|
209
|
+
console.print("[dim]No logs found.[/]")
|
|
210
|
+
return
|
|
211
|
+
table = Table(title="Captured logs")
|
|
212
|
+
table.add_column("File", style="cyan")
|
|
213
|
+
table.add_column("Command", overflow="fold")
|
|
214
|
+
table.add_column("Exit", justify="right", style="red")
|
|
215
|
+
for entry in entries:
|
|
216
|
+
table.add_row(
|
|
217
|
+
escape(entry["file"]),
|
|
218
|
+
escape(entry.get("command")) if entry.get("command") else "[dim](unreadable)[/]",
|
|
219
|
+
str(entry["exit_code"]) if entry.get("exit_code") is not None else "?",
|
|
220
|
+
)
|
|
221
|
+
console.print(table)
|
logwise/env.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Minimal `.env` file support (stdlib only, no python-dotenv dependency).
|
|
2
|
+
|
|
3
|
+
Loaded once at CLI startup (see ``main.py``). Rules:
|
|
4
|
+
|
|
5
|
+
- Discovery: ``LOGWISE_ENV_FILE`` if set, else the first ``.env`` found
|
|
6
|
+
walking from the current working directory up to the filesystem root.
|
|
7
|
+
(Cwd-based, not package-dir-based, so it works for a pip-installed
|
|
8
|
+
``logwise`` run from any project directory.)
|
|
9
|
+
- Precedence: real process environment always wins — file values are
|
|
10
|
+
applied with ``os.environ.setdefault`` and never override exports.
|
|
11
|
+
- Format (common subset): ``KEY=VALUE`` lines, optional ``export ``
|
|
12
|
+
prefix, ``#`` comments, single/double-quote stripping. Single-line
|
|
13
|
+
values only; no variable interpolation or multiline values.
|
|
14
|
+
|
|
15
|
+
A missing file is silently ignored, except an explicitly-set but
|
|
16
|
+
missing ``LOGWISE_ENV_FILE``, which warns on stderr.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
ENV_FILENAME = ".env"
|
|
24
|
+
ENV_FILE_OVERRIDE_VAR = "LOGWISE_ENV_FILE"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_dotenv(start: Path | None = None) -> Path | None:
|
|
28
|
+
"""Return the nearest `.env` from `start` upward, or None."""
|
|
29
|
+
current = (start or Path.cwd()).resolve()
|
|
30
|
+
root = current.anchor
|
|
31
|
+
while True:
|
|
32
|
+
candidate = current / ENV_FILENAME
|
|
33
|
+
if candidate.is_file():
|
|
34
|
+
return candidate
|
|
35
|
+
if str(current) == root:
|
|
36
|
+
return None
|
|
37
|
+
current = current.parent
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_dotenv_text(text: str) -> dict:
|
|
41
|
+
"""Parse `.env` content into a dict (no env interaction)."""
|
|
42
|
+
values: dict = {}
|
|
43
|
+
for raw_line in text.splitlines():
|
|
44
|
+
line = raw_line.strip()
|
|
45
|
+
if not line or line.startswith("#"):
|
|
46
|
+
continue
|
|
47
|
+
if line.lower().startswith("export "):
|
|
48
|
+
line = line[7:].lstrip()
|
|
49
|
+
key, sep, value = line.partition("=")
|
|
50
|
+
if not sep:
|
|
51
|
+
continue
|
|
52
|
+
key = key.strip()
|
|
53
|
+
if not key or key[0].isdigit() or not key.replace("_", "").isalnum():
|
|
54
|
+
continue
|
|
55
|
+
value = value.strip()
|
|
56
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
57
|
+
value = value[1:-1]
|
|
58
|
+
elif "#" in value:
|
|
59
|
+
# Strip trailing inline comments only for unquoted values.
|
|
60
|
+
value = value.split("#", 1)[0].rstrip()
|
|
61
|
+
values[key] = value
|
|
62
|
+
return values
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_dotenv(path: Path | str | None = None) -> Path | None:
|
|
66
|
+
"""Load environment variables from a `.env` file.
|
|
67
|
+
|
|
68
|
+
Returns the path loaded, or None if no file was found. Existing
|
|
69
|
+
process environment variables are never overridden.
|
|
70
|
+
"""
|
|
71
|
+
if path is None:
|
|
72
|
+
override = os.getenv(ENV_FILE_OVERRIDE_VAR)
|
|
73
|
+
if override:
|
|
74
|
+
explicit = Path(override).expanduser()
|
|
75
|
+
if not explicit.is_file():
|
|
76
|
+
print(
|
|
77
|
+
f"logwise: {ENV_FILE_OVERRIDE_VAR}={override} not found, skipping.",
|
|
78
|
+
file=sys.stderr,
|
|
79
|
+
)
|
|
80
|
+
return None
|
|
81
|
+
path = explicit
|
|
82
|
+
else:
|
|
83
|
+
found = find_dotenv()
|
|
84
|
+
if found is None:
|
|
85
|
+
return None
|
|
86
|
+
path = found
|
|
87
|
+
else:
|
|
88
|
+
path = Path(path).expanduser()
|
|
89
|
+
if not path.is_file():
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
text = path.read_text(encoding="utf-8")
|
|
94
|
+
except OSError:
|
|
95
|
+
return None
|
|
96
|
+
for key, value in parse_dotenv_text(text).items():
|
|
97
|
+
os.environ.setdefault(key, value)
|
|
98
|
+
return path
|