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
common/preflight.py ADDED
@@ -0,0 +1,496 @@
1
+ import importlib
2
+ import json
3
+ import shutil
4
+ import socket
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Dict, List
8
+ from urllib.parse import urlparse
9
+ from urllib.request import urlopen
10
+
11
+ import config.config as config
12
+ from utils.utils_web import normalize_loopback_url
13
+
14
+
15
+ def _resolve_venv_dir(project_root: Path) -> Path:
16
+ project_root = Path(project_root).resolve()
17
+ for candidate in [project_root, *project_root.parents]:
18
+ venv_dir = candidate / ".venv"
19
+ if venv_dir.exists():
20
+ return venv_dir
21
+ return project_root / ".venv"
22
+
23
+
24
+ def _iter_venv_entrypoints(bin_dir: Path):
25
+ if not bin_dir.exists():
26
+ return
27
+
28
+ for path in sorted(bin_dir.iterdir()):
29
+ if not path.is_file():
30
+ continue
31
+ try:
32
+ with path.open("r", encoding="utf-8") as f:
33
+ first_line = f.readline().strip()
34
+ except (OSError, UnicodeDecodeError):
35
+ continue
36
+ if first_line.startswith("#!") and ".venv/bin/python" in first_line:
37
+ yield path, first_line
38
+
39
+
40
+ def check_virtualenv_consistency(project_root: Path) -> Dict[str, object]:
41
+ project_root = Path(project_root)
42
+ venv_dir = _resolve_venv_dir(project_root)
43
+ issues: List[str] = []
44
+ checked_scripts: List[str] = []
45
+
46
+ if not venv_dir.exists():
47
+ return {
48
+ "name": "venv_consistency",
49
+ "ok": True,
50
+ "venv_dir": str(venv_dir),
51
+ "issues": issues,
52
+ "checked_scripts": checked_scripts,
53
+ }
54
+
55
+ pyvenv_cfg = venv_dir / "pyvenv.cfg"
56
+ if pyvenv_cfg.exists():
57
+ for line in pyvenv_cfg.read_text(encoding="utf-8").splitlines():
58
+ if line.startswith("command = ") and str(venv_dir) not in line and ".venv" in line:
59
+ issues.append(f"pyvenv.cfg command 指向了其他环境: {line.split('=', 1)[1].strip()}")
60
+
61
+ for path, shebang in _iter_venv_entrypoints(venv_dir / "bin"):
62
+ checked_scripts.append(path.name)
63
+ if str(venv_dir) not in shebang:
64
+ issues.append(f"{path.name} shebang 指向了其他环境: {shebang[2:]}")
65
+
66
+ return {
67
+ "name": "venv_consistency",
68
+ "ok": not issues,
69
+ "venv_dir": str(venv_dir),
70
+ "issues": issues,
71
+ "checked_scripts": checked_scripts,
72
+ }
73
+
74
+
75
+ def repair_virtualenv_consistency(project_root: Path) -> Dict[str, object]:
76
+ project_root = Path(project_root)
77
+ venv_dir = _resolve_venv_dir(project_root)
78
+ bin_dir = venv_dir / "bin"
79
+ expected_python = bin_dir / "python3.13"
80
+ if not expected_python.exists():
81
+ expected_python = bin_dir / "python"
82
+
83
+ updated_scripts: List[str] = []
84
+ if bin_dir.exists():
85
+ for path, shebang in _iter_venv_entrypoints(bin_dir):
86
+ if str(venv_dir) in shebang:
87
+ continue
88
+
89
+ original = path.read_text(encoding="utf-8")
90
+ original_lines = original.splitlines()
91
+ if not original_lines:
92
+ continue
93
+ original_lines[0] = f"#!{expected_python}"
94
+ new_text = "\n".join(original_lines)
95
+ if original.endswith("\n"):
96
+ new_text += "\n"
97
+ path.write_text(new_text, encoding="utf-8")
98
+ updated_scripts.append(path.name)
99
+
100
+ cfg_updated = False
101
+ pyvenv_cfg = venv_dir / "pyvenv.cfg"
102
+ if pyvenv_cfg.exists():
103
+ new_lines = []
104
+ for line in pyvenv_cfg.read_text(encoding="utf-8").splitlines():
105
+ if line.startswith("command = ") and str(venv_dir) not in line and " -m venv " in line:
106
+ command_prefix = line.split(" -m venv ", 1)[0].split("=", 1)[1].strip()
107
+ line = f"command = {command_prefix} -m venv {venv_dir}"
108
+ cfg_updated = True
109
+ new_lines.append(line)
110
+ pyvenv_cfg.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
111
+
112
+ return {
113
+ "venv_dir": str(venv_dir),
114
+ "updated_scripts": updated_scripts,
115
+ "updated_pyvenv_cfg": cfg_updated,
116
+ }
117
+
118
+
119
+ def check_required_config() -> Dict[str, object]:
120
+ errors: List[str] = []
121
+
122
+ if not config.OPENAI_API_KEY:
123
+ errors.append("OPENAI_API_KEY 未配置")
124
+ if config.DEFAULT_TIMEOUT <= 0:
125
+ errors.append(f"DEFAULT_TIMEOUT 必须大于 0,当前值: {config.DEFAULT_TIMEOUT}")
126
+ if not 0 <= config.CACHE_SIMILARITY_THRESHOLD <= 1:
127
+ errors.append(
128
+ f"CACHE_SIMILARITY_THRESHOLD 必须在 0-1 之间,当前值: {config.CACHE_SIMILARITY_THRESHOLD}"
129
+ )
130
+ if not 0 <= config.CACHE_EXACT_MATCH_THRESHOLD <= 1:
131
+ errors.append(
132
+ f"CACHE_EXACT_MATCH_THRESHOLD 必须在 0-1 之间,当前值: {config.CACHE_EXACT_MATCH_THRESHOLD}"
133
+ )
134
+ if not config.WEB_CDP_URL.startswith(("http://", "https://")):
135
+ errors.append(
136
+ f"WEB_CDP_URL 必须以 http:// 或 https:// 开头,当前值: {config.WEB_CDP_URL}"
137
+ )
138
+
139
+ return {
140
+ "name": "config",
141
+ "ok": not errors,
142
+ "errors": errors,
143
+ }
144
+
145
+
146
+ def check_module_import(module_name: str) -> Dict[str, object]:
147
+ try:
148
+ importlib.import_module(module_name)
149
+ return {"name": module_name, "ok": True, "error": ""}
150
+ except Exception as exc:
151
+ return {"name": module_name, "ok": False, "error": str(exc)}
152
+
153
+
154
+ def check_runtime_paths(script_dir: Path, run_dir: Path) -> Dict[str, object]:
155
+ script_dir = Path(script_dir)
156
+ run_dir = Path(run_dir)
157
+ script_dir.mkdir(parents=True, exist_ok=True)
158
+ run_dir.mkdir(parents=True, exist_ok=True)
159
+
160
+ return {
161
+ "name": "runtime_paths",
162
+ "ok": script_dir.is_dir() and run_dir.is_dir(),
163
+ "script_dir": str(script_dir),
164
+ "run_dir": str(run_dir),
165
+ }
166
+
167
+
168
+ def check_command_available(command_name: str) -> Dict[str, object]:
169
+ command_path = shutil.which(command_name)
170
+ return {
171
+ "name": command_name,
172
+ "ok": bool(command_path),
173
+ "path": command_path or "",
174
+ "hint": f"Ensure {command_name} is installed and available on PATH.",
175
+ }
176
+
177
+
178
+ def _is_environment_restricted_error(message: str) -> bool:
179
+ text = str(message).lower()
180
+ return any(
181
+ pattern in text
182
+ for pattern in (
183
+ "operation not permitted",
184
+ "permission denied",
185
+ "smartsocket",
186
+ )
187
+ )
188
+
189
+
190
+ def check_tcp_endpoint(url: str, timeout_seconds: float = 1.0) -> Dict[str, object]:
191
+ normalized_url = normalize_loopback_url(url)
192
+ parsed = urlparse(normalized_url)
193
+ host = parsed.hostname
194
+ port = parsed.port
195
+ if not host or not port:
196
+ return {
197
+ "name": url,
198
+ "ok": False,
199
+ "error": "Cannot parse host or port from URL",
200
+ "hint": "Check that the URL contains a valid host and port.",
201
+ }
202
+
203
+ try:
204
+ with socket.create_connection((host, port), timeout=timeout_seconds):
205
+ return {"name": url, "ok": True, "error": "", "hint": ""}
206
+ except OSError as exc:
207
+ raw_error = str(exc)
208
+ if _is_environment_restricted_error(raw_error):
209
+ return {
210
+ "name": url,
211
+ "ok": False,
212
+ "error": "Current environment restricts local TCP connection checks",
213
+ "raw_error": raw_error,
214
+ "environment_restricted": True,
215
+ "hint": (
216
+ f"Try checking {url}/json/version directly from a host terminal, "
217
+ "or retry after relaxing local network permissions."
218
+ ),
219
+ }
220
+ return {
221
+ "name": url,
222
+ "ok": False,
223
+ "error": raw_error,
224
+ "hint": f"Ensure the local service at {url} is running.",
225
+ }
226
+
227
+
228
+ def check_android_device_connected() -> Dict[str, object]:
229
+ try:
230
+ result = subprocess.run(
231
+ ["adb", "devices"],
232
+ capture_output=True,
233
+ text=True,
234
+ timeout=5,
235
+ check=False,
236
+ )
237
+ except Exception as exc:
238
+ return {
239
+ "name": "adb_devices",
240
+ "ok": False,
241
+ "devices": [],
242
+ "blocked_devices": [],
243
+ "error": str(exc),
244
+ "hint": "Ensure adb is executable and `adb devices` runs successfully.",
245
+ }
246
+
247
+ if result.returncode != 0:
248
+ raw_error = result.stderr.strip() or result.stdout.strip() or "adb devices execution failed"
249
+ if _is_environment_restricted_error(raw_error):
250
+ return {
251
+ "name": "adb_devices",
252
+ "ok": False,
253
+ "devices": [],
254
+ "blocked_devices": [],
255
+ "error": "Current environment restricts local port access for adb daemon",
256
+ "raw_error": raw_error,
257
+ "environment_restricted": True,
258
+ "hint": "Run `adb devices` directly from a host terminal, or retry after relaxing local network permissions.",
259
+ }
260
+ return {
261
+ "name": "adb_devices",
262
+ "ok": False,
263
+ "devices": [],
264
+ "blocked_devices": [],
265
+ "error": raw_error,
266
+ "hint": "Ensure the adb service is running, then re-run `adb devices`.",
267
+ }
268
+
269
+ devices = []
270
+ blocked_devices = []
271
+ for raw_line in result.stdout.splitlines()[1:]:
272
+ line = raw_line.strip()
273
+ if not line:
274
+ continue
275
+ parts = line.split()
276
+ serial = parts[0]
277
+ status = parts[1] if len(parts) > 1 else "unknown"
278
+ if status == "device":
279
+ devices.append(serial)
280
+ else:
281
+ blocked_devices.append({"serial": serial, "status": status})
282
+
283
+ if devices:
284
+ return {
285
+ "name": "adb_devices",
286
+ "ok": True,
287
+ "devices": devices,
288
+ "blocked_devices": blocked_devices,
289
+ "error": "",
290
+ "hint": "",
291
+ }
292
+
293
+ if blocked_devices:
294
+ blocked_desc = ", ".join(
295
+ f"{item['serial']}({item['status']})" for item in blocked_devices
296
+ )
297
+ error = f"Android device(s) detected but unavailable: {blocked_desc}"
298
+ else:
299
+ error = "No usable Android device detected"
300
+
301
+ return {
302
+ "name": "adb_devices",
303
+ "ok": False,
304
+ "devices": [],
305
+ "blocked_devices": blocked_devices,
306
+ "error": error,
307
+ "hint": "Connect a device, enable USB debugging, and accept the debug authorization prompt on the phone.",
308
+ }
309
+
310
+
311
+ def check_cdp_debug_endpoint(url: str, timeout_seconds: float = 1.5) -> Dict[str, object]:
312
+ base_url = normalize_loopback_url(url).rstrip("/")
313
+ version_url = f"{base_url}/json/version"
314
+ try:
315
+ with urlopen(version_url, timeout=timeout_seconds) as response:
316
+ payload = json.loads(response.read().decode("utf-8"))
317
+ except Exception as exc:
318
+ raw_error = str(exc)
319
+ if _is_environment_restricted_error(raw_error):
320
+ return {
321
+ "name": "cdp_debug_endpoint",
322
+ "ok": False,
323
+ "browser": "",
324
+ "websocket_url": "",
325
+ "error": "Current environment restricts local HTTP debug endpoint checks",
326
+ "raw_error": raw_error,
327
+ "environment_restricted": True,
328
+ "hint": (
329
+ f"Try checking {version_url} directly from a host terminal, "
330
+ "or retry after relaxing local network permissions."
331
+ ),
332
+ }
333
+ return {
334
+ "name": "cdp_debug_endpoint",
335
+ "ok": False,
336
+ "browser": "",
337
+ "websocket_url": "",
338
+ "error": raw_error,
339
+ "hint": "Launch Chrome with `--remote-debugging-port=9222` and ensure the CDP address is reachable.",
340
+ }
341
+
342
+ websocket_url = payload.get("webSocketDebuggerUrl", "")
343
+ if not websocket_url:
344
+ return {
345
+ "name": "cdp_debug_endpoint",
346
+ "ok": False,
347
+ "browser": payload.get("Browser", ""),
348
+ "websocket_url": "",
349
+ "error": "CDP metadata missing webSocketDebuggerUrl",
350
+ "hint": "Ensure the port exposes Chrome DevTools Protocol, not a regular HTTP service.",
351
+ }
352
+
353
+ return {
354
+ "name": "cdp_debug_endpoint",
355
+ "ok": True,
356
+ "browser": payload.get("Browser", ""),
357
+ "websocket_url": websocket_url,
358
+ "error": "",
359
+ "hint": "",
360
+ }
361
+
362
+
363
+ def check_wda_status_endpoint(url: str, timeout_seconds: float = 1.5) -> Dict[str, object]:
364
+ status_url = f"{url.rstrip('/')}/status"
365
+ try:
366
+ with urlopen(status_url, timeout=timeout_seconds) as response:
367
+ payload = json.loads(response.read().decode("utf-8"))
368
+ except Exception as exc:
369
+ return {
370
+ "name": "wda_status",
371
+ "ok": False,
372
+ "platform_name": "",
373
+ "platform_version": "",
374
+ "message": "",
375
+ "error": str(exc),
376
+ "hint": "Ensure WebDriverAgent is running on the iPhone and port 8100 is mapped.",
377
+ }
378
+
379
+ value = payload.get("value")
380
+ if not isinstance(value, dict):
381
+ return {
382
+ "name": "wda_status",
383
+ "ok": False,
384
+ "platform_name": "",
385
+ "platform_version": "",
386
+ "message": "",
387
+ "error": "WDA status response missing 'value' field",
388
+ "hint": "Ensure port 8100 exposes a valid WebDriverAgent status endpoint.",
389
+ }
390
+
391
+ state = str(value.get("state", "")).strip()
392
+ if state.lower() != "success":
393
+ return {
394
+ "name": "wda_status",
395
+ "ok": False,
396
+ "platform_name": "",
397
+ "platform_version": "",
398
+ "message": str(value.get("message", "")).strip(),
399
+ "error": f"WDA state is abnormal: {state or 'unknown'}",
400
+ "hint": "Check that WebDriverAgent is properly installed and signed, and the device trusts the developer certificate.",
401
+ }
402
+
403
+ os_info = value.get("os", {}) if isinstance(value.get("os"), dict) else {}
404
+ return {
405
+ "name": "wda_status",
406
+ "ok": True,
407
+ "platform_name": str(os_info.get("name", "")).strip(),
408
+ "platform_version": str(os_info.get("version", "")).strip(),
409
+ "message": str(value.get("message", "")).strip(),
410
+ "error": "",
411
+ "hint": "",
412
+ }
413
+
414
+
415
+ def check_orphan_web_browser() -> Dict[str, object]:
416
+ """Report a leftover persistent Chromium still holding the CDP port.
417
+
418
+ The web adapter launches Chromium detached on port 9333 and keeps it alive
419
+ across CLI calls (teardown only disconnects) so later runs can reconnect.
420
+ `--web-stop` reaps it, but until now doctor had no way to *notice* one was
421
+ leaked. This closes the loop: read report/web_session.json, and if the
422
+ recorded pid is still alive, surface it as a finding.
423
+
424
+ ADVISORY: a live persistent browser is the design's *intended* reconnect
425
+ target, not a fault, so this check is marked `advisory` — run_preflight
426
+ excludes advisory findings from the pass/fail aggregate. It informs ("a
427
+ browser is up, run --web-stop to reclaim it") without failing --doctor.
428
+
429
+ Reuses web_adapter's _read_session / _is_process_alive so the zombie-aware
430
+ liveness logic stays single-sourced.
431
+ """
432
+ from common.adapters import web_adapter
433
+
434
+ ok_result = {
435
+ "name": "orphan_web_browser",
436
+ "ok": True,
437
+ "advisory": True,
438
+ "pid": 0,
439
+ "cdp_url": "",
440
+ "error": "",
441
+ "hint": "",
442
+ }
443
+
444
+ session = web_adapter._read_session()
445
+ if not session:
446
+ return ok_result
447
+
448
+ pid = session.get("pid", 0)
449
+ cdp_url = session.get("cdp_url", "")
450
+ if not pid or not web_adapter._is_process_alive(pid):
451
+ return ok_result
452
+
453
+ return {
454
+ "name": "orphan_web_browser",
455
+ "ok": False,
456
+ "advisory": True,
457
+ "pid": pid,
458
+ "cdp_url": cdp_url,
459
+ "error": f"Persistent Chromium still running (pid {pid}, {cdp_url})",
460
+ "hint": "Run `screenforge --web-stop` to reclaim it (this is a note, not a failure).",
461
+ }
462
+
463
+
464
+ def run_preflight(platform: str, script_dir: Path, run_dir: Path) -> Dict[str, object]:
465
+ checks = [
466
+ check_required_config(),
467
+ check_runtime_paths(script_dir, run_dir),
468
+ check_virtualenv_consistency(config.BASE_DIR),
469
+ ]
470
+
471
+ if platform == "android":
472
+ checks.append(check_module_import("uiautomator2"))
473
+ checks.append(check_command_available("adb"))
474
+ checks.append(check_android_device_connected())
475
+ elif platform == "ios":
476
+ checks.append(check_module_import("wda"))
477
+ ios_endpoint_check = check_tcp_endpoint("http://localhost:8100")
478
+ checks.append(ios_endpoint_check)
479
+ if ios_endpoint_check.get("ok", False):
480
+ checks.append(check_wda_status_endpoint("http://localhost:8100"))
481
+ elif platform == "web":
482
+ checks.append(check_module_import("playwright"))
483
+ web_endpoint_check = check_tcp_endpoint(config.WEB_CDP_URL)
484
+ checks.append(web_endpoint_check)
485
+ if web_endpoint_check.get("ok", False):
486
+ checks.append(check_cdp_debug_endpoint(config.WEB_CDP_URL))
487
+ checks.append(check_orphan_web_browser())
488
+
489
+ # Advisory findings (e.g. a healthy persistent browser still up) inform but
490
+ # must NOT flip the pass/fail verdict or exit code — only real blockers do.
491
+ ok = all(item.get("ok", False) for item in checks if not item.get("advisory", False))
492
+ return {
493
+ "ok": ok,
494
+ "platform": platform,
495
+ "checks": checks,
496
+ }
common/progress.py ADDED
@@ -0,0 +1,37 @@
1
+ """Rich progress indicators for AI operations."""
2
+
3
+ import sys
4
+ from contextlib import contextmanager
5
+
6
+ from rich.console import Console
7
+
8
+ _console = Console(stderr=True)
9
+
10
+ _TOOL_MODE = False
11
+
12
+
13
+ def set_tool_mode(enabled: bool = True):
14
+ """Disable spinners when running in tool/MCP mode (stdout is structured)."""
15
+ global _TOOL_MODE
16
+ _TOOL_MODE = enabled
17
+
18
+
19
+ @contextmanager
20
+ def ai_status(message: str = "Calling AI..."):
21
+ if _TOOL_MODE or not sys.stderr.isatty():
22
+ yield
23
+ return
24
+ with _console.status(f"[bold cyan]{message}[/]", spinner="dots") as status:
25
+ yield status
26
+
27
+
28
+ @contextmanager
29
+ def action_status(action: str, target: str = ""):
30
+ label = f"Executing: {action}"
31
+ if target:
32
+ label += f" → {target}"
33
+ if _TOOL_MODE or not sys.stderr.isatty():
34
+ yield
35
+ return
36
+ with _console.status(f"[bold green]{label}[/]", spinner="dots") as status:
37
+ yield status