macos-harness 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.
macos_harness/SKILL.md ADDED
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: macos-harness
3
+ description: Control a whole Mac from one persistent Python session with screenshots, PID-targeted input, an animated virtual pointer, targeted Apple Accessibility, Apple Events, Browser Harness CDP, and filesystem access. Use for native, Electron, browser, dialog, file, or cross-app tasks without moving the physical cursor or forcing apps into the foreground.
4
+ ---
5
+
6
+ # macOS Harness
7
+
8
+ Use one CLI call per decision point, not per primitive:
9
+
10
+ ```bash
11
+ macos-harness <<'PY'
12
+ app = "Spotify"
13
+ mac.see(app)
14
+ mac.key("cmd+k", app=app)
15
+ mac.type("Alessia Cara", app=app)
16
+ print(mac.see(app))
17
+ PY
18
+ ```
19
+
20
+ The CLI preloads `mac`, `browser`, `Path`, and `subprocess`. Prefer bounded stdin
21
+ programs; reserve `macos-harness repl` for manual exploration and always exit it.
22
+
23
+ ## Minimize round trips
24
+
25
+ - Bundle deterministic, reversible steps into one program, then verify once. Opening
26
+ search, typing a query, and capturing the results is one burst—not three calls.
27
+ - Stop at a genuine decision boundary: ambiguous identity, new coordinates, an
28
+ irreversible action, or unexpected state. Inspect once, then run the next burst.
29
+ - Do not screenshot merely to confirm that a known shortcut opened a text field
30
+ before typing. Let the final screenshot verify the whole sequence.
31
+ - Poll exact AX or Apple Events state inside the same Python program when possible;
32
+ do not make the LLM repeatedly ask whether a transition finished.
33
+ - Use the cheapest strong end-state check. Prefer one screenshot for visible state
34
+ or one exact API/AX query for semantic state; use both only when they prove
35
+ different things.
36
+
37
+ ## Use the small surface
38
+
39
+ Think in six verbs: `see`, `key`, `type`, `click`, `ax`, `script`.
40
+
41
+ ```python
42
+ frame = mac.see("Spotify")
43
+ mac.key("cmd+k", app="Spotify")
44
+ mac.type("Alessia Cara", app="Spotify")
45
+ mac.click(640, 420, app="Spotify")
46
+
47
+ item = mac.ax.at(640, 420, app="Spotify")
48
+ mac.ax.perform(item["element_index"], "AXPress")
49
+
50
+ mac.script('tell application "Spotify" to play')
51
+ ```
52
+
53
+ Use ordinary Python for local context and one-off logic. Do not add app-specific
54
+ helpers when a short program can resolve the task.
55
+
56
+ ## Choose the lowest useful mode
57
+
58
+ 1. When identity depends on local context (`my`, `friend`, or prior activity),
59
+ inspect that context and correlate stable fields; a loose text hit is not enough.
60
+ 2. Use `mac.script()` for a known exact, focus-safe app command.
61
+ 3. Otherwise use `mac.see(app)` and vision.
62
+ 4. Prefer a known keyboard route; use a verified coordinate for a visible,
63
+ low-risk target.
64
+ 5. Use targeted `mac.ax` only when semantic identity or state matters. Do not dump
65
+ a full AX tree before trying the direct route.
66
+
67
+ After a failed verified burst, switch mode or stop. Never repair uncertainty with
68
+ repeated keys, clicks, deletion loops, or bulk input.
69
+
70
+ ## Keep the invariants
71
+
72
+ - Input targets an already-running app PID and never requests activation or raise.
73
+ - A background target becoming frontmost raises `FocusChangedError`; never
74
+ manipulate focus to restore it.
75
+ - `mac.click()` is raw PID-targeted input. It never guesses an AX action.
76
+ - The animated pointer is click-through and never moves the physical cursor.
77
+ - `mac.move()` moves only that pointer; it cannot produce native hover.
78
+ - Inactive apps may reject raw clicks. After one verified failure, switch mode.
79
+ - Never launch a closed app or use a custom URL scheme when focus is forbidden.
80
+ - Screenshot coordinates come from the latest `mac.see()` and preserve window
81
+ bounds and Retina scaling.
82
+
83
+ Secondary primitives are `mac.move`, `drag`, `scroll`, `show_pointer`, and
84
+ `hide_pointer`. `mac.ax.query()` returns compact matches and bounds fallback
85
+ traversal; lower `max_nodes` for especially large apps.
86
+
87
+ ## Browser and permissions
88
+
89
+ Use `browser` for DOM, tabs, network, downloads, and uploads. Do not substitute AX
90
+ for CDP inside a web page. While Browser Harness connects, macOS Harness accepts
91
+ Chrome's exact `Allow remote debugging?` sheet through system-wide AX. It never
92
+ activates Chrome or emits a mouse event.
93
+
94
+ Run `macos-harness doctor` to inspect permissions without prompting. Run
95
+ `macos-harness doctor --request` only with user approval. Accessibility, screen
96
+ recording, and event posting are global; Apple Events Automation is per target.
@@ -0,0 +1,12 @@
1
+ """Public Python interface for macOS Harness."""
2
+
3
+ from .browser import BrowserHarness
4
+ from .macos import AccessibilityPermissionError, FocusChangedError, MacOS, MacOSError
5
+
6
+ __all__ = [
7
+ "AccessibilityPermissionError",
8
+ "BrowserHarness",
9
+ "FocusChangedError",
10
+ "MacOS",
11
+ "MacOSError",
12
+ ]
@@ -0,0 +1,168 @@
1
+ """Lazy bridge to the Browser Harness Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import time
11
+ from types import ModuleType
12
+ from typing import Any
13
+
14
+
15
+ def _approve_chrome_prompt(stop: threading.Event, timeout: float) -> None:
16
+ """Press Chrome's exact remote-debugging Allow button through system-wide AX."""
17
+ if platform.system() != "Darwin":
18
+ return
19
+ try:
20
+ import ApplicationServices as AS
21
+ except ImportError: # pragma: no cover - macOS dependency failure
22
+ return
23
+
24
+ deadline = time.monotonic() + timeout
25
+ while not stop.is_set() and time.monotonic() < deadline:
26
+ root = AS.AXUIElementCreateSystemWide()
27
+ pressed = False
28
+ windows = AS.CGWindowListCopyWindowInfo(
29
+ AS.kCGWindowListOptionOnScreenOnly, AS.kCGNullWindowID
30
+ )
31
+ for window in windows:
32
+ if window.get("kCGWindowOwnerName") != "Google Chrome":
33
+ continue
34
+ if window.get("kCGWindowName") != "Allow remote debugging?":
35
+ continue
36
+ bounds = window.get("kCGWindowBounds") or {}
37
+ left = float(bounds.get("X", 0))
38
+ top = float(bounds.get("Y", 0))
39
+ width = float(bounds.get("Width", 0))
40
+ height = float(bounds.get("Height", 0))
41
+ points = [(left + width - 58, top + height - 38)]
42
+ points.extend(
43
+ (x, y)
44
+ for y in range(int(top + height - 16), int(top + height - 96), -16)
45
+ for x in range(int(left + width - 16), int(left), -16)
46
+ )
47
+ for x, y in points:
48
+ error, element = AS.AXUIElementCopyElementAtPosition(root, x, y, None)
49
+ if error != 0 or element is None:
50
+ continue
51
+ role_error, role = AS.AXUIElementCopyAttributeValue(
52
+ element, "AXRole", None
53
+ )
54
+ description_error, description = AS.AXUIElementCopyAttributeValue(
55
+ element, "AXDescription", None
56
+ )
57
+ if (
58
+ role_error == 0
59
+ and description_error == 0
60
+ and str(role) == "AXButton"
61
+ and str(description) == "Allow"
62
+ ):
63
+ AS.AXUIElementPerformAction(element, "AXPress")
64
+ pressed = True
65
+ break
66
+ if pressed:
67
+ break
68
+ stop.wait(0.2 if pressed else 0.05)
69
+
70
+
71
+ def _watch_chrome_prompts(timeout: float) -> None:
72
+ """Start AX only after Chrome creates its protected permission window."""
73
+ if platform.system() != "Darwin":
74
+ return
75
+ try:
76
+ import ApplicationServices as AS
77
+ except ImportError: # pragma: no cover - macOS dependency failure
78
+ return
79
+
80
+ source = (
81
+ "import threading; "
82
+ "from macos_harness.browser import _approve_chrome_prompt; "
83
+ "_approve_chrome_prompt(threading.Event(), 0.8)"
84
+ )
85
+ deadline = time.monotonic() + timeout
86
+ while time.monotonic() < deadline:
87
+ windows = AS.CGWindowListCopyWindowInfo(
88
+ AS.kCGWindowListOptionOnScreenOnly, AS.kCGNullWindowID
89
+ )
90
+ prompt_exists = any(
91
+ str(window.get("kCGWindowOwnerName") or "") == "Google Chrome"
92
+ and str(window.get("kCGWindowName") or "") == "Allow remote debugging?"
93
+ for window in windows
94
+ )
95
+ if prompt_exists:
96
+ subprocess.run(
97
+ [sys.executable, "-c", source],
98
+ stdout=subprocess.DEVNULL,
99
+ stderr=subprocess.DEVNULL,
100
+ timeout=2,
101
+ check=False,
102
+ )
103
+ time.sleep(0.05)
104
+
105
+
106
+ class BrowserHarness:
107
+ """Expose Browser Harness helpers without connecting until first use."""
108
+
109
+ def __init__(self, *, name: str | None = None, wait: float = 60.0) -> None:
110
+ self.name = name
111
+ self.wait = wait
112
+ self._helpers: ModuleType | None = None
113
+
114
+ def connect(self) -> BrowserHarness:
115
+ if self._helpers is not None:
116
+ return self
117
+
118
+ if self.name:
119
+ os.environ["BU_NAME"] = self.name
120
+
121
+ try:
122
+ from browser_harness import admin, helpers
123
+ except ImportError as exc: # pragma: no cover - packaging failure
124
+ raise RuntimeError(
125
+ "Browser Harness is unavailable. Install the project dependencies "
126
+ "with `uv sync`."
127
+ ) from exc
128
+
129
+ approver = subprocess.Popen(
130
+ [
131
+ sys.executable,
132
+ "-c",
133
+ (
134
+ "from macos_harness.browser import _watch_chrome_prompts; "
135
+ f"_watch_chrome_prompts({self.wait!r})"
136
+ ),
137
+ ],
138
+ stdout=subprocess.DEVNULL,
139
+ stderr=subprocess.DEVNULL,
140
+ )
141
+ try:
142
+ admin.ensure_daemon(wait=self.wait, name=self.name)
143
+ finally:
144
+ if approver.poll() is None:
145
+ approver.terminate()
146
+ try:
147
+ approver.wait(timeout=0.2)
148
+ except subprocess.TimeoutExpired:
149
+ approver.kill()
150
+ if self.name:
151
+ helpers.NAME = self.name
152
+ self._helpers = helpers
153
+ return self
154
+
155
+ def __getattr__(self, name: str) -> Any:
156
+ if name.startswith("_"):
157
+ raise AttributeError(name)
158
+ self.connect()
159
+ assert self._helpers is not None
160
+ try:
161
+ return getattr(self._helpers, name)
162
+ except AttributeError as exc:
163
+ raise AttributeError(f"Browser Harness has no helper {name!r}") from exc
164
+
165
+ def __repr__(self) -> str:
166
+ status = "connected" if self._helpers is not None else "lazy"
167
+ suffix = f", name={self.name!r}" if self.name else ""
168
+ return f"BrowserHarness({status}{suffix})"
macos_harness/cli.py ADDED
@@ -0,0 +1,158 @@
1
+ """One Python execution surface for the browser, macOS, and local files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import code
7
+ import json
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from importlib import resources
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .browser import BrowserHarness
16
+ from .macos import MacOS, MacOSError
17
+ from .telemetry import capture_cli
18
+ from .telemetry import run_cli as run_telemetry_cli
19
+
20
+
21
+ def _namespace() -> dict[str, Any]:
22
+ return {
23
+ "__name__": "__macos_harness__",
24
+ "browser": BrowserHarness(),
25
+ "mac": MacOS(),
26
+ "Path": Path,
27
+ "subprocess": subprocess,
28
+ }
29
+
30
+
31
+ def _execute(code: str) -> int:
32
+ if not code.strip():
33
+ print("No Python code received on stdin", file=sys.stderr)
34
+ return 2
35
+ namespace = _namespace()
36
+ exec(compile(code, "<macos-harness>", "exec"), namespace, namespace) # noqa: S102
37
+ return 0
38
+
39
+
40
+ def _skill_text() -> str:
41
+ bundled = resources.files("macos_harness").joinpath("SKILL.md")
42
+ if bundled.is_file():
43
+ return bundled.read_text(encoding="utf-8")
44
+ checkout = Path(__file__).resolve().parents[2] / "skills/macos-harness/SKILL.md"
45
+ return checkout.read_text(encoding="utf-8")
46
+
47
+
48
+ def _build_parser() -> argparse.ArgumentParser:
49
+ parser = argparse.ArgumentParser(
50
+ prog="macos-harness",
51
+ description="Execute Python with browser, macOS, and filesystem access.",
52
+ epilog=(
53
+ "Typical usage:\n macos-harness <<'PY'\n print(mac.see('Spotify'))\n PY"
54
+ ),
55
+ formatter_class=argparse.RawDescriptionHelpFormatter,
56
+ )
57
+ subparsers = parser.add_subparsers(dest="command")
58
+ doctor = subparsers.add_parser("doctor", help="check macOS permissions and runtime")
59
+ doctor.add_argument(
60
+ "--request", action="store_true", help="request missing global permissions"
61
+ )
62
+ subparsers.add_parser("apps", help="list running macOS applications")
63
+ subparsers.add_parser("repl", help="start a persistent interactive Python session")
64
+ subparsers.add_parser("skill", help="print the macOS Harness skill")
65
+ telemetry = subparsers.add_parser(
66
+ "telemetry", help="inspect or change anonymous telemetry"
67
+ )
68
+ telemetry.add_argument("action", nargs="?", choices=("status", "enable", "disable"))
69
+ see = subparsers.add_parser("see", help="capture a bounded application window")
70
+ see.add_argument("app")
71
+ see.add_argument("--max-width", type=int, default=1280)
72
+ see.add_argument("--max-height", type=int, default=1280)
73
+ see.add_argument("--no-pointer", action="store_true")
74
+ state = subparsers.add_parser(
75
+ "state", help="print an application's AX state as JSON"
76
+ )
77
+ state.add_argument("app")
78
+ state.add_argument("--screenshot", action="store_true")
79
+ state.add_argument("--max-depth", type=int, default=25)
80
+ state.add_argument("--max-nodes", type=int, default=5000)
81
+ state.add_argument("--include-menu-bar", action="store_true")
82
+ return parser
83
+
84
+
85
+ def main(argv: list[str] | None = None) -> int:
86
+ parser = _build_parser()
87
+ args = parser.parse_args(argv)
88
+ if args.command == "telemetry":
89
+ return run_telemetry_cli([args.action] if args.action else [])
90
+
91
+ started = time.monotonic()
92
+ result: int | None = None
93
+ try:
94
+ if args.command == "doctor":
95
+ mac = MacOS()
96
+ if args.request:
97
+ mac.request_permissions()
98
+ print(json.dumps(mac.doctor(), indent=2))
99
+ result = 0
100
+ return result
101
+ if args.command == "apps":
102
+ print(json.dumps(MacOS().list_apps(), indent=2))
103
+ result = 0
104
+ return result
105
+ if args.command == "skill":
106
+ print(_skill_text(), end="")
107
+ result = 0
108
+ return result
109
+ if args.command == "repl":
110
+ code.interact(
111
+ banner=(
112
+ "macos-harness: mac.see/key/type/click/ax/script, browser, "
113
+ "Path, and subprocess are ready"
114
+ ),
115
+ local=_namespace(),
116
+ exitmsg="",
117
+ )
118
+ result = 0
119
+ return result
120
+ if args.command == "see":
121
+ result = MacOS().see(
122
+ args.app,
123
+ max_width=args.max_width,
124
+ max_height=args.max_height,
125
+ show_pointer=not args.no_pointer,
126
+ )
127
+ print(json.dumps(result, indent=2, ensure_ascii=False))
128
+ result = 0
129
+ return result
130
+ if args.command == "state":
131
+ state = MacOS().get_app_state(
132
+ args.app,
133
+ screenshot=args.screenshot,
134
+ max_depth=args.max_depth,
135
+ max_nodes=args.max_nodes,
136
+ include_menu_bar=args.include_menu_bar,
137
+ )
138
+ print(json.dumps(state, indent=2, ensure_ascii=False))
139
+ result = 0
140
+ return result
141
+ if args.command is None:
142
+ if sys.stdin.isatty():
143
+ parser.print_help()
144
+ result = 2
145
+ return result
146
+ result = _execute(sys.stdin.read())
147
+ return result
148
+ parser.error(f"unknown command: {args.command}")
149
+ except (MacOSError, RuntimeError) as exc:
150
+ print(f"macos-harness: {exc}", file=sys.stderr)
151
+ result = 1
152
+ return result
153
+ finally:
154
+ capture_cli(args.command or "python", result == 0, time.monotonic() - started)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ raise SystemExit(main())
@@ -0,0 +1,119 @@
1
+ """The explicit Accessibility escape hatch for the macOS harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import ApplicationServices as AS
9
+
10
+ if TYPE_CHECKING:
11
+ from .macos import MacOS
12
+
13
+
14
+ class Accessibility:
15
+ """Thin, opt-in access to Apple's AXUIElement operations."""
16
+
17
+ _COMPACT_ATTRIBUTES = (
18
+ "AXRole",
19
+ "AXTitle",
20
+ "AXDescription",
21
+ "AXValue",
22
+ "AXFrame",
23
+ )
24
+
25
+ def __init__(self, host: MacOS) -> None:
26
+ self._host = host
27
+
28
+ def at(
29
+ self,
30
+ x: float,
31
+ y: float,
32
+ *,
33
+ app: str | None = None,
34
+ coordinate_space: str = "screenshot",
35
+ attributes: Iterable[str] = _COMPACT_ATTRIBUTES,
36
+ include_actions: bool = True,
37
+ ) -> dict[str, Any]:
38
+ self._host._ensure_accessibility()
39
+ point = self._host._screen_point(x, y, coordinate_space)
40
+ pid = self._host._pid(app)
41
+ root = (
42
+ self._host._application_element(pid)
43
+ if pid is not None
44
+ else AS.AXUIElementCreateSystemWide()
45
+ )
46
+ error, element = AS.AXUIElementCopyElementAtPosition(
47
+ root, point[0], point[1], None
48
+ )
49
+ if error != 0 or element is None:
50
+ raise RuntimeError(f"AX hit test failed with AXError {error}")
51
+ index = self._host._remember_element(element)
52
+ return self._host._describe_element(
53
+ element,
54
+ index,
55
+ attributes=attributes,
56
+ include_actions=include_actions,
57
+ )
58
+
59
+ def query(
60
+ self,
61
+ *,
62
+ app: str | None = None,
63
+ element_index: int | None = None,
64
+ search_key: str = "AXAnyTypeSearchKey",
65
+ text: str | None = None,
66
+ visible_only: bool = True,
67
+ limit: int = 20,
68
+ direction: str = "next",
69
+ immediate_descendants_only: bool = False,
70
+ attributes: Iterable[str] = _COMPACT_ATTRIBUTES,
71
+ include_actions: bool = False,
72
+ max_nodes: int = 500,
73
+ ) -> list[dict[str, Any]]:
74
+ return self._host.ax_search(
75
+ element_index=element_index,
76
+ app=app,
77
+ search_key=search_key,
78
+ text=text,
79
+ visible_only=visible_only,
80
+ limit=limit,
81
+ direction=direction,
82
+ immediate_descendants_only=immediate_descendants_only,
83
+ attributes=attributes,
84
+ include_actions=include_actions,
85
+ max_nodes=max_nodes,
86
+ )
87
+
88
+ def get(
89
+ self, element_index: int, attributes: str | Iterable[str] = "AXValue"
90
+ ) -> Any:
91
+ if isinstance(attributes, str):
92
+ return self._host.get(element_index, attributes)
93
+ return self._host.get_attributes(element_index, attributes)
94
+
95
+ def set(self, element_index: int, attribute: str, value: Any) -> None:
96
+ self._host.set(element_index, value, attribute)
97
+
98
+ def actions(self, element_index: int) -> list[str]:
99
+ return self._host._actions(self._host._element(element_index))
100
+
101
+ def perform(self, element_index: int, action: str = "AXPress") -> None:
102
+ self._host.perform_action(element_index, action)
103
+
104
+ def parameterized(self, element_index: int, attribute: str, parameter: Any) -> Any:
105
+ error, value = AS.AXUIElementCopyParameterizedAttributeValue(
106
+ self._host._element(element_index), attribute, parameter, None
107
+ )
108
+ if error != 0:
109
+ raise RuntimeError(
110
+ f"Parameterized AX read {attribute!r} failed with AXError {error}"
111
+ )
112
+ return self._host._jsonable(value)
113
+
114
+ def raw(self, element_index: int) -> Any:
115
+ return self._host._element(element_index)
116
+
117
+ def dump(self, app: str, **kwargs: Any) -> dict[str, Any]:
118
+ kwargs.setdefault("screenshot", False)
119
+ return self._host.get_app_state(app, **kwargs)