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.
@@ -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__"]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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())