mtrx-cli 0.1.13 → 0.1.15

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtrx-cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "MATRX CLI for routing Codex, Claude, and Cursor through Matrx",
5
5
  "homepage": "https://mtrx.so",
6
6
  "repository": {
@@ -27,7 +27,9 @@
27
27
  "src/matrx/cli/__init__.py",
28
28
  "src/matrx/cli/cursor_ca.py",
29
29
  "src/matrx/cli/cursor_config.py",
30
+ "src/matrx/cli/cursor_hooks.py",
30
31
  "src/matrx/cli/cursor_daemon.py",
32
+ "src/matrx/cli/cursor_launcher.py",
31
33
  "src/matrx/cli/cursor_proxy.py",
32
34
  "src/matrx/cli/cursor_service.py",
33
35
  "src/matrx/cli/launcher.py",
@@ -1 +1 @@
1
- __version__ = "0.1.13"
1
+ __version__ = "0.1.15"
@@ -0,0 +1,186 @@
1
+ """
2
+ Cursor Hooks integration for MTRX telemetry.
3
+
4
+ Uses Cursor's official Hooks API (https://cursor.com/docs/hooks) to send
5
+ session/agent events to MTRX. Works regardless of how Cursor was launched
6
+ (Dock, Spotlight, CLI) - same clean model as Claude/Codex.
7
+
8
+ Configures ~/.cursor/hooks.json and a Python script that POSTs to
9
+ POST /v1/telemetry/cursor/hooks.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from matrx.cli.state import config_dir
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _CURSOR_HOME = Path.home() / ".cursor"
24
+ _HOOKS_JSON = _CURSOR_HOME / "hooks.json"
25
+ _HOOKS_DIR = _CURSOR_HOME / "hooks"
26
+ _MTRX_HOOK_SCRIPT = _HOOKS_DIR / "mtrx-telemetry.py"
27
+ _CONFIG_PATH = "cursor-hooks-config.json"
28
+
29
+ _MTRX_HOOK_MARKER = "# MTRX cursor hooks (managed by mtrx cursor)"
30
+ _MTRX_HOOKS_KEY = "_mtrx_managed_hooks"
31
+
32
+
33
+ def _hooks_config_path() -> Path:
34
+ return config_dir() / _CONFIG_PATH
35
+
36
+
37
+ def _read_hooks_json() -> dict:
38
+ if not _HOOKS_JSON.exists():
39
+ return {"version": 1, "hooks": {}}
40
+ try:
41
+ return json.loads(_HOOKS_JSON.read_text(encoding="utf-8"))
42
+ except (json.JSONDecodeError, OSError) as exc:
43
+ logger.debug("cursor_hooks: could not read hooks.json: %s", exc)
44
+ return {"version": 1, "hooks": {}}
45
+
46
+
47
+ def _write_hooks_json(data: dict) -> bool:
48
+ try:
49
+ _HOOKS_JSON.parent.mkdir(parents=True, exist_ok=True)
50
+ _HOOKS_JSON.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
51
+ return True
52
+ except OSError as exc:
53
+ logger.debug("cursor_hooks: could not write hooks.json: %s", exc)
54
+ return False
55
+
56
+
57
+ def _hook_script_content() -> str:
58
+ return '''#!/usr/bin/env python3
59
+ """MTRX Cursor hooks telemetry — forwards events to MTRX. Managed by mtrx cursor."""
60
+ from __future__ import annotations
61
+
62
+ import json
63
+ import os
64
+ import sys
65
+ from pathlib import Path
66
+
67
+ def _config_path() -> Path:
68
+ config_dir = Path(os.environ.get("MTRX_CONFIG_DIR", Path.home() / ".config" / "mtrx"))
69
+ return config_dir / "cursor-hooks-config.json"
70
+
71
+ def main() -> None:
72
+ try:
73
+ payload = json.load(sys.stdin)
74
+ except (json.JSONDecodeError, EOFError):
75
+ print("{}")
76
+ return
77
+ cfg_path = _config_path()
78
+ if not cfg_path.exists():
79
+ print("{}")
80
+ return
81
+ try:
82
+ cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
83
+ except (json.JSONDecodeError, OSError):
84
+ print("{}")
85
+ return
86
+ url = (cfg.get("matrx_base_url") or "").rstrip("/") + "/v1/telemetry/cursor/hooks"
87
+ key = (cfg.get("matrx_key") or "").strip()
88
+ if not url or not key or not key.startswith("mx_"):
89
+ print("{}")
90
+ return
91
+ try:
92
+ import urllib.request
93
+ req = urllib.request.Request(
94
+ url,
95
+ data=json.dumps(payload).encode(),
96
+ headers={"Content-Type": "application/json", "X-Matrx-Key": key},
97
+ method="POST",
98
+ )
99
+ with urllib.request.urlopen(req, timeout=10) as resp:
100
+ pass
101
+ except Exception:
102
+ pass
103
+ print("{}")
104
+
105
+ if __name__ == "__main__":
106
+ main()
107
+ '''
108
+
109
+
110
+ def install_mtrx_hooks(matrx_key: str, matrx_base_url: str) -> bool:
111
+ """
112
+ Install MTRX hooks: create script, config, and merge into hooks.json.
113
+
114
+ Hooks: sessionEnd, stop — fire-and-forget telemetry to MTRX.
115
+ """
116
+ base = (matrx_base_url or "").rstrip("/")
117
+ if not base or not matrx_key or not matrx_key.startswith("mx_"):
118
+ return False
119
+
120
+ # Write hook script
121
+ _HOOKS_DIR.mkdir(parents=True, exist_ok=True)
122
+ script_path = _HOOKS_DIR / "mtrx-telemetry.py"
123
+ try:
124
+ script_path.write_text(_hook_script_content(), encoding="utf-8")
125
+ script_path.chmod(0o755)
126
+ except OSError as exc:
127
+ logger.debug("cursor_hooks: could not write script: %s", exc)
128
+ return False
129
+
130
+ # Write config (script reads this at runtime)
131
+ cfg_path = _hooks_config_path()
132
+ config_dir().mkdir(parents=True, exist_ok=True)
133
+ try:
134
+ cfg_path.write_text(
135
+ json.dumps({"matrx_key": matrx_key, "matrx_base_url": base}),
136
+ encoding="utf-8",
137
+ )
138
+ cfg_path.chmod(0o600)
139
+ except OSError as exc:
140
+ logger.debug("cursor_hooks: could not write config: %s", exc)
141
+ return False
142
+
143
+ # Merge into hooks.json — use ./hooks/mtrx-telemetry.py (relative to ~/.cursor/)
144
+ hooks = _read_hooks_json()
145
+ if "hooks" not in hooks:
146
+ hooks["hooks"] = {}
147
+ if "version" not in hooks:
148
+ hooks["version"] = 1
149
+
150
+ managed = {
151
+ "sessionEnd": [{"command": "./hooks/mtrx-telemetry.py"}],
152
+ "stop": [{"command": "./hooks/mtrx-telemetry.py"}],
153
+ }
154
+ for hook_name, defs in managed.items():
155
+ existing = hooks["hooks"].get(hook_name) or []
156
+ # Avoid duplicate
157
+ our_cmd = "./hooks/mtrx-telemetry.py"
158
+ if not any(d.get("command") == our_cmd for d in existing):
159
+ existing.extend(defs)
160
+ hooks["hooks"][hook_name] = existing
161
+
162
+ return _write_hooks_json(hooks)
163
+
164
+
165
+ def remove_mtrx_hooks() -> bool:
166
+ """Remove MTRX hooks from hooks.json and delete config + script."""
167
+ removed = False
168
+ hooks = _read_hooks_json()
169
+ if "hooks" in hooks:
170
+ our_cmd = "./hooks/mtrx-telemetry.py"
171
+ for hook_name in ("sessionEnd", "stop"):
172
+ defs = hooks["hooks"].get(hook_name) or []
173
+ new_defs = [d for d in defs if d.get("command") != our_cmd]
174
+ if len(new_defs) != len(defs):
175
+ removed = True
176
+ hooks["hooks"][hook_name] = new_defs if new_defs else []
177
+ if removed:
178
+ _write_hooks_json(hooks)
179
+ _hooks_config_path().unlink(missing_ok=True)
180
+ _MTRX_HOOK_SCRIPT.unlink(missing_ok=True)
181
+ return removed
182
+
183
+
184
+ def is_mtrx_hooks_installed() -> bool:
185
+ """Return True if MTRX hooks are configured."""
186
+ return _hooks_config_path().exists() and _MTRX_HOOK_SCRIPT.exists()
@@ -0,0 +1,106 @@
1
+ """
2
+ Cross-platform launcher for Cursor IDE with proxy environment variables.
3
+
4
+ Cursor must be started with HTTP_PROXY, HTTPS_PROXY, and NODE_EXTRA_CA_CERTS
5
+ for the MITM proxy to work. Launching from Dock/Spotlight does not pass
6
+ these env vars; this module finds the Cursor executable and launches it
7
+ with the correct environment on any platform.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import platform
15
+ import shutil
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _cursor_executable_darwin() -> str | None:
23
+ """macOS: /Applications/Cursor.app/Contents/MacOS/Cursor."""
24
+ app = Path("/Applications/Cursor.app")
25
+ exe = app / "Contents" / "MacOS" / "Cursor"
26
+ if exe.exists():
27
+ return str(exe)
28
+ return None
29
+
30
+
31
+ def _cursor_executable_linux() -> str | None:
32
+ """Linux: cursor in PATH or common install locations."""
33
+ cursor = shutil.which("cursor")
34
+ if cursor:
35
+ return cursor
36
+ for path in (
37
+ Path.home() / ".local" / "share" / "cursor" / "bin" / "cursor",
38
+ Path.home() / ".local" / "bin" / "cursor",
39
+ ):
40
+ if path.exists():
41
+ return str(path)
42
+ return None
43
+
44
+
45
+ def _cursor_executable_windows() -> str | None:
46
+ """Windows: Cursor.exe in Local AppData."""
47
+ local = os.environ.get("LOCALAPPDATA", "")
48
+ if local:
49
+ for parts in (("Programs", "cursor", "Cursor.exe"), ("Cursor", "Cursor.exe")):
50
+ exe = Path(local) / Path(*parts)
51
+ if exe.exists():
52
+ return str(exe)
53
+ alt = Path(local) / "cursor" / "Cursor.exe"
54
+ if alt.exists():
55
+ return str(alt)
56
+ return None
57
+
58
+
59
+ def find_cursor_executable() -> str | None:
60
+ """Return the path to the Cursor executable for the current platform."""
61
+ system = platform.system()
62
+ if system == "Darwin":
63
+ return _cursor_executable_darwin()
64
+ if system == "Linux":
65
+ return _cursor_executable_linux()
66
+ if system == "Windows":
67
+ return _cursor_executable_windows()
68
+ return None
69
+
70
+
71
+ def launch_cursor_with_proxy(
72
+ proxy_url: str,
73
+ ca_cert_path: str,
74
+ ) -> bool:
75
+ """
76
+ Launch Cursor with HTTP_PROXY, HTTPS_PROXY, and NODE_EXTRA_CA_CERTS set.
77
+
78
+ Returns True if Cursor was launched, False if executable not found.
79
+ """
80
+ exe = find_cursor_executable()
81
+ if not exe:
82
+ return False
83
+
84
+ env = os.environ.copy()
85
+ env["HTTP_PROXY"] = proxy_url
86
+ env["HTTPS_PROXY"] = proxy_url
87
+ env["NODE_EXTRA_CA_CERTS"] = str(ca_cert_path)
88
+
89
+ kwargs: dict = {}
90
+ if platform.system() == "Windows":
91
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
92
+
93
+ try:
94
+ subprocess.Popen(
95
+ [exe],
96
+ env=env,
97
+ stdin=subprocess.DEVNULL,
98
+ stdout=subprocess.DEVNULL,
99
+ stderr=subprocess.DEVNULL,
100
+ start_new_session=(platform.system() != "Windows"),
101
+ **kwargs,
102
+ )
103
+ return True
104
+ except OSError as exc:
105
+ logger.warning("Failed to launch Cursor: %s", exc)
106
+ return False
@@ -120,6 +120,11 @@ def _build_parser() -> argparse.ArgumentParser:
120
120
  cursor.add_argument("--route", choices=["direct", "matrx"])
121
121
  cursor.add_argument("--status", action="store_true", help="Check proxy status")
122
122
  cursor.add_argument("--stop", action="store_true", help="Stop the proxy service")
123
+ cursor.add_argument(
124
+ "--launch",
125
+ action="store_true",
126
+ help="Launch Cursor with proxy env (required for traffic to flow)",
127
+ )
123
128
 
124
129
  return parser
125
130
 
@@ -501,8 +506,13 @@ def _cmd_use(args) -> int:
501
506
  def _restore_cursor_if_needed() -> None:
502
507
  import json as _json
503
508
 
509
+ from matrx.cli.cursor_hooks import remove_mtrx_hooks
504
510
  from matrx.cli.cursor_service import is_proxy_running, uninstall_service
505
511
 
512
+ # Remove MTRX Cursor hooks
513
+ if remove_mtrx_hooks():
514
+ print("Cursor hooks removed.")
515
+
506
516
  # Stop the MITM proxy service if it's running
507
517
  if is_proxy_running():
508
518
  uninstall_service()
@@ -831,39 +841,23 @@ def _cmd_launch(tool: str, route: str | None, remainder: list[str]) -> int:
831
841
 
832
842
 
833
843
  def _cmd_cursor(args) -> int:
834
- import json as _json
835
-
836
- from matrx.cli.cursor_ca import (
837
- ca_cert_path,
838
- ca_exists,
839
- generate_ca,
840
- is_ca_trusted,
841
- trust_ca_system,
842
- )
843
- from matrx.cli.cursor_proxy import DEFAULT_PORT, PROXY_HOST
844
- from matrx.cli.cursor_service import (
845
- get_proxy_status,
846
- install_service,
847
- is_proxy_running,
848
- uninstall_service,
849
- )
844
+ from matrx.cli.cursor_hooks import install_mtrx_hooks, is_mtrx_hooks_installed
850
845
 
851
846
  route = args.route
852
847
 
853
- # --status: just report proxy health
848
+ # --status: report hooks status
854
849
  if args.status:
855
- status = get_proxy_status()
856
- if status:
857
- print("MTRX Cursor proxy: running")
858
- print(f" requests processed: {status.get('requests', '?')}")
850
+ if is_mtrx_hooks_installed():
851
+ print("MTRX Cursor hooks: active")
852
+ print(" sessionEnd, stop telemetry to MTRX")
859
853
  else:
860
- print("MTRX Cursor proxy: not running")
854
+ print("MTRX Cursor hooks: not installed")
861
855
  return 0
862
856
 
863
- # --stop: tear down the proxy
857
+ # --stop: tear down
864
858
  if args.stop:
865
859
  _restore_cursor_if_needed()
866
- print("Cursor route set to direct — MTRX proxy disabled.")
860
+ print("Cursor route set to direct — MTRX hooks disabled.")
867
861
  return 0
868
862
 
869
863
  state = load_state()
@@ -872,12 +866,12 @@ def _cmd_cursor(args) -> int:
872
866
 
873
867
  if effective_route == "direct":
874
868
  _restore_cursor_if_needed()
875
- print("Cursor route set to direct — MTRX proxy disabled.")
869
+ print("Cursor route set to direct — MTRX hooks disabled.")
876
870
  if cursor_is_running():
877
871
  print(" Restart Cursor for settings to take effect.")
878
872
  return 0
879
873
 
880
- # --- matrx route: set up the MITM proxy ---
874
+ # --- matrx route: install Cursor Hooks (official, works like Claude/Codex) ---
881
875
 
882
876
  try:
883
877
  state, login_changed = _complete_matrx_login(state)
@@ -910,66 +904,17 @@ def _cmd_cursor(args) -> int:
910
904
  "Use `mtrx use cursor direct` to opt out.",
911
905
  )
912
906
 
913
- # Step 1: Generate CA certificate if needed
914
- if not ca_exists():
915
- print("Generating MTRX CA certificate...")
916
- generate_ca()
917
- print(f" CA cert: {ca_cert_path()}")
918
-
919
- # Step 2: Trust CA (one-time)
920
- if not is_ca_trusted():
921
- print("Trusting MTRX CA certificate (may require password)...")
922
- if trust_ca_system():
923
- print(" CA trusted in system keychain.")
924
- else:
925
- print(
926
- f" [warn] Could not auto-trust CA. Cursor needs NODE_EXTRA_CA_CERTS={ca_cert_path()}"
927
- )
928
- print(
929
- " You can manually trust it or set the env var before launching Cursor."
930
- )
931
-
932
- # Step 3: Install and start the proxy service
933
- proxy_url = f"http://{PROXY_HOST}:{DEFAULT_PORT}"
934
- if is_proxy_running():
935
- print(f"MTRX proxy already running on {proxy_url}")
936
- else:
937
- print("Starting MTRX Cursor proxy service...")
938
- if install_service(
939
- matrx_key=mx_key,
940
- matrx_base_url=matrx_base_url,
941
- host=PROXY_HOST,
942
- port=DEFAULT_PORT,
943
- ):
944
- print(f" Proxy running on {proxy_url}")
945
- else:
946
- print("[warn] Proxy service may not have started. Check logs at:")
947
- print(f" {config_dir() / 'logs' / 'cursor-proxy.err.log'}")
948
-
949
- # Step 4: Configure Cursor's settings.json
950
- conf_dir = config_dir()
951
- conf_dir.mkdir(parents=True, exist_ok=True)
952
-
953
- previous = configure_cursor_proxy_settings(
954
- proxy_url=proxy_url,
955
- ca_cert_path=str(ca_cert_path()),
956
- )
957
- prev_path = conf_dir / "cursor-proxy-previous-settings.json"
958
- prev_path.write_text(_json.dumps(previous), encoding="utf-8")
959
-
960
- print()
961
- print("Cursor configured to route ALL traffic through MTRX.")
962
- print(f" proxy: {proxy_url}")
963
- print(f" ca_cert: {ca_cert_path()}")
964
- print(f" telemetry: {matrx_base_url}/v1/telemetry/cursor")
965
- print()
966
- if cursor_is_running():
967
- print(" Cursor is running — restart it for settings to take effect.")
907
+ if install_mtrx_hooks(mx_key, matrx_base_url):
908
+ print()
909
+ print("Cursor configured with MTRX hooks — Chat & Agent usage flows to MTRX.")
910
+ print(f" telemetry: {matrx_base_url}/v1/telemetry/cursor/hooks")
911
+ print()
912
+ print(" Works with Cursor from Dock, Spotlight, or CLI — no special launch needed.")
913
+ print(" Check status: mtrx cursor --status")
914
+ print(" To disable: mtrx use cursor direct")
968
915
  else:
969
- print(" Settings will apply next time Cursor starts.")
970
- print()
971
- print(" Check status: mtrx cursor --status")
972
- print(" To disable: mtrx use cursor direct")
916
+ print("[warn] Could not install Cursor hooks. Check ~/.cursor/ is writable.", file=sys.stderr)
917
+ return 1
973
918
  return 0
974
919
 
975
920