opspilot-ai 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.
opspilot/cli.py ADDED
@@ -0,0 +1,360 @@
1
+ import typer
2
+ from rich.console import Console
3
+ from pathlib import Path
4
+ from opspilot.agents.planner import plan
5
+ import json
6
+ import os
7
+ from dotenv import load_dotenv
8
+ from opspilot.state import AgentState
9
+ from opspilot.config import load_config
10
+ from opspilot.context import collect_context
11
+ from opspilot.context.project import scan_project_tree
12
+ from opspilot.context.logs import read_logs
13
+ from opspilot.context.env import read_env
14
+ from opspilot.context.docker import read_docker_files
15
+ from opspilot.context.deps import read_dependencies
16
+ from opspilot.context.production_logs import auto_detect_and_fetch
17
+ from opspilot.context.deployment_history import analyze_deployment_impact, format_deployment_analysis, correlate_with_error_timeline
18
+ from opspilot.tools import collect_evidence
19
+ from opspilot.tools.log_tools import analyze_log_errors
20
+ from opspilot.utils.llm import check_any_llm_available, get_llm_router
21
+ from opspilot.tools.env_tools import find_missing_env
22
+ from opspilot.tools.dep_tools import has_dependency
23
+ from opspilot.agents.verifier import verify
24
+ from opspilot.agents.fixer import suggest
25
+ from opspilot.agents.remediation import generate_remediation_plan, format_remediation_output
26
+ from opspilot.diffs.redis import redis_timeout_diff, redis_pooling_diff
27
+ from opspilot.memory import save_memory
28
+ from opspilot.memory import find_similar_issues
29
+ # Redis memory backend (with fallback to file-based)
30
+ try:
31
+ from opspilot.memory_redis import get_memory_backend
32
+ redis_memory = get_memory_backend(
33
+ redis_host=os.getenv("REDIS_HOST", "localhost"),
34
+ redis_port=int(os.getenv("REDIS_PORT", "6379")),
35
+ fallback_to_file=True
36
+ )
37
+ except Exception:
38
+ redis_memory = None # Will use file-based fallback
39
+ from opspilot.graph.engine import run_agent
40
+ from opspilot.state import AgentState
41
+
42
+
43
+
44
+ # Load environment variables from .env file (searches in current directory and parents)
45
+ load_dotenv(verbose=False, override=False) # Don't override existing env vars
46
+
47
+ app = typer.Typer(help="OpsPilot - Agentic AI CLI for incident analysis")
48
+ console = Console()
49
+
50
+
51
+ @app.callback()
52
+ def main():
53
+ """
54
+ OpsPilot CLI entry point.
55
+ """
56
+ pass
57
+
58
+
59
+ @app.command()
60
+ def analyze(
61
+ mode: str = typer.Option(
62
+ "quick",
63
+ help="Execution mode: quick | deep | explain"
64
+ ),
65
+ output_json: bool = typer.Option(
66
+ False,
67
+ "--json",
68
+ help="Output machine-readable JSON"
69
+ ),
70
+ verbose: bool = typer.Option(
71
+ False,
72
+ "--verbose",
73
+ help="Show additional details"
74
+ ),
75
+ debug: bool = typer.Option(
76
+ False,
77
+ "--debug",
78
+ help="Show debug logs"
79
+ ),
80
+ log_source: str = typer.Option(
81
+ None,
82
+ "--log-source",
83
+ help="Production log source: URL, s3://bucket/key, k8s://namespace/pod, cw://log-group/stream, or file path"
84
+ ),
85
+ deployment_analysis: bool = typer.Option(
86
+ False,
87
+ "--deployment-analysis",
88
+ help="Analyze recent Git deployments for correlation"
89
+ ),
90
+ since_hours: int = typer.Option(
91
+ 24,
92
+ "--since-hours",
93
+ help="Hours to look back for deployment analysis (default: 24)"
94
+ ),
95
+ ):
96
+ """
97
+ Analyze the current project for runtime issues.
98
+ """
99
+ if mode not in {"quick", "deep", "explain"}:
100
+ raise typer.BadParameter("Mode must be quick, deep, or explain")
101
+
102
+ try:
103
+ # Check LLM availability (any provider)
104
+ if not check_any_llm_available():
105
+ router = get_llm_router()
106
+ console.print(
107
+ "[red]ERROR:[/red] No LLM provider available.\n\n"
108
+ "[bold]Setup at least one FREE/open-source provider:[/bold]\n\n"
109
+ "1. [cyan]Ollama[/cyan] (local, 100% free & private)\n"
110
+ " curl -fsSL https://ollama.ai/install.sh | sh\n"
111
+ " ollama pull llama3\n\n"
112
+ "2. [cyan]Google Gemini[/cyan] (cloud, free tier)\n"
113
+ " Get key: https://aistudio.google.com/\n"
114
+ " export GOOGLE_API_KEY='your-key'\n\n"
115
+ "3. [cyan]OpenRouter[/cyan] (cloud, free models)\n"
116
+ " Get key: https://openrouter.ai/\n"
117
+ " export OPENROUTER_API_KEY='your-key'\n\n"
118
+ "4. [cyan]HuggingFace[/cyan] (cloud, free tier)\n"
119
+ " Get key: https://huggingface.co/settings/tokens\n"
120
+ " export HUGGINGFACE_API_KEY='your-key'"
121
+ )
122
+ raise typer.Exit(code=1)
123
+
124
+ # Show which provider will be used
125
+ router = get_llm_router()
126
+ available = router.get_available_providers()
127
+ console.print(f"[dim]LLM providers available: {', '.join(available)}[/dim]")
128
+
129
+ project_root = str(Path.cwd())
130
+
131
+ past = find_similar_issues(project_root)
132
+ if past:
133
+ console.print(
134
+ "[magenta]Similar issues detected from past runs:[/magenta]")
135
+ for p in past[-2:]:
136
+ console.print(
137
+ f"- {p['hypothesis']} (confidence {p['confidence']})")
138
+
139
+ state = AgentState(project_root=project_root)
140
+
141
+ if mode == "explain":
142
+ # No LLM at all
143
+ state.context = collect_context(project_root)
144
+ state.evidence = collect_evidence(state.context)
145
+
146
+ elif mode == "quick":
147
+ # One planner pass only
148
+ state.max_iterations = 1
149
+ state = run_agent(state)
150
+
151
+ elif mode == "deep":
152
+ # Full agent loop (default)
153
+ state = run_agent(state)
154
+
155
+ config = load_config(project_root)
156
+
157
+ console.print("[bold green]OpsPilot initialized[/bold green]")
158
+ console.print(
159
+ f"[bold green]Project detected[/bold green]: {project_root}")
160
+ console.print(f"Config loaded: {bool(config)}")
161
+
162
+ # Fetch production logs if specified
163
+ production_logs = None
164
+ if log_source:
165
+ console.print(f"[cyan]Fetching production logs from: {log_source}[/cyan]")
166
+ production_logs = auto_detect_and_fetch(log_source)
167
+ if production_logs:
168
+ console.print(f"[green]Successfully fetched {len(production_logs)} bytes of logs[/green]")
169
+ else:
170
+ console.print("[yellow]Warning: Could not fetch production logs[/yellow]")
171
+
172
+ state.context = {
173
+ "structure": scan_project_tree(project_root),
174
+ "logs": production_logs if production_logs else read_logs(project_root),
175
+ "env": read_env(project_root),
176
+ "docker": read_docker_files(project_root),
177
+ "dependencies": read_dependencies(project_root),
178
+ }
179
+ console.print("[cyan]Context collected:[/cyan]")
180
+ console.print(f"• Logs found: {bool(state.context['logs'])} ({'production' if production_logs else 'local'})")
181
+ console.print(f"• Env vars: {len(state.context['env'])}")
182
+ console.print(f"• Docker config: {bool(state.context['docker'])}")
183
+ console.print(
184
+ f"• Dependencies detected: {len(state.context['dependencies'])}")
185
+
186
+ console.print("[cyan]Planner Agent reasoning...[/cyan]")
187
+ console.print("[debug] entering planner")
188
+
189
+ with console.status("[cyan]Analyzing project context with LLM...[/cyan]", spinner="dots"):
190
+ plan_result = plan(state.context)
191
+ console.print("[debug] planner done")
192
+
193
+ state.hypothesis = plan_result.get("hypothesis")
194
+ state.confidence = plan_result.get("confidence")
195
+ state.checks_remaining = plan_result.get("required_checks", [])
196
+
197
+ if "error" in plan_result:
198
+ console.print("[bold red]⚠ Planner Error:[/bold red]", plan_result["error"])
199
+
200
+ console.print("[bold yellow]Hypothesis:[/bold yellow]", state.hypothesis)
201
+ console.print("[bold yellow]Confidence:[/bold yellow]", state.confidence)
202
+
203
+ console.print("[debug] collecting evidence")
204
+
205
+ # Use centralized evidence collection with pattern analysis
206
+ evidence = collect_evidence(state.context)
207
+
208
+ console.print("[debug] evidence done")
209
+
210
+ # Deployment correlation analysis
211
+ deployment_info = None
212
+ if deployment_analysis:
213
+ console.print("\n[cyan]Analyzing recent deployments...[/cyan]")
214
+ deployment_info = analyze_deployment_impact(project_root, since_hours)
215
+
216
+ if deployment_info and deployment_info.get("has_recent_changes"):
217
+ formatted_deployment = format_deployment_analysis(deployment_info)
218
+ console.print(formatted_deployment)
219
+
220
+ # Correlate with error timeline if available
221
+ if evidence.get("timeline") and evidence["timeline"].get("first_seen"):
222
+ correlation = correlate_with_error_timeline(
223
+ deployment_info.get("commits", []),
224
+ evidence["timeline"]["first_seen"]
225
+ )
226
+
227
+ if correlation.get("correlation") == "strong":
228
+ console.print("\n[bold red]DEPLOYMENT CORRELATION DETECTED![/bold red]")
229
+ console.print(f"[yellow]{correlation.get('reason')}[/yellow]")
230
+ console.print("\n[bold yellow]Suspicious Commits:[/bold yellow]")
231
+ for commit in correlation.get("suspicious_commits", [])[:3]:
232
+ console.print(f" [{commit['hash']}] {commit['message']}")
233
+ console.print(f" Time diff: {commit['time_diff_hours']}h before error")
234
+ else:
235
+ console.print("[green]No recent deployments detected[/green]")
236
+
237
+ # Display severity and error summary
238
+ if evidence.get("severity"):
239
+ severity = evidence["severity"]
240
+ severity_color = {
241
+ "P0": "bold red",
242
+ "P1": "bold orange1",
243
+ "P2": "bold yellow",
244
+ "P3": "bold blue"
245
+ }.get(severity, "white")
246
+
247
+ console.print(f"\n[{severity_color}]SEVERITY: {severity}[/{severity_color}]")
248
+
249
+ if evidence.get("error_count"):
250
+ console.print(f"[white]Total Errors: {evidence['error_count']}[/white]")
251
+
252
+ # Show timeline if available
253
+ if evidence.get("timeline"):
254
+ timeline = evidence["timeline"]
255
+ console.print(f"[white]First Seen: {timeline.get('first_seen', 'unknown')}[/white]")
256
+ console.print(f"[white]Occurrences: {timeline.get('total_occurrences', 0)}[/white]")
257
+
258
+ console.print("\n[cyan]Evidence collected:[/cyan]", evidence)
259
+
260
+ verdict = None
261
+ if state.hypothesis and evidence:
262
+ console.print("[debug] verifying")
263
+ console.print("[cyan]Verifying hypothesis...[/cyan]")
264
+ verdict = verify(state.hypothesis, evidence)
265
+ console.print("[debug] verification done")
266
+
267
+ console.print("[bold yellow]Supported:[/bold yellow]",
268
+ verdict["supported"])
269
+ console.print("[bold yellow]Confidence:[/bold yellow]",
270
+ verdict["confidence"])
271
+ console.print("[bold yellow]Reason:[/bold yellow]", verdict["reason"])
272
+ else:
273
+ console.print(
274
+ "[yellow]Not enough evidence to verify hypothesis[/yellow]")
275
+
276
+ CONFIDENCE_THRESHOLD = 0.6
277
+
278
+ if verdict and verdict.get("confidence", 0) >= CONFIDENCE_THRESHOLD and state.hypothesis:
279
+ console.print("[debug] suggesting fixes")
280
+ console.print(
281
+ "[cyan]Generating safe fix suggestions (dry-run)...[/cyan]")
282
+
283
+ suggestions = []
284
+
285
+ if evidence.get("uses_redis") and "Timeout" in evidence.get("log_errors", {}):
286
+ suggestions.append(redis_timeout_diff())
287
+ suggestions.append(redis_pooling_diff())
288
+
289
+ if not suggestions:
290
+ llm_suggestions = suggest(
291
+ state.hypothesis, evidence).get("suggestions", [])
292
+ suggestions.extend(llm_suggestions)
293
+
294
+ if suggestions:
295
+ for s in suggestions:
296
+ console.print(f"\n[bold]File:[/bold] {s['file']}")
297
+ console.print(f"[dim]{s['rationale']}[/dim]")
298
+ console.print(s["diff"])
299
+
300
+ # Generate and display remediation plan
301
+ console.print("\n[bold cyan]Generating Remediation Plan...[/bold cyan]")
302
+ remediation_plan = generate_remediation_plan(
303
+ state.hypothesis,
304
+ evidence,
305
+ suggestions
306
+ )
307
+ formatted_plan = format_remediation_output(remediation_plan)
308
+ console.print(formatted_plan)
309
+ else:
310
+ console.print("[yellow]No safe suggestions generated.[/yellow]")
311
+ console.print("[debug] fixer done")
312
+ else:
313
+ console.print(
314
+ "[yellow]Confidence too low — not generating fixes.[/yellow]")
315
+
316
+ save_memory({
317
+ "project": project_root,
318
+ "hypothesis": state.hypothesis,
319
+ "confidence": verdict.get("confidence") if verdict else 0.0,
320
+ "evidence": evidence
321
+ })
322
+ console.print("\n[bold green]Final Diagnosis Summary[/bold green]")
323
+ console.print(f"• Hypothesis: {state.hypothesis}")
324
+ console.print(f"• Confidence: {state.confidence}")
325
+ console.print(f"• Evidence signals: {list(state.evidence.keys())}")
326
+ console.print("• Suggested fixes: DRY-RUN ONLY")
327
+
328
+ result = {
329
+ "project": project_root,
330
+ "hypothesis": state.hypothesis,
331
+ "confidence": state.confidence,
332
+ "evidence": state.evidence,
333
+ "suggestions": state.suggestions,
334
+ }
335
+
336
+ if output_json:
337
+ print(json.dumps(result, indent=2))
338
+ return
339
+
340
+ if not state.hypothesis or state.confidence < 0.6:
341
+ console.print(
342
+ "[yellow]No confident diagnosis could be made. Evidence insufficient.[/yellow]"
343
+ )
344
+ return
345
+
346
+ except KeyboardInterrupt:
347
+ console.print("\n[yellow]Analysis interrupted by user[/yellow]")
348
+ raise typer.Exit(code=130)
349
+ except FileNotFoundError as e:
350
+ console.print(f"[red]ERROR:[/red] File not found: {e}")
351
+ if debug:
352
+ import traceback
353
+ console.print(traceback.format_exc())
354
+ raise typer.Exit(code=1)
355
+ except Exception as e:
356
+ console.print(f"[red]FATAL ERROR:[/red] Analysis failed: {e}")
357
+ if debug:
358
+ import traceback
359
+ console.print(traceback.format_exc())
360
+ raise typer.Exit(code=1)
opspilot/config.py ADDED
@@ -0,0 +1,22 @@
1
+ from pathlib import Path
2
+ from typing import Optional, Dict, Any
3
+ import json
4
+
5
+
6
+ def load_config(project_root: str) -> Optional[Dict[str, Any]]:
7
+ """
8
+ Load configuration from the project root directory.
9
+
10
+ Args:
11
+ project_root: The root directory of the project
12
+
13
+ Returns:
14
+ Configuration dictionary if found, None otherwise
15
+ """
16
+ config_path = Path(project_root) / ".opspilot.json"
17
+
18
+ if config_path.exists():
19
+ with open(config_path, 'r') as f:
20
+ return json.load(f)
21
+
22
+ return None
@@ -0,0 +1,26 @@
1
+ """Context gathering modules for OpsPilot."""
2
+
3
+ from opspilot.context.project import scan_project_tree
4
+ from opspilot.context.logs import read_logs
5
+ from opspilot.context.env import read_env
6
+ from opspilot.context.docker import read_docker_files
7
+ from opspilot.context.deps import read_dependencies
8
+
9
+
10
+ def collect_context(project_root: str) -> dict:
11
+ """
12
+ Collect all project context from various sources.
13
+
14
+ Args:
15
+ project_root: Root directory of the project
16
+
17
+ Returns:
18
+ Dictionary containing all context information
19
+ """
20
+ return {
21
+ "structure": scan_project_tree(project_root),
22
+ "logs": read_logs(project_root),
23
+ "env": read_env(project_root),
24
+ "docker": read_docker_files(project_root),
25
+ "dependencies": read_dependencies(project_root),
26
+ }