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/__init__.py +0 -0
- opspilot/agents/fixer.py +46 -0
- opspilot/agents/planner.py +74 -0
- opspilot/agents/remediation.py +200 -0
- opspilot/agents/verifier.py +67 -0
- opspilot/cli.py +360 -0
- opspilot/config.py +22 -0
- opspilot/context/__init__.py +26 -0
- opspilot/context/deployment_history.py +347 -0
- opspilot/context/deps.py +14 -0
- opspilot/context/docker.py +17 -0
- opspilot/context/env.py +17 -0
- opspilot/context/logs.py +16 -0
- opspilot/context/production_logs.py +262 -0
- opspilot/context/project.py +19 -0
- opspilot/diffs/redis.py +23 -0
- opspilot/graph/engine.py +33 -0
- opspilot/graph/nodes.py +41 -0
- opspilot/memory.py +24 -0
- opspilot/memory_redis.py +322 -0
- opspilot/state.py +18 -0
- opspilot/tools/__init__.py +52 -0
- opspilot/tools/dep_tools.py +5 -0
- opspilot/tools/env_tools.py +5 -0
- opspilot/tools/log_tools.py +11 -0
- opspilot/tools/pattern_analysis.py +194 -0
- opspilot/utils/__init__.py +1 -0
- opspilot/utils/llm.py +23 -0
- opspilot/utils/llm_providers.py +499 -0
- opspilot_ai-0.1.0.dist-info/METADATA +408 -0
- opspilot_ai-0.1.0.dist-info/RECORD +35 -0
- opspilot_ai-0.1.0.dist-info/WHEEL +5 -0
- opspilot_ai-0.1.0.dist-info/entry_points.txt +2 -0
- opspilot_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
- opspilot_ai-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
}
|