Skip to content

Commit dcac473

Browse files
committed
Improve macOS diagnostics and doctor output
1 parent e074d3c commit dcac473

10 files changed

Lines changed: 1064 additions & 70 deletions

File tree

skills/simulateinput/references/cli-usage.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,16 @@ Inspect built-in profiles, MCP tools, and current driver capability.
3535

3636
```powershell
3737
python -m simulateinput.cli.main doctor
38+
python -m simulateinput.cli.main doctor --compact
39+
python -m simulateinput.cli.main doctor --verbose
3840
```
3941

42+
Output modes:
43+
44+
- default: profiles, MCP tool names, and driver diagnostics
45+
- `--compact`: reduced payload for UI surfaces that mainly need driver state and remediation
46+
- `--verbose`: default payload plus package version and full MCP tool metadata
47+
4048
### session start
4149

4250
Create a new session.

src/simulateinput/cli/main.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from simulateinput.mcp.server import MCPServer, list_tools
1313
from simulateinput.runner.case_runner import load_case, run_case
1414

15+
1516
def emit(payload: dict) -> None:
1617
print(json.dumps(payload, indent=2))
1718

@@ -21,7 +22,10 @@ def build_parser() -> argparse.ArgumentParser:
2122
subparsers = parser.add_subparsers(dest="command", required=True)
2223

2324
subparsers.add_parser("version", help="Print the current package version.")
24-
subparsers.add_parser("doctor", help="Show built-in profiles and MCP tool names.")
25+
doctor_parser = subparsers.add_parser("doctor", help="Show built-in profiles, driver diagnostics, and MCP tool names.")
26+
doctor_mode = doctor_parser.add_mutually_exclusive_group()
27+
doctor_mode.add_argument("--compact", action="store_true", help="Emit a reduced payload focused on driver status and remediation.")
28+
doctor_mode.add_argument("--verbose", action="store_true", help="Emit expanded driver details plus full MCP tool metadata.")
2529

2630
session_parser = subparsers.add_parser("session", help="Manage automation sessions.")
2731
session_subparsers = session_parser.add_subparsers(dest="session_command", required=True)
@@ -208,6 +212,32 @@ def resolve_engine(state_root: Path | None) -> AutomationEngine:
208212
return AutomationEngine(state_root=state_root)
209213

210214

215+
def build_doctor_payload(engine: AutomationEngine, compact: bool = False, verbose: bool = False) -> dict:
216+
driver = engine.probe_driver()
217+
tool_defs = list_tools()
218+
if compact:
219+
return {
220+
"ok": True,
221+
"driver": {
222+
"available": driver["available"],
223+
"platform": driver["platform"],
224+
"message": driver["message"],
225+
"capabilities": driver["capabilities"],
226+
"remediation": driver.get("details", {}).get("remediation", []),
227+
},
228+
}
229+
payload = {
230+
"ok": True,
231+
"profiles": sorted(BUILTIN_PROFILES),
232+
"mcp_tools": [tool.name for tool in tool_defs],
233+
"driver": driver,
234+
}
235+
if verbose:
236+
payload["version"] = __version__
237+
payload["mcp_tool_details"] = [tool.to_dict() for tool in tool_defs]
238+
return payload
239+
240+
211241
def main(argv: list[str] | None = None) -> int:
212242
parser = build_parser()
213243
try:
@@ -219,14 +249,7 @@ def main(argv: list[str] | None = None) -> int:
219249

220250
if args.command == "doctor":
221251
engine = resolve_engine(None)
222-
emit(
223-
{
224-
"ok": True,
225-
"profiles": sorted(BUILTIN_PROFILES),
226-
"mcp_tools": [tool.name for tool in list_tools()],
227-
"driver": engine.probe_driver(),
228-
}
229-
)
252+
emit(build_doctor_payload(engine, compact=getattr(args, "compact", False), verbose=getattr(args, "verbose", False)))
230253
return 0
231254

232255
if args.command == "mcp":

src/simulateinput/core/engine.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from pathlib import Path
44

55
from simulateinput.core.errors import DriverNotAvailableError
6-
from simulateinput.core.models import ActionResult, Artifact, ArtifactKind, ElementInfo, WindowInfo
6+
from simulateinput.core.models import ActionResult, Artifact, ArtifactKind, ElementInfo, PlatformKind, WindowInfo
77
from simulateinput.core.session import SessionStore
88
from simulateinput.drivers.registry import create_driver
99

@@ -23,6 +23,7 @@ def probe_driver(self) -> dict:
2323
"platform": probe.platform.value,
2424
"message": probe.message,
2525
"capabilities": probe.capabilities,
26+
"details": getattr(probe, "details", {}),
2627
}
2728

2829
def list_windows(self, session_id: str) -> list[WindowInfo]:
@@ -252,12 +253,56 @@ def execute_click_uia(
252253
if not matches:
253254
raise ValueError("uia target not found")
254255
target = matches[0]
256+
self._validate_clickable_uia_target(target)
255257
window = self._resolve_window(session_id, window_id=target.window_id)
256258
self._focus_window(window)
257259
payload = self._build_center_payload(target)
258260
self.driver.click(payload["absolute"]["x"], payload["absolute"]["y"])
259261
return ActionResult(ok=True, code="OK", message="click_uia executed", data=payload)
260262

263+
def _validate_clickable_uia_target(self, target: ElementInfo) -> None:
264+
if target.platform != PlatformKind.MACOS:
265+
return
266+
metadata = target.metadata or {}
267+
if not metadata:
268+
return
269+
270+
if metadata.get("visible") is False:
271+
raise ValueError("macOS UIA target is not visible")
272+
if metadata.get("enabled") is False:
273+
raise ValueError("macOS UIA target is disabled")
274+
if int(target.bounds.width) <= 0 or int(target.bounds.height) <= 0:
275+
raise ValueError("macOS UIA target has no clickable size")
276+
277+
actions = {
278+
str(action).strip().casefold()
279+
for action in metadata.get("actions", [])
280+
if str(action).strip()
281+
}
282+
actionable_actions = {
283+
"axpress",
284+
"axconfirm",
285+
"axshowmenu",
286+
"axpick",
287+
"axopen",
288+
"axincrement",
289+
"axdecrement",
290+
}
291+
interactive_roles = {
292+
"axbutton",
293+
"axcheckbox",
294+
"axradiobutton",
295+
"axpopbutton",
296+
"axmenuitem",
297+
"axtextfield",
298+
"axsecuretextfield",
299+
"axlink",
300+
"axdisclosuretriangle",
301+
}
302+
control_type = (target.control_type or "").casefold()
303+
if actions and not (actions & actionable_actions) and control_type not in interactive_roles:
304+
raise ValueError("macOS UIA target is not actionable for click_uia")
305+
261306
def preview_click_ocr(
262307
self,
263308
session_id: str,

src/simulateinput/core/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ class ElementInfo:
9898
automation_id: str | None = None
9999
source: str | None = None
100100
confidence: float | None = None
101+
metadata: dict[str, Any] = field(default_factory=dict)
101102

102103
def to_dict(self) -> dict[str, Any]:
103104
return {
@@ -111,6 +112,7 @@ def to_dict(self) -> dict[str, Any]:
111112
"automation_id": self.automation_id,
112113
"source": self.source,
113114
"confidence": float(self.confidence) if self.confidence is not None else None,
115+
"metadata": self.metadata,
114116
}
115117

116118
@classmethod
@@ -126,6 +128,7 @@ def from_dict(cls, payload: dict[str, Any]) -> "ElementInfo":
126128
automation_id=payload.get("automation_id"),
127129
source=payload.get("source"),
128130
confidence=payload.get("confidence"),
131+
metadata=payload.get("metadata", {}),
129132
)
130133

131134

src/simulateinput/drivers/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from dataclasses import dataclass, field
4-
from typing import Protocol
4+
from typing import Any, Protocol
55

66
from simulateinput.core.models import PlatformKind
77

@@ -12,6 +12,7 @@ class DriverProbe:
1212
platform: PlatformKind
1313
message: str = ""
1414
capabilities: list[str] = field(default_factory=list)
15+
details: dict[str, Any] = field(default_factory=dict)
1516

1617

1718
class PlatformDriver(Protocol):
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import asdict, dataclass, field
4+
from typing import Any
5+
6+
7+
@dataclass(frozen=True, slots=True)
8+
class RemediationHint:
9+
kind: str
10+
permission: str
11+
reason: str
12+
system_settings_path: list[str] = field(default_factory=list)
13+
shell_hint: str | None = None
14+
copyable_steps: list[str] = field(default_factory=list)
15+
metadata: dict[str, Any] = field(default_factory=dict)
16+
17+
def to_dict(self) -> dict[str, Any]:
18+
payload = asdict(self)
19+
if self.shell_hint is None:
20+
payload.pop("shell_hint", None)
21+
if not self.metadata:
22+
payload.pop("metadata", None)
23+
return payload
24+
25+
26+
def permission_remediation(
27+
permission: str,
28+
reason: str,
29+
system_settings_path: list[str],
30+
*,
31+
shell_hint: str | None = None,
32+
copyable_steps: list[str] | None = None,
33+
metadata: dict[str, Any] | None = None,
34+
) -> dict[str, Any]:
35+
return RemediationHint(
36+
kind="permission",
37+
permission=permission,
38+
reason=reason,
39+
system_settings_path=system_settings_path,
40+
shell_hint=shell_hint,
41+
copyable_steps=copyable_steps or [],
42+
metadata=metadata or {},
43+
).to_dict()

0 commit comments

Comments
 (0)