screenforge 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.
- cli/__init__.py +0 -0
- cli/_version.py +1 -0
- cli/dispatch.py +266 -0
- cli/doctor.py +487 -0
- cli/modes/__init__.py +0 -0
- cli/modes/action.py +262 -0
- cli/modes/default.py +248 -0
- cli/modes/demo.py +162 -0
- cli/modes/dry_run.py +237 -0
- cli/modes/init.py +133 -0
- cli/modes/plan.py +148 -0
- cli/modes/workflow.py +354 -0
- cli/parser.py +305 -0
- cli/reporter.py +207 -0
- cli/session.py +146 -0
- cli/shared.py +427 -0
- cli/shorthand.py +90 -0
- cli/tool_protocol_handlers.py +446 -0
- common/__init__.py +0 -0
- common/adapters/__init__.py +21 -0
- common/adapters/android_adapter.py +273 -0
- common/adapters/base_adapter.py +24 -0
- common/adapters/ios_adapter.py +278 -0
- common/adapters/web_adapter.py +271 -0
- common/ai.py +277 -0
- common/ai_autonomous.py +273 -0
- common/ai_heal.py +222 -0
- common/cache/__init__.py +15 -0
- common/cache/cache_hash.py +57 -0
- common/cache/cache_manager.py +300 -0
- common/cache/cache_stats.py +133 -0
- common/cache/cache_storage.py +79 -0
- common/cache/embedding_loader.py +150 -0
- common/capabilities.py +121 -0
- common/case_memory.py +327 -0
- common/error_codes.py +61 -0
- common/exceptions.py +18 -0
- common/executor.py +1504 -0
- common/failure_diagnosis.py +138 -0
- common/history_manager.py +75 -0
- common/logs.py +168 -0
- common/mcp_server.py +467 -0
- common/preflight.py +496 -0
- common/progress.py +37 -0
- common/run_reporter.py +415 -0
- common/run_resume.py +149 -0
- common/runtime_modes.py +35 -0
- common/tool_protocol.py +196 -0
- common/visual_fallback.py +71 -0
- common/workflow_schema.py +150 -0
- config/__init__.py +0 -0
- config/config.py +167 -0
- config/env_loader.py +76 -0
- screenforge-0.4.0.dist-info/METADATA +43 -0
- screenforge-0.4.0.dist-info/RECORD +64 -0
- screenforge-0.4.0.dist-info/WHEEL +5 -0
- screenforge-0.4.0.dist-info/entry_points.txt +2 -0
- screenforge-0.4.0.dist-info/licenses/LICENSE +21 -0
- screenforge-0.4.0.dist-info/top_level.txt +4 -0
- utils/__init__.py +0 -0
- utils/screenshot_annotator.py +60 -0
- utils/utils_ios.py +195 -0
- utils/utils_web.py +304 -0
- utils/utils_xml.py +218 -0
cli/doctor.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""Doctor mode: environment health checks and remediation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import cli.shared as _shared
|
|
9
|
+
from cli.reporter import _build_reporter, _emit_run_started
|
|
10
|
+
from cli.shared import (
|
|
11
|
+
_ensure_preflight_runner,
|
|
12
|
+
config,
|
|
13
|
+
log,
|
|
14
|
+
)
|
|
15
|
+
from common.capabilities import get_capabilities_payload
|
|
16
|
+
from common.runtime_modes import MODE_DOCTOR
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _render_rich_doctor_table(checks: list[dict]) -> None:
|
|
20
|
+
"""Display doctor results as a Rich table when running interactively."""
|
|
21
|
+
if not sys.stderr.isatty():
|
|
22
|
+
return
|
|
23
|
+
try:
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
from rich.table import Table
|
|
26
|
+
except ImportError:
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
console = Console(stderr=True)
|
|
30
|
+
table = Table(title="ScreenForge Doctor", show_lines=True)
|
|
31
|
+
table.add_column("Check", style="bold")
|
|
32
|
+
table.add_column("Status", justify="center")
|
|
33
|
+
table.add_column("Details")
|
|
34
|
+
|
|
35
|
+
for check in checks:
|
|
36
|
+
name = check.get("name", "unknown")
|
|
37
|
+
ok = check.get("ok", False)
|
|
38
|
+
advisory = check.get("advisory", False)
|
|
39
|
+
# An advisory finding (ok=False but advisory) is a NOTE, not a failure —
|
|
40
|
+
# render it yellow so it never reads as a broken environment.
|
|
41
|
+
if ok:
|
|
42
|
+
status = "[green]PASS[/]"
|
|
43
|
+
elif advisory:
|
|
44
|
+
status = "[yellow]NOTE[/]"
|
|
45
|
+
else:
|
|
46
|
+
status = "[red]FAIL[/]"
|
|
47
|
+
details = ""
|
|
48
|
+
if not ok:
|
|
49
|
+
issues = check.get("issues", []) or []
|
|
50
|
+
errors = check.get("errors", []) or []
|
|
51
|
+
error = check.get("error", "")
|
|
52
|
+
hint = check.get("hint", "")
|
|
53
|
+
parts = [str(i).strip() for i in issues + errors if str(i).strip()]
|
|
54
|
+
if error and str(error).strip():
|
|
55
|
+
parts.append(str(error).strip())
|
|
56
|
+
if hint and str(hint).strip():
|
|
57
|
+
parts.append(f"[dim]{hint}[/dim]")
|
|
58
|
+
details = "\n".join(parts[:3])
|
|
59
|
+
else:
|
|
60
|
+
if check.get("path"):
|
|
61
|
+
details = f"[dim]{check['path']}[/dim]"
|
|
62
|
+
table.add_row(name, status, details)
|
|
63
|
+
|
|
64
|
+
console.print(table)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _normalize_doctor_message(message: str) -> str:
|
|
68
|
+
lines = [line.strip() for line in str(message).splitlines() if line.strip()]
|
|
69
|
+
return lines[0] if lines else ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _iter_doctor_check_findings(check: dict):
|
|
73
|
+
for issue in check.get("issues", []) or []:
|
|
74
|
+
text = _normalize_doctor_message(issue)
|
|
75
|
+
if text:
|
|
76
|
+
yield "issue", text
|
|
77
|
+
|
|
78
|
+
for error in check.get("errors", []) or []:
|
|
79
|
+
text = _normalize_doctor_message(error)
|
|
80
|
+
if text:
|
|
81
|
+
yield "error", text
|
|
82
|
+
|
|
83
|
+
error_text = _normalize_doctor_message(check.get("error", ""))
|
|
84
|
+
if error_text:
|
|
85
|
+
yield "error", error_text
|
|
86
|
+
|
|
87
|
+
hint_text = _normalize_doctor_message(check.get("hint", ""))
|
|
88
|
+
if hint_text:
|
|
89
|
+
yield "hint", hint_text
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _classify_doctor_check(check: dict) -> dict:
|
|
93
|
+
check_name = str(check.get("name", "")).strip()
|
|
94
|
+
|
|
95
|
+
if check_name == "config":
|
|
96
|
+
return {"category": "config", "title": "Configuration", "priority": 1}
|
|
97
|
+
if check_name in {"venv_consistency", "runtime_paths"}:
|
|
98
|
+
return {"category": "runtime", "title": "Runtime", "priority": 2}
|
|
99
|
+
if check_name in {"adb", "uiautomator2", "wda", "playwright"}:
|
|
100
|
+
return {"category": "dependency", "title": "Dependency", "priority": 3}
|
|
101
|
+
if check_name in {
|
|
102
|
+
"adb_devices",
|
|
103
|
+
"wda_status",
|
|
104
|
+
"cdp_debug_endpoint",
|
|
105
|
+
"http://localhost:8100",
|
|
106
|
+
"http://localhost:9222",
|
|
107
|
+
} or check_name.startswith(("http://", "https://")):
|
|
108
|
+
return {"category": "connectivity", "title": "Connectivity", "priority": 4}
|
|
109
|
+
if check_name == "orphan_web_browser":
|
|
110
|
+
return {"category": "cleanup", "title": "Cleanup", "priority": 5}
|
|
111
|
+
return {"category": "other", "title": "Other", "priority": 6}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _doctor_fix_doc_reference(doc_name: str, section: str) -> dict:
|
|
115
|
+
return {
|
|
116
|
+
"fix_doc": doc_name,
|
|
117
|
+
"fix_doc_section": section,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _build_doctor_remediation(check_name: str, message: str) -> dict:
|
|
122
|
+
message = str(message).strip()
|
|
123
|
+
normalized_check_name = str(check_name).strip()
|
|
124
|
+
common_doc = _doctor_fix_doc_reference(
|
|
125
|
+
"docs/agent_guide.md", "Troubleshooting"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
remediation = {
|
|
129
|
+
"fix_label": "See diagnostics docs",
|
|
130
|
+
"fix_command": "",
|
|
131
|
+
**common_doc,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if normalized_check_name == "config":
|
|
135
|
+
return {
|
|
136
|
+
"fix_label": "Complete runtime configuration",
|
|
137
|
+
"fix_command": "",
|
|
138
|
+
**_doctor_fix_doc_reference("docs/agent_guide.md", "Troubleshooting"),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if normalized_check_name == "venv_consistency":
|
|
142
|
+
return {
|
|
143
|
+
"fix_label": "Repair venv entry-point drift",
|
|
144
|
+
"fix_command": "./.venv/bin/python scripts/repair_venv.py",
|
|
145
|
+
**_doctor_fix_doc_reference("docs/agent_guide.md", "Troubleshooting"),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if normalized_check_name == "runtime_paths":
|
|
149
|
+
return {
|
|
150
|
+
"fix_label": "Verify working directory is writable",
|
|
151
|
+
"fix_command": "",
|
|
152
|
+
**_doctor_fix_doc_reference("docs/agent_guide.md", "Troubleshooting"),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if normalized_check_name == "uiautomator2":
|
|
156
|
+
return {
|
|
157
|
+
"fix_label": "Install Android extras",
|
|
158
|
+
"fix_command": 'pip install -e ".[android]"',
|
|
159
|
+
**_doctor_fix_doc_reference("README.md", "Platform-specific extras"),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if normalized_check_name == "playwright":
|
|
163
|
+
return {
|
|
164
|
+
"fix_label": "Install web dependencies (default extra)",
|
|
165
|
+
"fix_command": "pip install -e .",
|
|
166
|
+
**_doctor_fix_doc_reference("README.md", "Platform-specific extras"),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if normalized_check_name == "wda":
|
|
170
|
+
return {
|
|
171
|
+
"fix_label": "Install iOS extras",
|
|
172
|
+
"fix_command": 'pip install -e ".[ios]"',
|
|
173
|
+
**_doctor_fix_doc_reference("README.md", "Platform-specific extras"),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if normalized_check_name == "adb":
|
|
177
|
+
return {
|
|
178
|
+
"fix_label": "Install adb and add to PATH",
|
|
179
|
+
"fix_command": "",
|
|
180
|
+
**_doctor_fix_doc_reference("docs/agent_guide.md", "Troubleshooting"),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if normalized_check_name == "adb_devices":
|
|
184
|
+
if "environment restricts" in message or "host terminal" in message:
|
|
185
|
+
return {
|
|
186
|
+
"fix_label": "Retry adb check in host terminal",
|
|
187
|
+
"fix_command": "adb devices",
|
|
188
|
+
**_doctor_fix_doc_reference("docs/agent_guide.md", "Troubleshooting"),
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
"fix_label": "Check Android device connection",
|
|
192
|
+
"fix_command": "adb devices",
|
|
193
|
+
**_doctor_fix_doc_reference("docs/agent_guide.md", "Troubleshooting"),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if normalized_check_name in {"http://localhost:8100", "wda_status"}:
|
|
197
|
+
return {
|
|
198
|
+
"fix_label": "Verify WebDriverAgent service status",
|
|
199
|
+
"fix_command": "",
|
|
200
|
+
**_doctor_fix_doc_reference("docs/agent_guide.md", "Troubleshooting"),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if normalized_check_name == "orphan_web_browser":
|
|
204
|
+
return {
|
|
205
|
+
"fix_label": "Stop the leaked persistent Chromium",
|
|
206
|
+
"fix_command": "screenforge --web-stop",
|
|
207
|
+
**_doctor_fix_doc_reference("docs/agent_guide.md", "Troubleshooting"),
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if normalized_check_name in {"http://localhost:9222", "cdp_debug_endpoint"}:
|
|
211
|
+
if "environment restricts" in message or "host terminal" in message:
|
|
212
|
+
return {
|
|
213
|
+
"fix_label": "Check Chrome DevTools port from host terminal",
|
|
214
|
+
"fix_command": "curl -sS http://localhost:9222/json/version",
|
|
215
|
+
**_doctor_fix_doc_reference("docs/agent_guide.md", "Troubleshooting"),
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
"fix_label": "Verify Chrome DevTools debug port",
|
|
219
|
+
"fix_command": "",
|
|
220
|
+
**_doctor_fix_doc_reference("docs/agent_guide.md", "Troubleshooting"),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if "OPENAI_API_KEY" in message or "WEB_CDP_URL" in message:
|
|
224
|
+
return {
|
|
225
|
+
"fix_label": "Complete runtime configuration",
|
|
226
|
+
"fix_command": "",
|
|
227
|
+
**_doctor_fix_doc_reference("docs/agent_guide.md", "Troubleshooting"),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return remediation
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _doctor_action_signature(category: str, item: dict) -> tuple:
|
|
234
|
+
return (
|
|
235
|
+
category,
|
|
236
|
+
tuple(item.get("check_names", [])),
|
|
237
|
+
item.get("fix_label", ""),
|
|
238
|
+
item.get("fix_command", ""),
|
|
239
|
+
item.get("fix_doc", ""),
|
|
240
|
+
item.get("fix_doc_section", ""),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _append_recommended_action(actions: list[dict], category: str, priority: int, item: dict) -> None:
|
|
245
|
+
candidate = {
|
|
246
|
+
"category": category,
|
|
247
|
+
"priority": priority,
|
|
248
|
+
**item,
|
|
249
|
+
}
|
|
250
|
+
candidate_signature = _doctor_action_signature(category, item)
|
|
251
|
+
|
|
252
|
+
for index, existing in enumerate(actions):
|
|
253
|
+
existing_signature = _doctor_action_signature(existing.get("category", ""), existing)
|
|
254
|
+
if existing_signature != candidate_signature:
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
if candidate.get("kind") == "hint":
|
|
258
|
+
actions[index] = candidate
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
if existing.get("kind") == "hint":
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
actions.append(candidate)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _build_doctor_summary(checks: list[dict]) -> dict:
|
|
268
|
+
groups = {}
|
|
269
|
+
advisories = []
|
|
270
|
+
severity_rank = {"error": 0, "issue": 1, "hint": 2}
|
|
271
|
+
|
|
272
|
+
for check in checks:
|
|
273
|
+
if check.get("ok", False):
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
# Advisory findings (e.g. a healthy persistent browser) are NOTES, not
|
|
277
|
+
# blockers: collect them separately so they never affect summary["ok"]
|
|
278
|
+
# or sort among real failures, but the remediation note still reaches
|
|
279
|
+
# the user (surfaced even on the success path).
|
|
280
|
+
if check.get("advisory", False):
|
|
281
|
+
check_name = str(check.get("name", "unknown")).strip() or "unknown"
|
|
282
|
+
# One note per advisory check: the error is the "what", the
|
|
283
|
+
# remediation carries the "how" (fix_command). The hint largely
|
|
284
|
+
# restates the remediation, so don't emit it as a second line.
|
|
285
|
+
message = _normalize_doctor_message(check.get("error", "")) or next(
|
|
286
|
+
(m for _k, m in _iter_doctor_check_findings(check)), ""
|
|
287
|
+
)
|
|
288
|
+
if message:
|
|
289
|
+
remediation = _build_doctor_remediation(check_name, message)
|
|
290
|
+
advisories.append(
|
|
291
|
+
{
|
|
292
|
+
"message": message,
|
|
293
|
+
"check_names": [check_name],
|
|
294
|
+
"fix_label": remediation.get("fix_label", ""),
|
|
295
|
+
"fix_command": remediation.get("fix_command", ""),
|
|
296
|
+
"fix_doc": remediation.get("fix_doc", ""),
|
|
297
|
+
"fix_doc_section": remediation.get("fix_doc_section", ""),
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
group_meta = _classify_doctor_check(check)
|
|
303
|
+
category = group_meta["category"]
|
|
304
|
+
group = groups.setdefault(
|
|
305
|
+
category,
|
|
306
|
+
{
|
|
307
|
+
"category": category,
|
|
308
|
+
"title": group_meta["title"],
|
|
309
|
+
"priority": group_meta["priority"],
|
|
310
|
+
"items": [],
|
|
311
|
+
"_item_map": {},
|
|
312
|
+
},
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
check_name = str(check.get("name", "unknown")).strip() or "unknown"
|
|
316
|
+
for kind, message in _iter_doctor_check_findings(check):
|
|
317
|
+
remediation = _build_doctor_remediation(check_name, message)
|
|
318
|
+
existing = group["_item_map"].get(message)
|
|
319
|
+
if existing:
|
|
320
|
+
if check_name not in existing["check_names"]:
|
|
321
|
+
existing["check_names"].append(check_name)
|
|
322
|
+
if severity_rank[kind] < severity_rank[existing["kind"]]:
|
|
323
|
+
existing["kind"] = kind
|
|
324
|
+
if not existing["fix_command"] and remediation.get("fix_command", ""):
|
|
325
|
+
existing["fix_command"] = remediation.get("fix_command", "")
|
|
326
|
+
if existing.get("fix_doc_section", "") == "Troubleshooting":
|
|
327
|
+
existing["fix_label"] = remediation.get("fix_label", existing["fix_label"])
|
|
328
|
+
existing["fix_doc"] = remediation.get("fix_doc", existing["fix_doc"])
|
|
329
|
+
existing["fix_doc_section"] = remediation.get(
|
|
330
|
+
"fix_doc_section", existing["fix_doc_section"]
|
|
331
|
+
)
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
item = {
|
|
335
|
+
"message": message,
|
|
336
|
+
"kind": kind,
|
|
337
|
+
"check_names": [check_name],
|
|
338
|
+
"fix_label": remediation.get("fix_label", ""),
|
|
339
|
+
"fix_command": remediation.get("fix_command", ""),
|
|
340
|
+
"fix_doc": remediation.get("fix_doc", ""),
|
|
341
|
+
"fix_doc_section": remediation.get("fix_doc_section", ""),
|
|
342
|
+
"fix_priority": group["priority"],
|
|
343
|
+
}
|
|
344
|
+
group["_item_map"][message] = item
|
|
345
|
+
group["items"].append(item)
|
|
346
|
+
|
|
347
|
+
ordered_groups = sorted(
|
|
348
|
+
groups.values(),
|
|
349
|
+
key=lambda item: (item["priority"], item["category"]),
|
|
350
|
+
)
|
|
351
|
+
for group in ordered_groups:
|
|
352
|
+
group.pop("_item_map", None)
|
|
353
|
+
|
|
354
|
+
top_items = []
|
|
355
|
+
recommended_actions = []
|
|
356
|
+
for group in ordered_groups:
|
|
357
|
+
for item in group["items"]:
|
|
358
|
+
top_items.append(item["message"])
|
|
359
|
+
_append_recommended_action(
|
|
360
|
+
recommended_actions,
|
|
361
|
+
group["category"],
|
|
362
|
+
group["priority"],
|
|
363
|
+
item,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
"ok": not ordered_groups,
|
|
368
|
+
"group_count": len(ordered_groups),
|
|
369
|
+
"top_items": top_items,
|
|
370
|
+
"groups": ordered_groups,
|
|
371
|
+
"recommended_actions": recommended_actions,
|
|
372
|
+
"advisories": advisories,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _build_doctor_remediation_items(checks: list[dict]) -> list[str]:
|
|
377
|
+
return _build_doctor_summary(checks).get("top_items", [])
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _build_doctor_check_failure_message(check: dict) -> str:
|
|
381
|
+
details = []
|
|
382
|
+
|
|
383
|
+
def add_detail(message: str) -> None:
|
|
384
|
+
text = _normalize_doctor_message(message)
|
|
385
|
+
if text and text not in details:
|
|
386
|
+
details.append(text)
|
|
387
|
+
|
|
388
|
+
for issue in check.get("issues", []) or []:
|
|
389
|
+
add_detail(issue)
|
|
390
|
+
|
|
391
|
+
for error in check.get("errors", []) or []:
|
|
392
|
+
add_detail(error)
|
|
393
|
+
|
|
394
|
+
add_detail(check.get("error", ""))
|
|
395
|
+
|
|
396
|
+
if not details:
|
|
397
|
+
if "path" in check and not str(check.get("path", "")).strip():
|
|
398
|
+
details.append("Executable not found")
|
|
399
|
+
elif check.get("name") == "runtime_paths":
|
|
400
|
+
details.append("Runtime directory unavailable or not writable")
|
|
401
|
+
else:
|
|
402
|
+
details.append("Check failed")
|
|
403
|
+
|
|
404
|
+
return f" - {check.get('name', 'unknown')}: {'; '.join(details)}"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def run_doctor_mode(args, output_script_path: str) -> int:
|
|
408
|
+
reporter = _build_reporter(args, output_script_path, MODE_DOCTOR)
|
|
409
|
+
final_status = "failed"
|
|
410
|
+
exit_code = 1
|
|
411
|
+
final_error = ""
|
|
412
|
+
_emit_run_started(reporter, args, output_script_path, MODE_DOCTOR)
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
_ensure_preflight_runner()
|
|
416
|
+
result = _shared.run_preflight(
|
|
417
|
+
platform=args.platform,
|
|
418
|
+
script_dir=Path(os.path.dirname(os.path.abspath(output_script_path))),
|
|
419
|
+
run_dir=Path(config.RUN_REPORT_BASE_DIR),
|
|
420
|
+
)
|
|
421
|
+
for check in result.get("checks", []):
|
|
422
|
+
reporter.emit_event(
|
|
423
|
+
"doctor_check",
|
|
424
|
+
check_name=check.get("name", ""),
|
|
425
|
+
success=check.get("ok", False),
|
|
426
|
+
detail=check,
|
|
427
|
+
)
|
|
428
|
+
doctor_summary = _build_doctor_summary(result.get("checks", []))
|
|
429
|
+
reporter.update_summary(doctor_summary=doctor_summary)
|
|
430
|
+
reporter.emit_event(
|
|
431
|
+
"doctor_summary",
|
|
432
|
+
ok=doctor_summary.get("ok", False),
|
|
433
|
+
group_count=doctor_summary.get("group_count", 0),
|
|
434
|
+
top_items=doctor_summary.get("top_items", []),
|
|
435
|
+
groups=doctor_summary.get("groups", []),
|
|
436
|
+
recommended_actions=doctor_summary.get("recommended_actions", []),
|
|
437
|
+
advisories=doctor_summary.get("advisories", []),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
_render_rich_doctor_table(result.get("checks", []))
|
|
441
|
+
|
|
442
|
+
if result.get("ok"):
|
|
443
|
+
log.info("🩺 [Doctor] Environment check passed.")
|
|
444
|
+
final_status = "success"
|
|
445
|
+
exit_code = 0
|
|
446
|
+
else:
|
|
447
|
+
final_error = "Doctor check failed"
|
|
448
|
+
log.error("❌ [Doctor] Environment check failed. Fix prerequisites first.")
|
|
449
|
+
for check in result.get("checks", []):
|
|
450
|
+
# Advisory findings are NOTES, not failures — keep them out of
|
|
451
|
+
# the error list (they're surfaced below regardless of verdict).
|
|
452
|
+
if not check.get("ok", False) and not check.get("advisory", False):
|
|
453
|
+
log.error(_build_doctor_check_failure_message(check))
|
|
454
|
+
remediation_items = doctor_summary.get("recommended_actions", [])
|
|
455
|
+
if remediation_items:
|
|
456
|
+
log.error("🧭 [Doctor] Recommended actions:")
|
|
457
|
+
for index, item in enumerate(remediation_items, start=1):
|
|
458
|
+
log.error(f" {index}. {item.get('message', '')}")
|
|
459
|
+
if item.get("fix_command"):
|
|
460
|
+
log.error(f" Run: {item.get('fix_command', '')}")
|
|
461
|
+
if item.get("fix_doc"):
|
|
462
|
+
log.error(
|
|
463
|
+
" Docs: "
|
|
464
|
+
f"{item.get('fix_doc', '')} ({item.get('fix_doc_section', '')})"
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
# Advisory notes surface on BOTH paths (a healthy env can still have a
|
|
468
|
+
# leftover browser worth reclaiming). They never affect status/exit code.
|
|
469
|
+
for note in doctor_summary.get("advisories", []):
|
|
470
|
+
log.info(f"💡 [Doctor] Note: {note.get('message', '')}")
|
|
471
|
+
if note.get("fix_command"):
|
|
472
|
+
log.info(f" Run: {note.get('fix_command', '')}")
|
|
473
|
+
finally:
|
|
474
|
+
reporter.finalize(
|
|
475
|
+
status=final_status,
|
|
476
|
+
exit_code=exit_code,
|
|
477
|
+
steps_executed=len(result.get("checks", [])) if "result" in locals() else 0,
|
|
478
|
+
last_error=final_error,
|
|
479
|
+
)
|
|
480
|
+
return exit_code
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def run_capabilities_mode(args) -> int:
|
|
484
|
+
payload = get_capabilities_payload()
|
|
485
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
486
|
+
sys.stdout.flush()
|
|
487
|
+
return 0
|
cli/modes/__init__.py
ADDED
|
File without changes
|