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/main.py ADDED
@@ -0,0 +1,206 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ from contextlib import nullcontext
5
+
6
+ import typer
7
+
8
+ from . import display
9
+ from .analyze import analyze_log, list_logs, prune_logs
10
+ from .capture import capture_and_run
11
+ from .env import load_dotenv
12
+ from .paths import ensure_log_dir, get_log_dir
13
+ from .providers import PROVIDERS
14
+
15
+ load_dotenv() # .env / LOGWISE_ENV_FILE, before any resolve_*() runs
16
+ ensure_log_dir() # logs/ exists before any command runs
17
+
18
+
19
+ def _ensure_utf8_output() -> None:
20
+ """Use UTF-8 for CLI output so model text never crashes Windows consoles."""
21
+ for stream in (sys.stdout, sys.stderr):
22
+ try:
23
+ stream.reconfigure(encoding="utf-8", errors="replace")
24
+ except Exception:
25
+ pass
26
+
27
+
28
+ _ensure_utf8_output()
29
+
30
+ app = typer.Typer(help="LogWise: Intelligent Log Analyzer")
31
+
32
+ _PROVIDER_HELP = f"AI provider ({', '.join(sorted(PROVIDERS))}). Env: LOGWISE_PROVIDER."
33
+ _NO_COLOR_HELP = "Disable colored output. Env: LOGWISE_NO_COLOR."
34
+ _NO_PROMPT_HELP = "Never prompt (retry / edit / AI offer / rerun). Env: LOGWISE_NO_PROMPT."
35
+
36
+
37
+ def _prompts_enabled(no_prompt: bool) -> bool:
38
+ return not no_prompt and not os.getenv("LOGWISE_NO_PROMPT")
39
+
40
+
41
+ @app.command()
42
+ def run(
43
+ command: str,
44
+ ai: bool = typer.Option(False, "--ai", help="Use AI enhanced analysis on errors"),
45
+ provider: str = typer.Option(None, "--provider", help=_PROVIDER_HELP),
46
+ model: str = typer.Option(None, "--model", help="Model override. Env: LOGWISE_MODEL."),
47
+ no_color: bool = typer.Option(False, "--no-color", help=_NO_COLOR_HELP),
48
+ no_prompt: bool = typer.Option(False, "--no-prompt", help=_NO_PROMPT_HELP),
49
+ ):
50
+ """Run a command: Output normally on success, analyze errors with reasons/fixes"""
51
+ console = display.get_console(no_color=no_color)
52
+ log_name = capture_and_run(
53
+ command, use_ai=ai, provider=provider, model=model, no_color=no_color
54
+ )
55
+ if log_name is None or not _prompts_enabled(no_prompt):
56
+ return
57
+ current = command
58
+ while display.prompt_retry(console):
59
+ edited = display.prompt_edit_command(console, current)
60
+ if edited is None:
61
+ break
62
+ current = edited
63
+ log_name = capture_and_run(
64
+ current, use_ai=ai, provider=provider, model=model, no_color=no_color
65
+ )
66
+ if log_name is None:
67
+ return
68
+ if not ai and display.prompt_ai(console):
69
+ plain = not display.use_rich(console)
70
+ with display.ai_progress(console, provider, model, plain):
71
+ result = analyze_log(log_name, use_ai=True, provider=provider, model=model)
72
+ display.analysis(
73
+ console,
74
+ result.get("summary", ""),
75
+ result.get("issues", []),
76
+ result.get("ai_analysis"),
77
+ plain,
78
+ show_summary=not plain,
79
+ ai_label="AI Analysis:",
80
+ )
81
+
82
+
83
+ @app.command()
84
+ def analyze(
85
+ log_file: str = typer.Argument(..., help="Log file to analyze"),
86
+ ai: bool = typer.Option(False, "--ai", help="Use AI for enchanced analysis"),
87
+ provider: str = typer.Option(None, "--provider", help=_PROVIDER_HELP),
88
+ model: str = typer.Option(None, "--model", help="Model override. Env: LOGWISE_MODEL."),
89
+ no_color: bool = typer.Option(False, "--no-color", help=_NO_COLOR_HELP),
90
+ no_prompt: bool = typer.Option(False, "--no-prompt", help=_NO_PROMPT_HELP),
91
+ ):
92
+ """
93
+ Analyze a captured log for errors
94
+ """
95
+ console = display.get_console(no_color=no_color)
96
+ plain = not display.use_rich(console)
97
+ if ai:
98
+ context = display.ai_progress(console, provider, model, plain)
99
+ else:
100
+ context = nullcontext()
101
+ with context:
102
+ result = analyze_log(log_file, use_ai=ai, provider=provider, model=model)
103
+ if "error" in result:
104
+ typer.echo(result["error"])
105
+ return
106
+
107
+ if not plain:
108
+ console.print("[bold]Log Summary:[/]")
109
+ else:
110
+ typer.echo("Log Summary:")
111
+ typer.echo(result["summary"])
112
+ display.analysis(
113
+ console,
114
+ result["summary"],
115
+ result.get("issues", []),
116
+ result.get("ai_analysis"),
117
+ plain,
118
+ show_summary=not plain,
119
+ ai_label="AI Analysis:",
120
+ )
121
+ if (
122
+ _prompts_enabled(no_prompt)
123
+ and result.get("log")
124
+ and result["log"].get("command")
125
+ and display.prompt_rerun(console)
126
+ ):
127
+ edited = display.prompt_edit_command(console, result["log"]["command"])
128
+ if edited is not None:
129
+ capture_and_run(
130
+ edited,
131
+ use_ai=ai,
132
+ provider=provider,
133
+ model=model,
134
+ no_color=no_color,
135
+ )
136
+
137
+
138
+ @app.command()
139
+ def list(
140
+ no_color: bool = typer.Option(False, "--no-color", help=_NO_COLOR_HELP),
141
+ ):
142
+ """
143
+ List all captured logs.
144
+ """
145
+ log_dir = get_log_dir()
146
+ entries = []
147
+ for name in list_logs():
148
+ entry = {"file": name, "command": None, "exit_code": None}
149
+ try:
150
+ with open(os.path.join(log_dir, name)) as f:
151
+ log = json.load(f)
152
+ entry["command"] = log.get("command")
153
+ entry["exit_code"] = log.get("exit_code")
154
+ except (OSError, ValueError):
155
+ pass
156
+ entries.append(entry)
157
+ console = display.get_console(no_color=no_color)
158
+ display.log_table(console, entries, not display.use_rich(console))
159
+
160
+
161
+ @app.command()
162
+ def prune(
163
+ keep: int = typer.Option(50, "--keep", help="Keep the newest N eligible logs."),
164
+ older_than: float = typer.Option(
165
+ None, "--older-than", help="Only delete logs older than N days."
166
+ ),
167
+ yes: bool = typer.Option(False, "--yes", "-y", help="Delete without asking."),
168
+ no_color: bool = typer.Option(False, "--no-color", help=_NO_COLOR_HELP),
169
+ ):
170
+ """
171
+ Delete old logs (by count and/or age). Asks first unless --yes.
172
+ """
173
+ preview = prune_logs(keep=keep, older_than_days=older_than, dry_run=True)
174
+ doomed = preview["doomed"]
175
+ console = display.get_console(no_color=no_color)
176
+ plain = not display.use_rich(console)
177
+ if not doomed:
178
+ if plain:
179
+ typer.echo("Nothing to prune.")
180
+ else:
181
+ console.print("[dim]Nothing to prune.[/]")
182
+ return
183
+ if plain:
184
+ typer.echo(f"Would delete {len(doomed)} log(s):")
185
+ for name in doomed:
186
+ typer.echo(f" {name}")
187
+ else:
188
+ console.print(f"[bold]Would delete {len(doomed)} log(s):[/]")
189
+ for name in doomed:
190
+ console.print(f" [cyan]{name}[/]")
191
+ if not yes:
192
+ try:
193
+ confirmed = display.interactive(console) and typer.confirm(
194
+ "Delete these logs?", default=False
195
+ )
196
+ except Exception:
197
+ confirmed = False
198
+ if not confirmed:
199
+ typer.echo("Aborted.")
200
+ return
201
+ result = prune_logs(keep=keep, older_than_days=older_than)
202
+ typer.echo(f"Deleted {len(result['deleted'])} log(s), {result['kept']} kept.")
203
+
204
+
205
+ if __name__ == "__main__":
206
+ app()
logwise/paths.py ADDED
@@ -0,0 +1,34 @@
1
+ """Filesystem locations for LogWise runtime data.
2
+
3
+ ``get_log_dir()`` resolves per call (not at import time) so library users
4
+ and tests that change cwd get the right directory.
5
+
6
+ Resolution: ``LOGWISE_LOG_DIR`` env var, else ``logs/`` under the current
7
+ working directory. Cwd-based (not package-dir-based): a pip-installed
8
+ ``logwise`` must never write into ``site-packages``.
9
+ """
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+
15
+ def get_log_dir() -> Path:
16
+ """Return the directory where failure logs are stored."""
17
+ override = os.getenv("LOGWISE_LOG_DIR")
18
+ if override:
19
+ return Path(override).expanduser()
20
+ return Path.cwd() / "logs"
21
+
22
+
23
+ def ensure_log_dir() -> Path:
24
+ """Best-effort creation of the log dir (never raises).
25
+
26
+ Called once at CLI startup so `list` works before any failure.
27
+ Read-only locations are silently ignored — commands cope already.
28
+ """
29
+ log_dir = get_log_dir()
30
+ try:
31
+ log_dir.mkdir(parents=True, exist_ok=True)
32
+ except OSError:
33
+ pass
34
+ return log_dir
logwise/providers.py ADDED
@@ -0,0 +1,83 @@
1
+ """Provider registry for LogWise AI analysis.
2
+
3
+ Every provider here speaks the OpenAI-compatible
4
+ ``POST {base_url}/chat/completions`` API — including Gemini (via its
5
+ OpenAI-compat endpoint) and local servers like Ollama. Adding a new
6
+ vendor is data-only: one dict entry, zero new dependencies.
7
+ """
8
+
9
+ import os
10
+
11
+ PROVIDERS = {
12
+ "gemini": {
13
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
14
+ "key_env": "GEMINI_API_KEY",
15
+ "default_model": "gemini-2.0-flash",
16
+ },
17
+ "openai": {
18
+ "base_url": "https://api.openai.com/v1",
19
+ "key_env": "OPENAI_API_KEY",
20
+ "default_model": "gpt-4o-mini",
21
+ },
22
+ "deepseek": {
23
+ "base_url": "https://api.deepseek.com/v1",
24
+ "key_env": "DEEPSEEK_API_KEY",
25
+ "default_model": "deepseek-chat",
26
+ },
27
+ "groq": {
28
+ "base_url": "https://api.groq.com/openai/v1",
29
+ "key_env": "GROQ_API_KEY",
30
+ "default_model": "llama-3.3-70b-versatile",
31
+ },
32
+ "openrouter": {
33
+ "base_url": "https://openrouter.ai/api/v1",
34
+ "key_env": "OPENROUTER_API_KEY",
35
+ "default_model": "openai/gpt-4o-mini",
36
+ },
37
+ # Local, keyless. Requires `ollama serve` + a pulled model.
38
+ "ollama": {
39
+ "base_url": "http://localhost:11434/v1",
40
+ "key_env": None,
41
+ "default_model": "llama3.1",
42
+ },
43
+ }
44
+
45
+ DEFAULT_PROVIDER = "gemini"
46
+
47
+
48
+ def resolve_provider(name: str | None) -> tuple[str, dict]:
49
+ """Return (canonical_name, config). Raises ValueError on unknown name."""
50
+ raw = (name or os.getenv("LOGWISE_PROVIDER") or DEFAULT_PROVIDER).strip().lower()
51
+ if raw not in PROVIDERS:
52
+ raise ValueError(
53
+ f"Unknown provider '{raw}'. Valid options: {', '.join(sorted(PROVIDERS))}."
54
+ )
55
+ return raw, PROVIDERS[raw]
56
+
57
+
58
+ def resolve_model(config: dict, model: str | None) -> str:
59
+ """CLI flag → LOGWISE_MODEL env → provider default."""
60
+ return (model or os.getenv("LOGWISE_MODEL") or config["default_model"]).strip()
61
+
62
+
63
+ def resolve_base_url(config: dict) -> str:
64
+ """LOGWISE_BASE_URL env overrides the provider default (custom servers)."""
65
+ return (os.getenv("LOGWISE_BASE_URL") or config["base_url"]).rstrip("/")
66
+
67
+
68
+ def resolve_api_key(canonical_name: str, config: dict) -> str | None:
69
+ """LOGWISE_API_KEY env overrides the provider-specific key env.
70
+
71
+ Returns None for keyless providers (e.g. ollama) or when no key is set
72
+ (caller turns that into a clean error message).
73
+ """
74
+ if config["key_env"] is None:
75
+ return os.getenv("LOGWISE_API_KEY") # optional even for local servers
76
+ return os.getenv("LOGWISE_API_KEY") or os.getenv(config["key_env"])
77
+
78
+
79
+ def key_hint(canonical_name: str, config: dict) -> str:
80
+ """Human-readable hint for the missing-key error message."""
81
+ if config["key_env"] is None:
82
+ return "no API key needed (local server)"
83
+ return f"Set env var {config['key_env']} (or generic LOGWISE_API_KEY)"
logwise/rules.py ADDED
@@ -0,0 +1,42 @@
1
+ ERROR_RULES = [
2
+ {
3
+ "id": "non_zero_exit",
4
+ "condition": lambda log: log["exit_code"] != 0,
5
+ "description": "Command failed with non-zero exit code.",
6
+ "root_cause": (
7
+ "Possible reasons: Invalid arguments, missing dependencies, "
8
+ "or runtime errors. Check stderr for details."
9
+ ),
10
+ "fixes": "Verify command syntax, install missing packages, or debug the script.",
11
+ },
12
+ {
13
+ "id": "permission_denied",
14
+ "condition": lambda log: "permission denied" in log["stderr"].lower(),
15
+ "description": "Permission denied error detected.",
16
+ "root_cause": "Insufficient permissions.",
17
+ "fixes": "Run with sudo, change file ownership (chown), or adjust permissions (chmod).",
18
+ },
19
+ {
20
+ "id": "file_not_found",
21
+ "condition": lambda log: "no such file or directory" in log["stderr"].lower(),
22
+ "description": "File or directory not found.",
23
+ "root_cause": "Path issue.",
24
+ "fixes": "Check if the file exists (ls), correct the path, or create the missing item.",
25
+ },
26
+ # Add more as needed
27
+ ]
28
+
29
+
30
+ def apply_rules(log: dict) -> list:
31
+ issues = []
32
+ for rule in ERROR_RULES:
33
+ if rule["condition"](log):
34
+ issues.append(
35
+ {
36
+ "rule_id": rule["id"],
37
+ "description": rule["description"],
38
+ "root_cause": rule["root_cause"],
39
+ "fixes": rule["fixes"],
40
+ }
41
+ )
42
+ return issues