macos-computer-use-kit 0.2.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_computer_use/__init__.py +9 -0
- macos_computer_use/__main__.py +4 -0
- macos_computer_use/ax.py +363 -0
- macos_computer_use/cli.py +204 -0
- macos_computer_use/darwin.py +261 -0
- macos_computer_use/input_events.py +172 -0
- macos_computer_use/jev.py +223 -0
- macos_computer_use/overlay.py +148 -0
- macos_computer_use/paste.py +111 -0
- macos_computer_use/shot.py +87 -0
- macos_computer_use_kit-0.2.0.dist-info/METADATA +308 -0
- macos_computer_use_kit-0.2.0.dist-info/RECORD +15 -0
- macos_computer_use_kit-0.2.0.dist-info/WHEEL +4 -0
- macos_computer_use_kit-0.2.0.dist-info/entry_points.txt +2 -0
- macos_computer_use_kit-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""AX-first computer-use toolkit for AI agents on macOS.
|
|
2
|
+
|
|
3
|
+
The public surface is the ``macos-cu`` command line; every subcommand prints a
|
|
4
|
+
single JSON object (or JSON lines) so an agent can consume the result directly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0"
|
|
8
|
+
|
|
9
|
+
__all__ = ["__version__"]
|
macos_computer_use/ax.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Accessibility (AX) tree reading, semantic targeting, and native AX actions.
|
|
2
|
+
|
|
3
|
+
Inspired by Codex's CUA repl: instead of screenshot -> vision -> estimate
|
|
4
|
+
coordinates, read the macOS accessibility tree and get exact element geometry.
|
|
5
|
+
`press`/`setvalue` additionally verify that the action changed observable state
|
|
6
|
+
before reporting success.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from . import darwin
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ax():
|
|
22
|
+
AS, _NSWorkspace, _Quartz = darwin._pyobjc() # noqa: N806
|
|
23
|
+
return AS
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def attr(el, name):
|
|
27
|
+
AS = _ax() # noqa: N806
|
|
28
|
+
err, value = AS.AXUIElementCopyAttributeValue(el, name, None)
|
|
29
|
+
if err != 0:
|
|
30
|
+
return None
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def point_of(el):
|
|
35
|
+
AS = _ax() # noqa: N806
|
|
36
|
+
value = attr(el, AS.kAXPositionAttribute)
|
|
37
|
+
if value is None:
|
|
38
|
+
return None
|
|
39
|
+
ok, pt = AS.AXValueGetValue(value, AS.kAXValueCGPointType, None)
|
|
40
|
+
if not ok:
|
|
41
|
+
return None
|
|
42
|
+
return (int(pt.x), int(pt.y))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def size_of(el):
|
|
46
|
+
AS = _ax() # noqa: N806
|
|
47
|
+
value = attr(el, AS.kAXSizeAttribute)
|
|
48
|
+
if value is None:
|
|
49
|
+
return None
|
|
50
|
+
ok, sz = AS.AXValueGetValue(value, AS.kAXValueCGSizeType, None)
|
|
51
|
+
if not ok:
|
|
52
|
+
return None
|
|
53
|
+
return (int(sz.width), int(sz.height))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def walk(el, depth, max_depth, out, role_filter=None, path="0"):
|
|
57
|
+
AS = _ax() # noqa: N806
|
|
58
|
+
if len(out) >= 4000 or depth > max_depth:
|
|
59
|
+
return
|
|
60
|
+
role = attr(el, AS.kAXRoleAttribute) or ""
|
|
61
|
+
title = attr(el, AS.kAXTitleAttribute) or ""
|
|
62
|
+
desc = attr(el, AS.kAXDescriptionAttribute) or ""
|
|
63
|
+
value = attr(el, AS.kAXValueAttribute)
|
|
64
|
+
value = value.replace("\n", " ")[:60] if isinstance(value, str) else None
|
|
65
|
+
pos = point_of(el)
|
|
66
|
+
size = size_of(el)
|
|
67
|
+
children = attr(el, AS.kAXChildrenAttribute) or []
|
|
68
|
+
matches = (not role_filter) or (role_filter.lower() in role.lower())
|
|
69
|
+
if matches and (title or desc or value or (size and size[0] > 4 and size[1] > 4)):
|
|
70
|
+
out.append(
|
|
71
|
+
{
|
|
72
|
+
"id": path,
|
|
73
|
+
"role": role,
|
|
74
|
+
"title": title,
|
|
75
|
+
"desc": desc,
|
|
76
|
+
"value": value,
|
|
77
|
+
"pos": pos,
|
|
78
|
+
"size": size,
|
|
79
|
+
"depth": depth,
|
|
80
|
+
"children": len(children),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
for i, child in enumerate(children[:200]):
|
|
84
|
+
walk(child, depth + 1, max_depth, out, role_filter, f"{path}.{i}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def element_at_path(el, path):
|
|
88
|
+
"""Resolve a snapshot-style path id (e.g. '0.1.0.6.0') to a live AXUIElement."""
|
|
89
|
+
AS = _ax() # noqa: N806
|
|
90
|
+
parts = str(path).split(".")
|
|
91
|
+
if not parts or parts[0] != "0":
|
|
92
|
+
return None
|
|
93
|
+
cur = el
|
|
94
|
+
for p in parts[1:]:
|
|
95
|
+
children = attr(cur, AS.kAXChildrenAttribute) or []
|
|
96
|
+
try:
|
|
97
|
+
i = int(p)
|
|
98
|
+
except ValueError:
|
|
99
|
+
return None
|
|
100
|
+
if i >= len(children):
|
|
101
|
+
return None
|
|
102
|
+
cur = children[i]
|
|
103
|
+
return cur
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def text_signature(root, limit=80):
|
|
107
|
+
"""Hash of the visible text in a window: catches pane/content changes."""
|
|
108
|
+
AS = _ax() # noqa: N806
|
|
109
|
+
texts: list[str] = []
|
|
110
|
+
|
|
111
|
+
def collect(el, depth=0):
|
|
112
|
+
if depth > 12 or len(texts) >= limit:
|
|
113
|
+
return
|
|
114
|
+
role = attr(el, AS.kAXRoleAttribute)
|
|
115
|
+
if role in ("AXStaticText", "AXButton", "AXTextField", "AXTextArea"):
|
|
116
|
+
v = attr(el, AS.kAXValueAttribute) or attr(el, AS.kAXTitleAttribute)
|
|
117
|
+
if v:
|
|
118
|
+
texts.append(str(v)[:40])
|
|
119
|
+
for c in (attr(el, AS.kAXChildrenAttribute) or [])[:80]:
|
|
120
|
+
collect(c, depth + 1)
|
|
121
|
+
|
|
122
|
+
collect(root)
|
|
123
|
+
return hashlib.md5("\n".join(texts).encode()).hexdigest()[:12], len(texts)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def app_fingerprint(root) -> dict[str, Any]:
|
|
127
|
+
"""Small observable state used to verify that an AX action had an effect."""
|
|
128
|
+
AS = _ax() # noqa: N806
|
|
129
|
+
f: dict[str, Any] = {}
|
|
130
|
+
focused = attr(root, AS.kAXFocusedUIElementAttribute)
|
|
131
|
+
if focused is not None:
|
|
132
|
+
f["focused_role"] = attr(focused, AS.kAXRoleAttribute)
|
|
133
|
+
f["focused_value"] = str(attr(focused, AS.kAXValueAttribute) or "")[:80]
|
|
134
|
+
windows = attr(root, AS.kAXWindowsAttribute) or []
|
|
135
|
+
if windows:
|
|
136
|
+
w = windows[0]
|
|
137
|
+
f["window_title"] = attr(w, AS.kAXTitleAttribute)
|
|
138
|
+
kids = attr(w, AS.kAXChildrenAttribute) or []
|
|
139
|
+
if kids:
|
|
140
|
+
k = kids[0]
|
|
141
|
+
f["pane_role"] = attr(k, AS.kAXRoleAttribute)
|
|
142
|
+
f["pane_children"] = len(attr(k, AS.kAXChildrenAttribute) or [])
|
|
143
|
+
sig, n = text_signature(w)
|
|
144
|
+
f["text_sig"] = sig
|
|
145
|
+
f["text_count"] = n
|
|
146
|
+
return f
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def locate(root, args):
|
|
150
|
+
"""Return (element, entry) for --id or role/title filters."""
|
|
151
|
+
AS = _ax() # noqa: N806
|
|
152
|
+
if getattr(args, "id", None):
|
|
153
|
+
el = element_at_path(root, args.id)
|
|
154
|
+
if el is None:
|
|
155
|
+
return None, None
|
|
156
|
+
entry = {
|
|
157
|
+
"id": args.id,
|
|
158
|
+
"role": attr(el, AS.kAXRoleAttribute),
|
|
159
|
+
"title": attr(el, AS.kAXTitleAttribute),
|
|
160
|
+
"value": attr(el, AS.kAXValueAttribute),
|
|
161
|
+
}
|
|
162
|
+
return el, entry
|
|
163
|
+
found: list[dict[str, Any]] = []
|
|
164
|
+
walk(root, 0, args.depth, found, args.role)
|
|
165
|
+
if args.title:
|
|
166
|
+
needle = args.title.lower()
|
|
167
|
+
found = [
|
|
168
|
+
e
|
|
169
|
+
for e in found
|
|
170
|
+
if needle in (e["title"] or "").lower()
|
|
171
|
+
or needle in (e["desc"] or "").lower()
|
|
172
|
+
or needle in (e["value"] or "").lower()
|
|
173
|
+
]
|
|
174
|
+
if not found:
|
|
175
|
+
return None, None
|
|
176
|
+
entry = found[0]
|
|
177
|
+
return element_at_path(root, entry["id"]), entry
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def run_action(root, args) -> dict[str, Any]:
|
|
181
|
+
AS = _ax() # noqa: N806
|
|
182
|
+
el, entry = locate(root, args)
|
|
183
|
+
if el is None:
|
|
184
|
+
return {"ok": False, "reason": "element_not_found"}
|
|
185
|
+
|
|
186
|
+
coords = None
|
|
187
|
+
pos = point_of(el)
|
|
188
|
+
size = size_of(el)
|
|
189
|
+
if pos and size:
|
|
190
|
+
coords = [pos[0] + size[0] // 2, pos[1] + size[1] // 2]
|
|
191
|
+
|
|
192
|
+
before = app_fingerprint(root)
|
|
193
|
+
|
|
194
|
+
if args.cmd == "press":
|
|
195
|
+
err = AS.AXUIElementPerformAction(el, AS.kAXPressAction)
|
|
196
|
+
time.sleep(0.4)
|
|
197
|
+
after = app_fingerprint(root)
|
|
198
|
+
changed = before != after
|
|
199
|
+
result = {
|
|
200
|
+
"ok": True,
|
|
201
|
+
"action": "ax_press",
|
|
202
|
+
"err": int(err),
|
|
203
|
+
"verified": bool(err == 0 and changed),
|
|
204
|
+
"state_changed": changed,
|
|
205
|
+
"before": before,
|
|
206
|
+
"after": after,
|
|
207
|
+
"coords": coords,
|
|
208
|
+
"element": {"id": entry.get("id"), "role": entry.get("role"), "title": entry.get("title")},
|
|
209
|
+
}
|
|
210
|
+
if not result["verified"]:
|
|
211
|
+
result["hint"] = (
|
|
212
|
+
"AXPress did not change observable state; the element may be custom-drawn. "
|
|
213
|
+
"Fall back to a coordinate click (macos-cu input click) after re-reading geometry."
|
|
214
|
+
)
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
err = AS.AXUIElementSetAttributeValue(el, AS.kAXValueAttribute, args.text)
|
|
218
|
+
time.sleep(0.2)
|
|
219
|
+
readback = attr(el, AS.kAXValueAttribute)
|
|
220
|
+
result = {
|
|
221
|
+
"ok": True,
|
|
222
|
+
"action": "ax_set_value",
|
|
223
|
+
"err": int(err),
|
|
224
|
+
"verified": bool(err == 0 and str(readback) == args.text),
|
|
225
|
+
"readback": str(readback)[:80] if readback is not None else None,
|
|
226
|
+
"coords": coords,
|
|
227
|
+
"element": {"id": entry.get("id"), "role": entry.get("role"), "title": entry.get("title")},
|
|
228
|
+
}
|
|
229
|
+
if not result["verified"]:
|
|
230
|
+
result["hint"] = (
|
|
231
|
+
"AXValue is not settable here; use `macos-cu paste` (clipboard paste + verify) instead."
|
|
232
|
+
)
|
|
233
|
+
return result
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def snapshot_cache_dir() -> str:
|
|
237
|
+
base = os.environ.get("MACOS_CU_CACHE_DIR") or os.path.join(
|
|
238
|
+
os.path.expanduser("~"), ".cache", "macos-computer-use"
|
|
239
|
+
)
|
|
240
|
+
path = os.path.join(base, "snapshots")
|
|
241
|
+
os.makedirs(path, exist_ok=True)
|
|
242
|
+
return path
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def app_root(args):
|
|
246
|
+
"""Resolve --pid/--app to an AX application element."""
|
|
247
|
+
AS = _ax() # noqa: N806
|
|
248
|
+
if getattr(args, "pid", None):
|
|
249
|
+
return AS.AXUIElementCreateApplication(int(args.pid)), int(args.pid)
|
|
250
|
+
app = darwin.find_app(args.app)
|
|
251
|
+
if app is None:
|
|
252
|
+
return None, None
|
|
253
|
+
return AS.AXUIElementCreateApplication(app.processIdentifier()), app
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def run(args) -> int:
|
|
257
|
+
out: list[dict[str, Any]] = []
|
|
258
|
+
|
|
259
|
+
if args.cmd == "resolve":
|
|
260
|
+
if not args.file or not args.id:
|
|
261
|
+
print(json.dumps({"error": "resolve needs --file and --id"}), file=sys.stderr)
|
|
262
|
+
return 2
|
|
263
|
+
with open(args.file) as fh:
|
|
264
|
+
data = json.load(fh)
|
|
265
|
+
for e in data["elements"]:
|
|
266
|
+
if e["id"] == args.id:
|
|
267
|
+
print(json.dumps(e, ensure_ascii=False))
|
|
268
|
+
return 0
|
|
269
|
+
print(json.dumps({"error": "id_not_found", "id": args.id}), file=sys.stderr)
|
|
270
|
+
return 3
|
|
271
|
+
|
|
272
|
+
if not darwin.permissions()["accessibility"]:
|
|
273
|
+
print(json.dumps({"error": "accessibility_not_granted", "hint": darwin.permission_hint("accessibility")}), file=sys.stderr)
|
|
274
|
+
return 2
|
|
275
|
+
|
|
276
|
+
if args.cmd in ("press", "setvalue") and not (args.pid or args.app):
|
|
277
|
+
print(json.dumps({"error": "press/setvalue need --app or --pid"}), file=sys.stderr)
|
|
278
|
+
return 2
|
|
279
|
+
if args.cmd == "setvalue" and args.text is None:
|
|
280
|
+
print(json.dumps({"error": "setvalue needs --text"}), file=sys.stderr)
|
|
281
|
+
return 2
|
|
282
|
+
|
|
283
|
+
root, app = app_root(args)
|
|
284
|
+
if root is None:
|
|
285
|
+
print(json.dumps({"error": "app_not_found", "app": args.app}), file=sys.stderr)
|
|
286
|
+
return 2
|
|
287
|
+
|
|
288
|
+
if args.cmd in ("press", "setvalue"):
|
|
289
|
+
print(json.dumps(run_action(root, args), ensure_ascii=False))
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
walk(root, 0, args.depth, out, args.role)
|
|
293
|
+
|
|
294
|
+
if args.title:
|
|
295
|
+
needle = args.title.lower()
|
|
296
|
+
out = [
|
|
297
|
+
e
|
|
298
|
+
for e in out
|
|
299
|
+
if needle in (e["title"] or "").lower()
|
|
300
|
+
or needle in (e["desc"] or "").lower()
|
|
301
|
+
or needle in (e["value"] or "").lower()
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
if args.cmd == "click-info" and args.index is not None:
|
|
305
|
+
out = [out[args.index]] if 0 <= args.index < len(out) else []
|
|
306
|
+
elif args.cmd in ("find", "tree", "click-info"):
|
|
307
|
+
# Cap results for these modes; `snapshot` is bounded by --budget instead.
|
|
308
|
+
out = out[: args.max]
|
|
309
|
+
|
|
310
|
+
app_name = (
|
|
311
|
+
app.localizedName()
|
|
312
|
+
if hasattr(app, "localizedName")
|
|
313
|
+
else darwin.find_app(args.app).localizedName() if args.app else f"pid:{args.pid}"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
result = []
|
|
317
|
+
for i, e in enumerate(out):
|
|
318
|
+
entry = dict(e)
|
|
319
|
+
entry["idx"] = i
|
|
320
|
+
if e["pos"] and e["size"]:
|
|
321
|
+
cx = e["pos"][0] + e["size"][0] // 2
|
|
322
|
+
cy = e["pos"][1] + e["size"][1] // 2
|
|
323
|
+
entry["center_screen"] = [cx, cy]
|
|
324
|
+
# `shot` space only exists for harnesses whose screenshots are
|
|
325
|
+
# scaled differently from screen points; opt in with --shot-scale.
|
|
326
|
+
if args.shot_scale:
|
|
327
|
+
entry["center_shot"] = [round(cx * args.shot_scale), round(cy * args.shot_scale)]
|
|
328
|
+
result.append(entry)
|
|
329
|
+
|
|
330
|
+
if args.json:
|
|
331
|
+
print(json.dumps({"app": app_name, "count": len(result), "elements": result}, ensure_ascii=False))
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
if args.cmd == "snapshot":
|
|
335
|
+
lines = []
|
|
336
|
+
used = 0
|
|
337
|
+
omitted = 0
|
|
338
|
+
for e in result:
|
|
339
|
+
t = (e["title"] or e["desc"] or e["value"] or "")[:60]
|
|
340
|
+
loc = f"@screen{e['center_screen']}" if e.get("center_screen") else ""
|
|
341
|
+
sz = f"{e['size'][0]}x{e['size'][1]}" if e["size"] else "-"
|
|
342
|
+
line = f"[{e['id']}] {e['role']:<22} {sz:>10} {loc:<22} {t}"
|
|
343
|
+
if used + len(line) + 1 > args.budget:
|
|
344
|
+
omitted += 1
|
|
345
|
+
continue
|
|
346
|
+
lines.append(line)
|
|
347
|
+
used += len(line) + 1
|
|
348
|
+
cache = os.path.join(snapshot_cache_dir(), f"{app_name}-{int(time.time())}.json")
|
|
349
|
+
with open(cache, "w") as fh:
|
|
350
|
+
json.dump({"app": app_name, "elements": result}, fh, ensure_ascii=False)
|
|
351
|
+
print(f"# snapshot: {cache} | elements={len(result)} shown={len(lines)} omitted={omitted} (budget={args.budget})")
|
|
352
|
+
print("\n".join(lines))
|
|
353
|
+
return 0
|
|
354
|
+
|
|
355
|
+
print(f"app={app_name} elements={len(result)}")
|
|
356
|
+
for e in result:
|
|
357
|
+
t = (e["title"] or e["desc"] or e["value"] or "")[:50]
|
|
358
|
+
loc = f"@screen{e['center_screen']}" if e.get("center_screen") else ""
|
|
359
|
+
if e.get("center_shot"):
|
|
360
|
+
loc += f" shot{e['center_shot']}"
|
|
361
|
+
sz = f"{e['size'][0]}x{e['size'][1]}" if e["size"] else "-"
|
|
362
|
+
print(f"[{e['idx']:>3}] {e['role']:<24} {sz:>10} {loc:<34} {t}")
|
|
363
|
+
return 0
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Command line interface: ``macos-cu <group> <command> [options]``.
|
|
2
|
+
|
|
3
|
+
Every command prints JSON so an agent can consume it directly. Exit codes are
|
|
4
|
+
stable and meaningful:
|
|
5
|
+
|
|
6
|
+
- 0 success
|
|
7
|
+
- 2 usage error, unsupported platform, or missing permission
|
|
8
|
+
- 3 not found / capture failed / upstream HTTP error
|
|
9
|
+
- 4 target app not found (paste) or capture failed
|
|
10
|
+
- 5 target_changed (window signature mismatch) or paste conflict
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from . import __version__, darwin
|
|
22
|
+
|
|
23
|
+
EXIT_USAGE = 2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _add_common(p: argparse.ArgumentParser) -> None:
|
|
27
|
+
p.add_argument("--json", action="store_true", help="emit structured JSON (where the command has a text mode)")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
31
|
+
ap = argparse.ArgumentParser(
|
|
32
|
+
prog="macos-cu",
|
|
33
|
+
description="AX-first computer-use toolkit for macOS agents.",
|
|
34
|
+
epilog="Run `macos-cu doctor` first to check permissions and display geometry.",
|
|
35
|
+
)
|
|
36
|
+
ap.add_argument("--version", action="version", version=f"macos-computer-use-kit {__version__}")
|
|
37
|
+
groups = ap.add_subparsers(dest="group", required=True)
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------- ax
|
|
40
|
+
ax = groups.add_parser("ax", help="read the accessibility tree and run native AX actions")
|
|
41
|
+
_add_common(ax)
|
|
42
|
+
ax.add_argument("cmd", choices=["tree", "find", "click-info", "snapshot", "resolve", "press", "setvalue"])
|
|
43
|
+
ax.add_argument("--app", default=None, help="app name or bundle id (substring match)")
|
|
44
|
+
ax.add_argument("--pid", type=int, default=None, help="target a specific process instead of --app")
|
|
45
|
+
ax.add_argument("--depth", type=int, default=16)
|
|
46
|
+
ax.add_argument("--max", type=int, default=120, help="cap rows printed (tree/find)")
|
|
47
|
+
ax.add_argument("--role", default=None, help="AX role filter, e.g. AXButton")
|
|
48
|
+
ax.add_argument("--title", default=None, help="substring match on title/description/value")
|
|
49
|
+
ax.add_argument("--index", type=int, default=None, help="element index for click-info")
|
|
50
|
+
ax.add_argument(
|
|
51
|
+
"--shot-scale",
|
|
52
|
+
type=float,
|
|
53
|
+
default=None,
|
|
54
|
+
help="also emit center_shot = round(center_screen * SCALE). Only needed when your harness's "
|
|
55
|
+
"screenshots use a different scale than screen points; there is no safe default.",
|
|
56
|
+
)
|
|
57
|
+
ax.add_argument("--budget", type=int, default=4000, help="max characters of snapshot text")
|
|
58
|
+
ax.add_argument("--file", default=None, help="snapshot cache file (for resolve)")
|
|
59
|
+
ax.add_argument("--id", default=None, help="element id inside a snapshot (for resolve)")
|
|
60
|
+
ax.add_argument("--text", default=None, help="text to write (for setvalue)")
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------- input
|
|
63
|
+
inp = groups.add_parser("input", help="process/window-scoped input (the physical cursor never moves)")
|
|
64
|
+
inp.add_argument("cmd", choices=["windows", "cursor", "pid", "click", "key", "scroll", "move"])
|
|
65
|
+
inp.add_argument("--app")
|
|
66
|
+
inp.add_argument("--pid", type=int)
|
|
67
|
+
inp.add_argument("--window-id", type=int, help="target a window; --x/--y become window-relative")
|
|
68
|
+
inp.add_argument("--expect", help="expected signature pid:wid:x:y:w:h; mismatch refuses with target_changed")
|
|
69
|
+
inp.add_argument("--show", action="store_true", help="draw a visual ring at the action point")
|
|
70
|
+
inp.add_argument("--x", type=int)
|
|
71
|
+
inp.add_argument("--y", type=int)
|
|
72
|
+
inp.add_argument("--button", default="left", choices=["left", "right", "middle"])
|
|
73
|
+
inp.add_argument("--count", type=int, default=1)
|
|
74
|
+
inp.add_argument("--key")
|
|
75
|
+
inp.add_argument("--flags", default="", help="modifier flags, e.g. cmd+shift")
|
|
76
|
+
inp.add_argument("--amount", type=int, default=-5, help="scroll lines: negative = up, positive = down")
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------- paste
|
|
79
|
+
paste = groups.add_parser("paste", help="clipboard-safe paste that restores the user's clipboard")
|
|
80
|
+
paste.add_argument("--app")
|
|
81
|
+
paste.add_argument("--pid", type=int)
|
|
82
|
+
paste.add_argument("--text", required=True)
|
|
83
|
+
paste.add_argument("--mode", choices=["pid", "hid"], default="pid",
|
|
84
|
+
help="pid = post to the app (cursor untouched); hid = system-wide")
|
|
85
|
+
paste.add_argument("--wait", type=float, default=1.5, help="seconds to wait before checking consumption")
|
|
86
|
+
paste.add_argument("--keep", action="store_true", help="leave our text on the clipboard")
|
|
87
|
+
|
|
88
|
+
# -------------------------------------------------------------- shot
|
|
89
|
+
shot = groups.add_parser("shot", help="screenshots with blank-frame detection")
|
|
90
|
+
shot.add_argument("cmd", choices=["capture", "check", "windows"])
|
|
91
|
+
shot.add_argument("--file")
|
|
92
|
+
shot.add_argument("--out")
|
|
93
|
+
shot.add_argument("--window-id", type=int)
|
|
94
|
+
shot.add_argument("--app")
|
|
95
|
+
shot.add_argument("--region", help="x,y,w,h")
|
|
96
|
+
|
|
97
|
+
# ----------------------------------------------------------- overlay
|
|
98
|
+
overlay = groups.add_parser("overlay", help="transient visual feedback ring")
|
|
99
|
+
overlay.add_argument("cmd", choices=["show", "clear"])
|
|
100
|
+
overlay.add_argument("--x", type=int)
|
|
101
|
+
overlay.add_argument("--y", type=int)
|
|
102
|
+
overlay.add_argument("--label", default="")
|
|
103
|
+
overlay.add_argument("--duration", type=float, default=1.5)
|
|
104
|
+
overlay.add_argument("--color", default="cyan", choices=["cyan", "green", "orange", "red"])
|
|
105
|
+
|
|
106
|
+
# --------------------------------------------------------------- jev
|
|
107
|
+
jev = groups.add_parser("jev", help="optional TypeSafe System One semantic guards (JSON on stdin)")
|
|
108
|
+
jev.add_argument("cmd", choices=["guard", "select"])
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------ doctor
|
|
111
|
+
groups.add_parser("doctor", help="diagnose permissions, displays, dependencies, and Jev setup")
|
|
112
|
+
|
|
113
|
+
return ap
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _pyobjc_version() -> str | None:
|
|
117
|
+
try:
|
|
118
|
+
import objc
|
|
119
|
+
|
|
120
|
+
return getattr(objc, "__version__", None)
|
|
121
|
+
except Exception:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def doctor() -> int:
|
|
126
|
+
perms = {"accessibility": False, "screen_recording": False}
|
|
127
|
+
perm_error = None
|
|
128
|
+
try:
|
|
129
|
+
perms = darwin.permissions()
|
|
130
|
+
except SystemExit as exc: # dependency missing
|
|
131
|
+
perm_error = str(exc.code)
|
|
132
|
+
|
|
133
|
+
info: dict[str, Any] = {
|
|
134
|
+
"version": __version__,
|
|
135
|
+
"platform": {"system": sys.platform, "macos": darwin.macos_version(), "arch": os.uname().machine},
|
|
136
|
+
"python": sys.version.split()[0],
|
|
137
|
+
"pyobjc": _pyobjc_version(),
|
|
138
|
+
"permissions": perms,
|
|
139
|
+
"displays": [],
|
|
140
|
+
"jev": {"key_present": bool(darwin.jev_key()), "model": os.environ.get("TYPESAFE_MODEL", "jev-latest")},
|
|
141
|
+
"hints": [],
|
|
142
|
+
}
|
|
143
|
+
try:
|
|
144
|
+
info["displays"] = darwin.displays()
|
|
145
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
146
|
+
info["displays_error"] = str(exc)[:200]
|
|
147
|
+
|
|
148
|
+
if perm_error:
|
|
149
|
+
info["hints"].append("Install dependencies: pip install 'macos-computer-use-kit'")
|
|
150
|
+
else:
|
|
151
|
+
if not perms.get("accessibility"):
|
|
152
|
+
info["hints"].append(darwin.permission_hint("accessibility"))
|
|
153
|
+
if not perms.get("screen_recording"):
|
|
154
|
+
info["hints"].append(darwin.permission_hint("screen_recording"))
|
|
155
|
+
if not info["jev"]["key_present"]:
|
|
156
|
+
info["hints"].append(
|
|
157
|
+
"Jev is optional. To enable semantic guards, set TYPESAFE_API_KEY or write "
|
|
158
|
+
"~/.config/typesafe/api_key (https://console.typesafe.ai/keys)."
|
|
159
|
+
)
|
|
160
|
+
if not info["hints"]:
|
|
161
|
+
info["hints"].append("All checks passed. Try: macos-cu ax tree --app Finder --max 20")
|
|
162
|
+
|
|
163
|
+
print(json.dumps(info, ensure_ascii=False, indent=2))
|
|
164
|
+
return 0 if (perms.get("accessibility") and perms.get("screen_recording")) else 1
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main(argv: list[str] | None = None) -> int:
|
|
168
|
+
args = build_parser().parse_args(argv)
|
|
169
|
+
|
|
170
|
+
if args.group == "doctor":
|
|
171
|
+
return doctor()
|
|
172
|
+
|
|
173
|
+
darwin.require_macos()
|
|
174
|
+
|
|
175
|
+
if args.group == "ax":
|
|
176
|
+
from . import ax
|
|
177
|
+
|
|
178
|
+
return ax.run(args)
|
|
179
|
+
if args.group == "input":
|
|
180
|
+
from . import input_events
|
|
181
|
+
|
|
182
|
+
return input_events.run(args)
|
|
183
|
+
if args.group == "paste":
|
|
184
|
+
from . import paste
|
|
185
|
+
|
|
186
|
+
return paste.run(args)
|
|
187
|
+
if args.group == "shot":
|
|
188
|
+
from . import shot
|
|
189
|
+
|
|
190
|
+
return shot.run(args)
|
|
191
|
+
if args.group == "overlay":
|
|
192
|
+
from . import overlay
|
|
193
|
+
|
|
194
|
+
return overlay.run(args)
|
|
195
|
+
if args.group == "jev":
|
|
196
|
+
from . import jev
|
|
197
|
+
|
|
198
|
+
return jev.run(args)
|
|
199
|
+
|
|
200
|
+
return EXIT_USAGE
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
sys.exit(main())
|