mtrx-cli 0.1.14 → 0.1.16

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.14",
3
+ "version": "0.1.16",
4
4
  "description": "MATRX CLI for routing Codex, Claude, and Cursor through Matrx",
5
5
  "homepage": "https://mtrx.so",
6
6
  "repository": {
@@ -27,6 +27,7 @@
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",
31
32
  "src/matrx/cli/cursor_launcher.py",
32
33
  "src/matrx/cli/cursor_proxy.py",
@@ -1 +1 @@
1
- __version__ = "0.1.14"
1
+ __version__ = "0.1.16"
@@ -388,7 +388,7 @@ def restore_cursor_settings(
388
388
  return ok
389
389
 
390
390
 
391
- def print_manual_setup_instructions(proxy_url: str) -> None:
391
+ def print_manual_setup_instructions(proxy_url: str, *, api_key_hint: str = "your Matrx key (mx_...)") -> None:
392
392
  """Print step-by-step instructions for the user to configure Cursor manually."""
393
393
  print()
394
394
  print(" Could not auto-configure Cursor settings.")
@@ -397,7 +397,7 @@ def print_manual_setup_instructions(proxy_url: str) -> None:
397
397
  print(" 1. Open Cursor Settings (Cmd+, or Ctrl+,)")
398
398
  print(" 2. Go to Models")
399
399
  print(" 3. In the OpenAI API Keys section:")
400
- print(f" - API Key: mtrx-cursor-proxy")
400
+ print(f" - API Key: {api_key_hint}")
401
401
  print(f" - Override Base URL: {proxy_url}")
402
402
  print(" - Toggle ON 'Override OpenAI Base URL'")
403
403
  print()
@@ -0,0 +1,213 @@
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 datetime import datetime, timezone
66
+ from pathlib import Path
67
+
68
+ def _config_path() -> Path:
69
+ config_dir = Path(os.environ.get("MTRX_CONFIG_DIR", Path.home() / ".config" / "mtrx"))
70
+ return config_dir / "cursor-hooks-config.json"
71
+
72
+ def _log_path() -> Path:
73
+ return _config_path().parent / "logs" / "cursor-hooks.log"
74
+
75
+ def _log(msg: str) -> None:
76
+ try:
77
+ log_file = _log_path()
78
+ log_file.parent.mkdir(parents=True, exist_ok=True)
79
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
80
+ with log_file.open("a") as f:
81
+ f.write(f"{ts} {msg}\n")
82
+ except OSError:
83
+ pass
84
+
85
+ def main() -> None:
86
+ try:
87
+ payload = json.load(sys.stdin)
88
+ except (json.JSONDecodeError, EOFError):
89
+ print("{}")
90
+ return
91
+ event = payload.get("hook_event_name", "?")
92
+ conv = (payload.get("conversation_id") or payload.get("session_id") or "?")[:16]
93
+ cfg_path = _config_path()
94
+ if not cfg_path.exists():
95
+ _log(f"{event} conv={conv} skip=no_config")
96
+ print("{}")
97
+ return
98
+ try:
99
+ cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
100
+ except (json.JSONDecodeError, OSError):
101
+ _log(f"{event} conv={conv} skip=config_error")
102
+ print("{}")
103
+ return
104
+ url = (cfg.get("matrx_base_url") or "").rstrip("/") + "/v1/telemetry/cursor/hooks"
105
+ key = (cfg.get("matrx_key") or "").strip()
106
+ if not url or not key or not key.startswith("mx_"):
107
+ print("{}")
108
+ return
109
+ try:
110
+ import urllib.request
111
+ req = urllib.request.Request(
112
+ url,
113
+ data=json.dumps(payload).encode(),
114
+ headers={"Content-Type": "application/json", "X-Matrx-Key": key},
115
+ method="POST",
116
+ )
117
+ with urllib.request.urlopen(req, timeout=10) as resp:
118
+ status = resp.status
119
+ _log(f"{event} conv={conv} ok status={status}")
120
+ except Exception as e:
121
+ _log(f"{event} conv={conv} err={type(e).__name__}")
122
+ print("{}")
123
+
124
+ if __name__ == "__main__":
125
+ main()
126
+ '''
127
+
128
+
129
+ def install_mtrx_hooks(matrx_key: str, matrx_base_url: str) -> bool:
130
+ """
131
+ Install MTRX hooks: create script, config, and merge into hooks.json.
132
+
133
+ Hooks: sessionEnd, stop — fire-and-forget telemetry to MTRX.
134
+ """
135
+ base = (matrx_base_url or "").rstrip("/")
136
+ if not base or not matrx_key or not matrx_key.startswith("mx_"):
137
+ return False
138
+
139
+ # Write hook script
140
+ try:
141
+ _HOOKS_DIR.mkdir(parents=True, exist_ok=True)
142
+ except PermissionError as exc:
143
+ logger.debug("cursor_hooks: permission denied: %s", exc)
144
+ raise ValueError(
145
+ "Cannot create ~/.cursor/hooks — directory may be owned by root. "
146
+ "Run: sudo chown -R $(whoami) ~/.cursor"
147
+ ) from exc
148
+
149
+ script_path = _HOOKS_DIR / "mtrx-telemetry.py"
150
+ try:
151
+ script_path.write_text(_hook_script_content(), encoding="utf-8")
152
+ script_path.chmod(0o755)
153
+ except OSError as exc:
154
+ logger.debug("cursor_hooks: could not write script: %s", exc)
155
+ return False
156
+
157
+ # Write config (script reads this at runtime)
158
+ cfg_path = _hooks_config_path()
159
+ config_dir().mkdir(parents=True, exist_ok=True)
160
+ try:
161
+ cfg_path.write_text(
162
+ json.dumps({"matrx_key": matrx_key, "matrx_base_url": base}),
163
+ encoding="utf-8",
164
+ )
165
+ cfg_path.chmod(0o600)
166
+ except OSError as exc:
167
+ logger.debug("cursor_hooks: could not write config: %s", exc)
168
+ return False
169
+
170
+ # Merge into hooks.json — use ./hooks/mtrx-telemetry.py (relative to ~/.cursor/)
171
+ hooks = _read_hooks_json()
172
+ if "hooks" not in hooks:
173
+ hooks["hooks"] = {}
174
+ if "version" not in hooks:
175
+ hooks["version"] = 1
176
+
177
+ managed = {
178
+ "sessionEnd": [{"command": "./hooks/mtrx-telemetry.py"}],
179
+ "stop": [{"command": "./hooks/mtrx-telemetry.py"}],
180
+ }
181
+ for hook_name, defs in managed.items():
182
+ existing = hooks["hooks"].get(hook_name) or []
183
+ # Avoid duplicate
184
+ our_cmd = "./hooks/mtrx-telemetry.py"
185
+ if not any(d.get("command") == our_cmd for d in existing):
186
+ existing.extend(defs)
187
+ hooks["hooks"][hook_name] = existing
188
+
189
+ return _write_hooks_json(hooks)
190
+
191
+
192
+ def remove_mtrx_hooks() -> bool:
193
+ """Remove MTRX hooks from hooks.json and delete config + script."""
194
+ removed = False
195
+ hooks = _read_hooks_json()
196
+ if "hooks" in hooks:
197
+ our_cmd = "./hooks/mtrx-telemetry.py"
198
+ for hook_name in ("sessionEnd", "stop"):
199
+ defs = hooks["hooks"].get(hook_name) or []
200
+ new_defs = [d for d in defs if d.get("command") != our_cmd]
201
+ if len(new_defs) != len(defs):
202
+ removed = True
203
+ hooks["hooks"][hook_name] = new_defs if new_defs else []
204
+ if removed:
205
+ _write_hooks_json(hooks)
206
+ _hooks_config_path().unlink(missing_ok=True)
207
+ _MTRX_HOOK_SCRIPT.unlink(missing_ok=True)
208
+ return removed
209
+
210
+
211
+ def is_mtrx_hooks_installed() -> bool:
212
+ """Return True if MTRX hooks are configured."""
213
+ return _hooks_config_path().exists() and _MTRX_HOOK_SCRIPT.exists()
@@ -31,6 +31,7 @@ from typing import Any
31
31
  import httpx
32
32
 
33
33
  from matrx.cli.cursor_ca import CertCache, load_ca
34
+ from matrx.cli.cursor_reroute import is_ai_path, try_reroute_to_matrx
34
35
 
35
36
  logger = logging.getLogger(__name__)
36
37
 
@@ -259,7 +260,11 @@ class MITMProxy:
259
260
  up_writer: asyncio.StreamWriter,
260
261
  hostname: str,
261
262
  ) -> None:
262
- """Forward HTTP/1.1 request-response pairs, logging each to telemetry."""
263
+ """Forward HTTP/1.1 request-response pairs, logging each to telemetry.
264
+
265
+ For AI paths (RunSSE, StreamCpp, etc.), attempts to reroute through MTRX
266
+ for live injection. If reroute succeeds, responds from MTRX; else forwards.
267
+ """
263
268
  while True:
264
269
  req_line = await client_reader.readline()
265
270
  if not req_line:
@@ -272,15 +277,62 @@ class MITMProxy:
272
277
  method = parts[0] if parts else "?"
273
278
  path = parts[1] if len(parts) > 1 else "/"
274
279
 
275
- up_writer.write(req_line)
276
-
277
- req_headers, req_cl, req_chunked = await self._forward_headers(
278
- client_reader, up_writer
279
- )
280
-
281
- req_body_size = await self._forward_body(
282
- client_reader, up_writer, req_cl, req_chunked
283
- )
280
+ req_body_size = 0
281
+ # For AI paths: buffer request and try rerouting through MTRX (live injection)
282
+ if method == "POST" and is_ai_path(path):
283
+ req_headers, req_cl, req_chunked = await self._read_headers_only(
284
+ client_reader
285
+ )
286
+ req_body = await self._read_body_to_bytes(
287
+ client_reader, req_cl, req_chunked
288
+ )
289
+ req_body_size = len(req_body)
290
+ result = await try_reroute_to_matrx(
291
+ path=path,
292
+ method=method,
293
+ req_headers=req_headers,
294
+ req_body=req_body,
295
+ matrx_base_url=self.matrx_base_url,
296
+ matrx_key=self.matrx_key,
297
+ session_id=str(uuid.uuid4()),
298
+ )
299
+ if result is not None:
300
+ success, resp_headers, resp_body, is_streaming = result
301
+ if success and resp_body is not None:
302
+ self._request_count += 1
303
+ self._write_http_response(
304
+ client_writer, 200, resp_headers, resp_body
305
+ )
306
+ asyncio.create_task(
307
+ self._ship_telemetry(
308
+ hostname=hostname,
309
+ method=method,
310
+ path=path,
311
+ status_code=200,
312
+ req_body_size=len(req_body),
313
+ resp_body_size=len(resp_body),
314
+ elapsed_ms=0,
315
+ content_type=resp_headers.get("content-type", ""),
316
+ is_streaming=is_streaming,
317
+ )
318
+ )
319
+ continue
320
+ # Reroute returned but failed — fall through to forward
321
+ # Reroute not implemented or failed — forward to upstream
322
+ up_writer.write(req_line)
323
+ await self._write_headers(up_writer, req_headers)
324
+ up_writer.write(req_body)
325
+ await up_writer.drain()
326
+ else:
327
+ up_writer.write(req_line)
328
+ req_headers, req_cl, req_chunked = await self._forward_headers(
329
+ client_reader, up_writer
330
+ )
331
+ req_body_size = await self._forward_body(
332
+ client_reader, up_writer, req_cl, req_chunked
333
+ )
334
+ if req_body_size == 0 and req_cl > 0:
335
+ req_body_size = req_cl
284
336
 
285
337
  started = time.monotonic()
286
338
 
@@ -336,6 +388,76 @@ class MITMProxy:
336
388
  if "close" in conn_h:
337
389
  break
338
390
 
391
+ async def _read_headers_only(
392
+ self, reader: asyncio.StreamReader
393
+ ) -> tuple[dict[str, str], int, bool]:
394
+ """Read headers without writing. Returns (headers_dict, content_length, is_chunked)."""
395
+ headers: dict[str, str] = {}
396
+ content_length = -1
397
+ chunked = False
398
+ while True:
399
+ line = await reader.readline()
400
+ decoded = line.decode("utf-8", errors="replace").strip()
401
+ if not decoded:
402
+ break
403
+ if ":" in decoded:
404
+ k, _, v = decoded.partition(":")
405
+ k_lower = k.strip().lower()
406
+ v_stripped = v.strip()
407
+ headers[k_lower] = v_stripped
408
+ if k_lower == "content-length":
409
+ content_length = int(v_stripped)
410
+ elif k_lower == "transfer-encoding" and "chunked" in v_stripped.lower():
411
+ chunked = True
412
+ return headers, content_length, chunked
413
+
414
+ async def _read_body_to_bytes(
415
+ self,
416
+ reader: asyncio.StreamReader,
417
+ content_length: int,
418
+ chunked: bool,
419
+ ) -> bytes:
420
+ """Read body into bytes (no writer)."""
421
+ if content_length > 0:
422
+ return await reader.read(content_length)
423
+ if chunked:
424
+ parts: list[bytes] = []
425
+ while True:
426
+ size_line = await reader.readline()
427
+ size_str = size_line.decode("utf-8", errors="replace").strip()
428
+ try:
429
+ chunk_size = int(size_str.split(";")[0], 16)
430
+ except ValueError:
431
+ break
432
+ if chunk_size == 0:
433
+ await reader.readline() # trailer
434
+ break
435
+ parts.append(await reader.read(chunk_size))
436
+ await reader.readline() # crlf
437
+ return b"".join(parts)
438
+ return b""
439
+
440
+ def _write_headers(
441
+ self, writer: asyncio.StreamWriter, headers: dict[str, str]
442
+ ) -> None:
443
+ """Write headers as HTTP lines (caller must drain)."""
444
+ for k, v in headers.items():
445
+ writer.write(f"{k}: {v}\r\n".encode())
446
+ writer.write(b"\r\n")
447
+
448
+ def _write_http_response(
449
+ self,
450
+ writer: asyncio.StreamWriter,
451
+ status: int,
452
+ resp_headers: dict[str, str],
453
+ resp_body: bytes,
454
+ ) -> None:
455
+ """Write a complete HTTP response."""
456
+ writer.write(f"HTTP/1.1 {status} OK\r\n".encode())
457
+ self._write_headers(writer, resp_headers)
458
+ writer.write(resp_body)
459
+ # Caller should drain
460
+
339
461
  async def _forward_headers(
340
462
  self,
341
463
  reader: asyncio.StreamReader,
@@ -434,6 +556,81 @@ class MITMProxy:
434
556
  await writer.drain()
435
557
  return total
436
558
 
559
+ async def _read_headers_only(
560
+ self, reader: asyncio.StreamReader
561
+ ) -> tuple[dict[str, str], int, bool]:
562
+ """Read headers from reader without writing. Returns (headers, content_length, chunked)."""
563
+ headers: dict[str, str] = {}
564
+ content_length = -1
565
+ chunked = False
566
+ while True:
567
+ line = await reader.readline()
568
+ decoded = line.decode("utf-8", errors="replace").strip()
569
+ if not decoded:
570
+ break
571
+ if ":" in decoded:
572
+ k, _, v = decoded.partition(":")
573
+ k_lower = k.strip().lower()
574
+ v_stripped = v.strip()
575
+ headers[k_lower] = v_stripped
576
+ if k_lower == "content-length":
577
+ content_length = int(v_stripped)
578
+ elif k_lower == "transfer-encoding" and "chunked" in v_stripped.lower():
579
+ chunked = True
580
+ return headers, content_length, chunked
581
+
582
+ async def _read_body_to_bytes(
583
+ self,
584
+ reader: asyncio.StreamReader,
585
+ content_length: int,
586
+ chunked: bool,
587
+ ) -> bytes:
588
+ """Read body into bytes."""
589
+ if content_length > 0:
590
+ return await reader.readexactly(content_length)
591
+ if chunked:
592
+ parts: list[bytes] = []
593
+ while True:
594
+ size_line = await reader.readline()
595
+ size_str = size_line.decode("utf-8", errors="replace").strip()
596
+ try:
597
+ chunk_size = int(size_str.split(";")[0], 16)
598
+ except ValueError:
599
+ break
600
+ if chunk_size == 0:
601
+ await reader.readline() # trailer
602
+ break
603
+ parts.append(await reader.readexactly(chunk_size))
604
+ await reader.readline() # crlf
605
+ return b"".join(parts)
606
+ return b""
607
+
608
+ def _write_headers(
609
+ self, writer: asyncio.StreamWriter, headers: dict[str, str]
610
+ ) -> None:
611
+ """Write HTTP headers to writer."""
612
+ for k, v in headers.items():
613
+ # Capitalize header key (e.g. content-type -> Content-Type)
614
+ name = "-".join(p.capitalize() for p in k.split("-"))
615
+ writer.write(f"{name}: {v}\r\n".encode())
616
+ writer.write(b"\r\n")
617
+
618
+ def _write_http_response(
619
+ self,
620
+ writer: asyncio.StreamWriter,
621
+ status: int,
622
+ headers: dict[str, str],
623
+ body: bytes,
624
+ ) -> None:
625
+ """Write a complete HTTP response."""
626
+ writer.write(f"HTTP/1.1 {status} OK\r\n".encode())
627
+ if "content-length" not in {k.lower() for k in headers} and body:
628
+ headers = dict(headers)
629
+ headers["Content-Length"] = str(len(body))
630
+ self._write_headers(writer, headers)
631
+ writer.write(body)
632
+ # Note: drain is caller's responsibility
633
+
437
634
  # -----------------------------------------------------------------
438
635
  # Raw bidirectional pipe (for opaque tunnels)
439
636
  # -----------------------------------------------------------------
@@ -85,6 +85,8 @@ def _runtime_agent_basename(tool: str) -> tuple[str, str, list[str], str]:
85
85
  return "codex-cli", "Codex CLI", ["cli", "codex"], "codex"
86
86
  if tool == "claude":
87
87
  return "claude-cli", "Claude CLI", ["claude", "cli"], "claude_code"
88
+ if tool == "gemini":
89
+ return "gemini-cli", "Gemini CLI", ["gemini", "cli"], "gemini_code"
88
90
  normalized = f"{tool}-cli"
89
91
  return normalized, f"{tool.capitalize()} CLI", ["cli", tool], tool
90
92
 
@@ -117,6 +119,8 @@ def find_executable(tool: str) -> str | None:
117
119
  candidates.extend(["claude.exe", "claude.cmd"])
118
120
  if tool == "codex":
119
121
  candidates.extend(["codex.exe", "codex.cmd"])
122
+ if tool == "gemini":
123
+ candidates.extend(["gemini.exe", "gemini.cmd"])
120
124
  for candidate in candidates:
121
125
  found = shutil.which(candidate)
122
126
  if found:
@@ -163,6 +167,13 @@ def build_launch_plan(
163
167
  env,
164
168
  orchestration=orchestration,
165
169
  )
170
+ elif tool == "gemini":
171
+ env, auth_source = _build_gemini_env(
172
+ state,
173
+ route,
174
+ env,
175
+ orchestration=orchestration,
176
+ )
166
177
  else:
167
178
  raise ValueError(f"Unsupported tool: {tool}")
168
179
 
@@ -229,6 +240,8 @@ def validate_launch_plan(plan: LaunchPlan, state: dict) -> None:
229
240
  _validate_claude_launch_plan(plan, state)
230
241
  if plan.tool == "codex":
231
242
  _validate_codex_launch_plan(plan, state)
243
+ if plan.tool == "gemini":
244
+ _validate_gemini_launch_plan(plan, state)
232
245
 
233
246
 
234
247
  def claude_credentials_path() -> Path:
@@ -519,6 +532,59 @@ def _build_codex_env(
519
532
  return env, "existing_codex_auth", passthrough_args
520
533
 
521
534
 
535
+ def _build_gemini_env(
536
+ state: dict,
537
+ route: str,
538
+ env: dict[str, str],
539
+ *,
540
+ orchestration: dict | None = None,
541
+ ) -> tuple[dict[str, str], str]:
542
+ matrx = state["auth"]["matrx"]
543
+ # Assuming we might store Gemini-specific keys in future, or use OpenAI key fallback
544
+ # For now, we don't have a specific 'gemini' auth section in state.py, but we can assume
545
+ # if direct route, we use env var.
546
+ proxy_base = ensure_v1_url(matrx.get("base_url"))
547
+ mx_key, matrx_auth_source = _resolve_matrx_route_key(state, env)
548
+
549
+ # Check for direct key in env or potentially saved elsewhere
550
+ direct_key = (env.get("GOOGLE_API_KEY") or "").strip()
551
+
552
+ if route == "matrx":
553
+ if not mx_key:
554
+ raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
555
+
556
+ # Clear existing Gemini config to force proxy usage
557
+ env.pop("MTRX_KEY", None)
558
+
559
+ # Set Proxy Config
560
+ env["GOOGLE_GEMINI_BASE_URL"] = proxy_base
561
+ env["GEMINI_API_ENDPOINT"] = proxy_base
562
+ env["GOOGLE_API_KEY"] = mx_key
563
+
564
+ # Matrx-specific headers (if supported by the tool, or for our own tracking)
565
+ # Note: Standard Gemini CLI might not support custom headers via env vars easily.
566
+ # We rely on the Base URL routing to Matrx proxy which handles the logic.
567
+
568
+ return env, matrx_auth_source
569
+
570
+ # Direct route: clear any matrx-managed env vars
571
+ env.pop("MTRX_KEY", None)
572
+
573
+ # Clear proxy overrides
574
+ _clear_if_matches(env, "GOOGLE_GEMINI_BASE_URL", proxy_base)
575
+ _clear_if_matches(env, "GEMINI_API_ENDPOINT", proxy_base)
576
+
577
+ # Clear key if it was the Matrx key
578
+ current_key = (env.get("GOOGLE_API_KEY") or "").strip()
579
+ if current_key == mx_key or current_key.startswith("mx_"):
580
+ env.pop("GOOGLE_API_KEY", None)
581
+
582
+ if env.get("GOOGLE_API_KEY"):
583
+ return env, "existing_google_env"
584
+
585
+ return env, "missing_auth"
586
+
587
+
522
588
  def _build_claude_env(
523
589
  state: dict,
524
590
  route: str,
@@ -765,6 +831,31 @@ def _validate_claude_launch_plan(plan: LaunchPlan, state: dict) -> None:
765
831
  raise ValueError("Claude Matrx route should not set ANTHROPIC_API_KEY")
766
832
 
767
833
 
834
+ def _validate_gemini_launch_plan(plan: LaunchPlan, state: dict) -> None:
835
+ if plan.route != "matrx":
836
+ return
837
+
838
+ expected_base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
839
+
840
+ base_url = (plan.env.get("GOOGLE_GEMINI_BASE_URL") or "").strip()
841
+ if not base_url:
842
+ # Try the other one
843
+ base_url = (plan.env.get("GEMINI_API_ENDPOINT") or "").strip()
844
+
845
+ if not base_url:
846
+ raise ValueError("Gemini Matrx route is missing GOOGLE_GEMINI_BASE_URL or GEMINI_API_ENDPOINT")
847
+
848
+ if base_url != expected_base_url:
849
+ raise ValueError(
850
+ "Gemini Matrx route must use the Matrx /v1 base URL. "
851
+ f"Got: {base_url}"
852
+ )
853
+
854
+ mx_key = (plan.env.get("GOOGLE_API_KEY") or "").strip()
855
+ if not mx_key.startswith("mx_"):
856
+ raise ValueError("Gemini Matrx route is missing a valid GOOGLE_API_KEY (should be mx_...)")
857
+
858
+
768
859
  def _validate_codex_launch_plan(plan: LaunchPlan, state: dict) -> None:
769
860
  if plan.route != "matrx":
770
861
  return
@@ -825,6 +916,16 @@ def describe_launch_plan(plan: LaunchPlan, state: dict) -> list[str]:
825
916
  " persistent_route: disabled",
826
917
  ]
827
918
 
919
+ if plan.tool == "gemini":
920
+ base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
921
+ return [
922
+ "Launching gemini via Matrx",
923
+ f" base_url: {base_url}",
924
+ f" auth_source: {plan.auth_source}",
925
+ " runtime_route: env injection",
926
+ " persistent_route: disabled",
927
+ ]
928
+
828
929
  return []
829
930
 
830
931
 
@@ -881,6 +982,9 @@ def _sync_tool_route_config(state: dict, *, tool: str, route: str) -> bool:
881
982
  return _cleanup_claude_managed_config(state)
882
983
  if tool == "codex":
883
984
  return _sync_codex_route_config(state, route=route)
985
+ if tool == "gemini":
986
+ # Gemini currently relies on env vars, no config file sync implemented yet.
987
+ return False
884
988
  return False
885
989
 
886
990
 
@@ -74,7 +74,7 @@ def main(argv: list[str] | None = None) -> int:
74
74
  return _cmd_doctor()
75
75
  if args.command == "personal":
76
76
  return _cmd_personal(args)
77
- if args.command in {"codex", "claude"}:
77
+ if args.command in {"codex", "claude", "gemini"}:
78
78
  return _cmd_launch(args.command, args.route, remainder)
79
79
  if args.command == "cursor":
80
80
  return _cmd_cursor(args)
@@ -97,7 +97,7 @@ def _build_parser() -> argparse.ArgumentParser:
97
97
  login.add_argument("--import", dest="do_import", action="store_true")
98
98
 
99
99
  use = subparsers.add_parser("use")
100
- use.add_argument("tool", choices=["codex", "claude", "cursor"])
100
+ use.add_argument("tool", choices=["codex", "claude", "cursor", "gemini"])
101
101
  use.add_argument("route", choices=["direct", "matrx"])
102
102
 
103
103
  subparsers.add_parser("help")
@@ -116,6 +116,9 @@ def _build_parser() -> argparse.ArgumentParser:
116
116
  claude = subparsers.add_parser("claude")
117
117
  claude.add_argument("--route", choices=["direct", "matrx"])
118
118
 
119
+ gemini = subparsers.add_parser("gemini")
120
+ gemini.add_argument("--route", choices=["direct", "matrx"])
121
+
119
122
  cursor = subparsers.add_parser("cursor")
120
123
  cursor.add_argument("--route", choices=["direct", "matrx"])
121
124
  cursor.add_argument("--status", action="store_true", help="Check proxy status")
@@ -506,8 +509,13 @@ def _cmd_use(args) -> int:
506
509
  def _restore_cursor_if_needed() -> None:
507
510
  import json as _json
508
511
 
512
+ from matrx.cli.cursor_hooks import remove_mtrx_hooks
509
513
  from matrx.cli.cursor_service import is_proxy_running, uninstall_service
510
514
 
515
+ # Remove MTRX Cursor hooks
516
+ if remove_mtrx_hooks():
517
+ print("Cursor hooks removed.")
518
+
511
519
  # Stop the MITM proxy service if it's running
512
520
  if is_proxy_running():
513
521
  uninstall_service()
@@ -548,6 +556,7 @@ def _cmd_status() -> int:
548
556
  print("Defaults:")
549
557
  print(f" codex: {_default_route_label(configured_route(state, 'codex'))}")
550
558
  print(f" claude: {_default_route_label(configured_route(state, 'claude'))}")
559
+ print(f" gemini: {_default_route_label(configured_route(state, 'gemini'))}")
551
560
  print(f" cursor: {_default_route_label(configured_route(state, 'cursor'))}")
552
561
  print("Auth:")
553
562
  print(
@@ -562,7 +571,7 @@ def _cmd_status() -> int:
562
571
  local = "present" if claude_oauth_available() else "missing"
563
572
  print(f" claude-code oauth: {imported}, local credentials: {local}")
564
573
  print("Tool config:")
565
- for tool in ("codex", "claude"):
574
+ for tool in ("codex", "claude", "gemini"):
566
575
  config_status = get_tool_config_status(state, tool)
567
576
  route = configured_route(state, tool)
568
577
  if tool == "codex" and config_status["verified"] and not config_status["configured"]:
@@ -584,6 +593,7 @@ def _cmd_status() -> int:
584
593
  print("Executables:")
585
594
  print(f" codex: {find_executable('codex') or 'not found'}")
586
595
  print(f" claude: {find_executable('claude') or 'not found'}")
596
+ print(f" gemini: {find_executable('gemini') or 'not found'}")
587
597
  profiles = _legacy_shell_proxy_profiles()
588
598
  active_env = _active_claude_proxy_env()
589
599
  if profiles or active_env:
@@ -698,7 +708,7 @@ def _cmd_doctor() -> int:
698
708
  workspace_binding = get_workspace_binding(state, cwd=os.environ.get("PWD") or os.getcwd()) or {}
699
709
  workspace_matrx_key = (workspace_binding.get("matrx_key") or "").strip()
700
710
 
701
- for tool in ("codex", "claude"):
711
+ for tool in ("codex", "claude", "gemini"):
702
712
  found = find_executable(tool)
703
713
  if found:
704
714
  print(f"[ok] {tool} executable: {found}")
@@ -763,7 +773,7 @@ def _cmd_doctor() -> int:
763
773
  "[warn] Cleanup: `unfunction claude 2>/dev/null || true` and `unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN MATRX_ACTIVE_ROUTE MATRX_BASE_URL MATRX_API_KEY MATRX_CLAUDE_MODE MATRX_FALLBACK_ENABLED MATRX_PROXY_TIMEOUT_SEC ANTHROPIC_DIRECT_BASE_URL`",
764
774
  )
765
775
 
766
- for tool in ("codex", "claude", "cursor"):
776
+ for tool in ("codex", "claude", "gemini", "cursor"):
767
777
  route = configured_route(state, tool)
768
778
  if route == "matrx" and not _has_matrx_login(state, env=os.environ):
769
779
  print(f"[fail] Default {tool} route is matrx but no Matrx key is saved")
@@ -836,43 +846,29 @@ def _cmd_launch(tool: str, route: str | None, remainder: list[str]) -> int:
836
846
 
837
847
 
838
848
  def _cmd_cursor(args) -> int:
839
- import json as _json
840
-
841
- from matrx.cli.cursor_ca import (
842
- ca_cert_path,
843
- ca_exists,
844
- generate_ca,
845
- is_ca_trusted,
846
- trust_ca_system,
847
- )
848
- from matrx.cli.cursor_proxy import DEFAULT_PORT, PROXY_HOST
849
- from matrx.cli.cursor_launcher import (
850
- find_cursor_executable,
851
- launch_cursor_with_proxy,
852
- )
853
- from matrx.cli.cursor_service import (
854
- get_proxy_status,
855
- install_service,
856
- is_proxy_running,
857
- uninstall_service,
858
- )
849
+ from matrx.cli.cursor_hooks import install_mtrx_hooks, is_mtrx_hooks_installed
850
+ from matrx.cli.cursor_launcher import find_cursor_executable
859
851
 
860
852
  route = args.route
861
853
 
862
- # --status: just report proxy health
854
+ # --status: report Base URL override + hooks status
863
855
  if args.status:
864
- status = get_proxy_status()
865
- if status:
866
- print("MTRX Cursor proxy: running")
867
- print(f" requests processed: {status.get('requests', '?')}")
868
- else:
869
- print("MTRX Cursor proxy: not running")
856
+ state = load_state()
857
+ hooks_installed = is_mtrx_hooks_installed()
858
+ base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
859
+ prev_path = config_dir() / "cursor-previous-settings.json"
860
+ configured = prev_path.exists()
861
+ print("MTRX Cursor integration:")
862
+ print(f" mode: {'Base URL override (all models)' if configured else 'not configured'}")
863
+ print(f" hooks: {'active (sessionEnd, stop → telemetry)' if hooks_installed else 'not installed'}")
864
+ if configured:
865
+ print(f" matrx: {base_url}")
870
866
  return 0
871
867
 
872
- # --stop: tear down the proxy
868
+ # --stop: tear down
873
869
  if args.stop:
874
870
  _restore_cursor_if_needed()
875
- print("Cursor route set to direct — MTRX proxy disabled.")
871
+ print("Cursor route set to direct — MTRX disabled.")
876
872
  return 0
877
873
 
878
874
  state = load_state()
@@ -886,7 +882,7 @@ def _cmd_cursor(args) -> int:
886
882
  print(" Restart Cursor for settings to take effect.")
887
883
  return 0
888
884
 
889
- # --- matrx route: set up the MITM proxy ---
885
+ # --- matrx route: Base URL override (works with any Cursor model: Claude, GPT, Gemini, etc.) ---
890
886
 
891
887
  try:
892
888
  state, login_changed = _complete_matrx_login(state)
@@ -910,6 +906,7 @@ def _cmd_cursor(args) -> int:
910
906
  matrx_base_url = ensure_root_url(
911
907
  state.get("auth", {}).get("matrx", {}).get("base_url")
912
908
  )
909
+ matrx_proxy_url = ensure_v1_url(matrx_base_url)
913
910
 
914
911
  if initialized or login_changed or promoted:
915
912
  save_state(state)
@@ -919,73 +916,40 @@ def _cmd_cursor(args) -> int:
919
916
  "Use `mtrx use cursor direct` to opt out.",
920
917
  )
921
918
 
922
- # Step 1: Generate CA certificate if needed
923
- if not ca_exists():
924
- print("Generating MTRX CA certificate...")
925
- generate_ca()
926
- print(f" CA cert: {ca_cert_path()}")
927
-
928
- # Step 2: Trust CA (one-time)
929
- if not is_ca_trusted():
930
- print("Trusting MTRX CA certificate (may require password)...")
931
- if trust_ca_system():
932
- print(" CA trusted in system keychain.")
933
- else:
934
- print(
935
- f" [warn] Could not auto-trust CA. Cursor needs NODE_EXTRA_CA_CERTS={ca_cert_path()}"
936
- )
937
- print(
938
- " You can manually trust it or set the env var before launching Cursor."
939
- )
940
-
941
- # Step 3: Install and start the proxy service
942
- proxy_url = f"http://{PROXY_HOST}:{DEFAULT_PORT}"
943
- if is_proxy_running():
944
- print(f"MTRX proxy already running on {proxy_url}")
919
+ # Configure Cursor's Override Base URL — sends chat to MTRX (any model: Claude, GPT-5, Gemini, etc.)
920
+ prev_path = config_dir() / "cursor-previous-settings.json"
921
+ previous = configure_cursor_for_proxy(matrx_proxy_url, mx_key)
922
+ if previous is not None:
923
+ prev_path.write_text(__import__("json").dumps(previous), encoding="utf-8")
945
924
  else:
946
- print("Starting MTRX Cursor proxy service...")
947
- if install_service(
948
- matrx_key=mx_key,
949
- matrx_base_url=matrx_base_url,
950
- host=PROXY_HOST,
951
- port=DEFAULT_PORT,
952
- ):
953
- print(f" Proxy running on {proxy_url}")
954
- else:
955
- print("[warn] Proxy service may not have started. Check logs at:")
956
- print(f" {config_dir() / 'logs' / 'cursor-proxy.err.log'}")
957
-
958
- # Step 4: Configure Cursor's settings.json
959
- conf_dir = config_dir()
960
- conf_dir.mkdir(parents=True, exist_ok=True)
925
+ print(
926
+ "[warn] Could not write Cursor state.vscdb. Try manual setup:",
927
+ file=sys.stderr,
928
+ )
929
+ print_manual_setup_instructions(matrx_proxy_url, api_key_hint="your Matrx key (mx_...)")
961
930
 
962
- previous = configure_cursor_proxy_settings(
963
- proxy_url=proxy_url,
964
- ca_cert_path=str(ca_cert_path()),
965
- )
966
- prev_path = conf_dir / "cursor-proxy-previous-settings.json"
967
- prev_path.write_text(_json.dumps(previous), encoding="utf-8")
931
+ # Hooks for session telemetry
932
+ install_mtrx_hooks(mx_key, matrx_base_url)
968
933
 
969
- print()
970
- print("Cursor configured to route ALL traffic through MTRX.")
971
- print(f" proxy: {proxy_url}")
972
- print(f" ca_cert: {ca_cert_path()}")
973
- print(f" telemetry: {matrx_base_url}/v1/telemetry/cursor")
974
- print()
975
-
976
- # Launch Cursor with proxy env vars (required for traffic to flow)
934
+ # Optional: launch Cursor
977
935
  if getattr(args, "launch", False):
978
- from matrx.cli.cursor_launcher import find_cursor_executable, launch_cursor_with_proxy
979
-
980
- if launch_cursor_with_proxy(proxy_url, str(ca_cert_path())):
981
- print(" Launched Cursor with proxy env vars — traffic will flow through MTRX.")
936
+ exe = find_cursor_executable()
937
+ if exe:
938
+ import subprocess
939
+ try:
940
+ subprocess.Popen([exe], start_new_session=True)
941
+ print("Launched Cursor.")
942
+ except Exception:
943
+ print("[warn] Could not launch Cursor.", file=sys.stderr)
982
944
  else:
983
- print(" [warn] Could not launch Cursor. Is it installed?")
984
- print(" To route traffic, launch Cursor via: mtrx cursor --launch")
985
- else:
986
- print(" To route traffic, launch Cursor via: mtrx cursor --launch")
987
- print(" (Cursor must be started with proxy env vars; Dock/Spotlight launch won't work)")
945
+ print("[warn] Could not find Cursor executable.", file=sys.stderr)
946
+
947
+ print()
948
+ print("Cursor configured for MTRX chat routes through Matrx (all models).")
949
+ print(f" base URL: {matrx_proxy_url}")
988
950
  print()
951
+ print(" Works with any Cursor Pro model: Claude, GPT-5, Gemini, and more.")
952
+ print(" Restart Cursor for settings to take effect.")
989
953
  print(" Check status: mtrx cursor --status")
990
954
  print(" To disable: mtrx use cursor direct")
991
955
  return 0
@@ -46,6 +46,7 @@ DEFAULT_STATE: dict = {
46
46
  "defaults": {
47
47
  "codex": None,
48
48
  "claude": None,
49
+ "gemini": None,
49
50
  "cursor": None,
50
51
  },
51
52
  "workspaces": {
@@ -93,6 +94,19 @@ DEFAULT_STATE: dict = {
93
94
  "previous_matrx_block": None,
94
95
  "previous_values": {},
95
96
  },
97
+ "gemini": {
98
+ "configured": False,
99
+ "verified": False,
100
+ "config_path": None,
101
+ "backup_path": None,
102
+ "original_backup_path": None,
103
+ "config_fingerprint": None,
104
+ "matrx_key_fingerprint": None,
105
+ "last_verified_at": None,
106
+ "previous_model_provider": None,
107
+ "previous_matrx_block": None,
108
+ "previous_values": {},
109
+ },
96
110
  },
97
111
  },
98
112
  }