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.
Files changed (64) hide show
  1. cli/__init__.py +0 -0
  2. cli/_version.py +1 -0
  3. cli/dispatch.py +266 -0
  4. cli/doctor.py +487 -0
  5. cli/modes/__init__.py +0 -0
  6. cli/modes/action.py +262 -0
  7. cli/modes/default.py +248 -0
  8. cli/modes/demo.py +162 -0
  9. cli/modes/dry_run.py +237 -0
  10. cli/modes/init.py +133 -0
  11. cli/modes/plan.py +148 -0
  12. cli/modes/workflow.py +354 -0
  13. cli/parser.py +305 -0
  14. cli/reporter.py +207 -0
  15. cli/session.py +146 -0
  16. cli/shared.py +427 -0
  17. cli/shorthand.py +90 -0
  18. cli/tool_protocol_handlers.py +446 -0
  19. common/__init__.py +0 -0
  20. common/adapters/__init__.py +21 -0
  21. common/adapters/android_adapter.py +273 -0
  22. common/adapters/base_adapter.py +24 -0
  23. common/adapters/ios_adapter.py +278 -0
  24. common/adapters/web_adapter.py +271 -0
  25. common/ai.py +277 -0
  26. common/ai_autonomous.py +273 -0
  27. common/ai_heal.py +222 -0
  28. common/cache/__init__.py +15 -0
  29. common/cache/cache_hash.py +57 -0
  30. common/cache/cache_manager.py +300 -0
  31. common/cache/cache_stats.py +133 -0
  32. common/cache/cache_storage.py +79 -0
  33. common/cache/embedding_loader.py +150 -0
  34. common/capabilities.py +121 -0
  35. common/case_memory.py +327 -0
  36. common/error_codes.py +61 -0
  37. common/exceptions.py +18 -0
  38. common/executor.py +1504 -0
  39. common/failure_diagnosis.py +138 -0
  40. common/history_manager.py +75 -0
  41. common/logs.py +168 -0
  42. common/mcp_server.py +467 -0
  43. common/preflight.py +496 -0
  44. common/progress.py +37 -0
  45. common/run_reporter.py +415 -0
  46. common/run_resume.py +149 -0
  47. common/runtime_modes.py +35 -0
  48. common/tool_protocol.py +196 -0
  49. common/visual_fallback.py +71 -0
  50. common/workflow_schema.py +150 -0
  51. config/__init__.py +0 -0
  52. config/config.py +167 -0
  53. config/env_loader.py +76 -0
  54. screenforge-0.4.0.dist-info/METADATA +43 -0
  55. screenforge-0.4.0.dist-info/RECORD +64 -0
  56. screenforge-0.4.0.dist-info/WHEEL +5 -0
  57. screenforge-0.4.0.dist-info/entry_points.txt +2 -0
  58. screenforge-0.4.0.dist-info/licenses/LICENSE +21 -0
  59. screenforge-0.4.0.dist-info/top_level.txt +4 -0
  60. utils/__init__.py +0 -0
  61. utils/screenshot_annotator.py +60 -0
  62. utils/utils_ios.py +195 -0
  63. utils/utils_web.py +304 -0
  64. 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