mtrx-cli 0.1.15 → 0.1.17
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 +2 -1
- package/src/matrx/__init__.py +1 -1
- package/src/matrx/cli/cursor_config.py +2 -2
- package/src/matrx/cli/cursor_hooks.py +31 -4
- package/src/matrx/cli/cursor_proxy.py +216 -10
- package/src/matrx/cli/cursor_reroute.py +76 -0
- package/src/matrx/cli/launcher.py +104 -0
- package/src/matrx/cli/main.py +61 -24
- package/src/matrx/cli/state.py +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mtrx-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"description": "MATRX CLI for routing Codex, Claude, and Cursor through Matrx",
|
|
5
5
|
"homepage": "https://mtrx.so",
|
|
6
6
|
"repository": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"src/matrx/cli/cursor_daemon.py",
|
|
32
32
|
"src/matrx/cli/cursor_launcher.py",
|
|
33
33
|
"src/matrx/cli/cursor_proxy.py",
|
|
34
|
+
"src/matrx/cli/cursor_reroute.py",
|
|
34
35
|
"src/matrx/cli/cursor_service.py",
|
|
35
36
|
"src/matrx/cli/launcher.py",
|
|
36
37
|
"src/matrx/cli/main.py",
|
package/src/matrx/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.1.
|
|
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:
|
|
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()
|
|
@@ -62,25 +62,43 @@ from __future__ import annotations
|
|
|
62
62
|
import json
|
|
63
63
|
import os
|
|
64
64
|
import sys
|
|
65
|
+
from datetime import datetime, timezone
|
|
65
66
|
from pathlib import Path
|
|
66
67
|
|
|
67
68
|
def _config_path() -> Path:
|
|
68
69
|
config_dir = Path(os.environ.get("MTRX_CONFIG_DIR", Path.home() / ".config" / "mtrx"))
|
|
69
70
|
return config_dir / "cursor-hooks-config.json"
|
|
70
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
|
+
|
|
71
85
|
def main() -> None:
|
|
72
86
|
try:
|
|
73
87
|
payload = json.load(sys.stdin)
|
|
74
88
|
except (json.JSONDecodeError, EOFError):
|
|
75
89
|
print("{}")
|
|
76
90
|
return
|
|
91
|
+
event = payload.get("hook_event_name", "?")
|
|
92
|
+
conv = (payload.get("conversation_id") or payload.get("session_id") or "?")[:16]
|
|
77
93
|
cfg_path = _config_path()
|
|
78
94
|
if not cfg_path.exists():
|
|
95
|
+
_log(f"{event} conv={conv} skip=no_config")
|
|
79
96
|
print("{}")
|
|
80
97
|
return
|
|
81
98
|
try:
|
|
82
99
|
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
83
100
|
except (json.JSONDecodeError, OSError):
|
|
101
|
+
_log(f"{event} conv={conv} skip=config_error")
|
|
84
102
|
print("{}")
|
|
85
103
|
return
|
|
86
104
|
url = (cfg.get("matrx_base_url") or "").rstrip("/") + "/v1/telemetry/cursor/hooks"
|
|
@@ -97,9 +115,10 @@ def main() -> None:
|
|
|
97
115
|
method="POST",
|
|
98
116
|
)
|
|
99
117
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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__}")
|
|
103
122
|
print("{}")
|
|
104
123
|
|
|
105
124
|
if __name__ == "__main__":
|
|
@@ -118,7 +137,15 @@ def install_mtrx_hooks(matrx_key: str, matrx_base_url: str) -> bool:
|
|
|
118
137
|
return False
|
|
119
138
|
|
|
120
139
|
# Write hook script
|
|
121
|
-
|
|
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
|
+
|
|
122
149
|
script_path = _HOOKS_DIR / "mtrx-telemetry.py"
|
|
123
150
|
try:
|
|
124
151
|
script_path.write_text(_hook_script_content(), encoding="utf-8")
|
|
@@ -32,6 +32,16 @@ import httpx
|
|
|
32
32
|
|
|
33
33
|
from matrx.cli.cursor_ca import CertCache, load_ca
|
|
34
34
|
|
|
35
|
+
try:
|
|
36
|
+
from matrx.cli.cursor_reroute import is_ai_path, try_reroute_to_matrx
|
|
37
|
+
except ImportError:
|
|
38
|
+
# Stubs when cursor_reroute not available (e.g. npm package omit).
|
|
39
|
+
def is_ai_path(path: str) -> bool:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
async def try_reroute_to_matrx(*, path: str, method: str, **kwargs: Any) -> None:
|
|
43
|
+
return None
|
|
44
|
+
|
|
35
45
|
logger = logging.getLogger(__name__)
|
|
36
46
|
|
|
37
47
|
DEFAULT_PORT = 8842
|
|
@@ -259,7 +269,11 @@ class MITMProxy:
|
|
|
259
269
|
up_writer: asyncio.StreamWriter,
|
|
260
270
|
hostname: str,
|
|
261
271
|
) -> None:
|
|
262
|
-
"""Forward HTTP/1.1 request-response pairs, logging each to telemetry.
|
|
272
|
+
"""Forward HTTP/1.1 request-response pairs, logging each to telemetry.
|
|
273
|
+
|
|
274
|
+
For AI paths (RunSSE, StreamCpp, etc.), attempts to reroute through MTRX
|
|
275
|
+
for live injection. If reroute succeeds, responds from MTRX; else forwards.
|
|
276
|
+
"""
|
|
263
277
|
while True:
|
|
264
278
|
req_line = await client_reader.readline()
|
|
265
279
|
if not req_line:
|
|
@@ -272,15 +286,62 @@ class MITMProxy:
|
|
|
272
286
|
method = parts[0] if parts else "?"
|
|
273
287
|
path = parts[1] if len(parts) > 1 else "/"
|
|
274
288
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
289
|
+
req_body_size = 0
|
|
290
|
+
# For AI paths: buffer request and try rerouting through MTRX (live injection)
|
|
291
|
+
if method == "POST" and is_ai_path(path):
|
|
292
|
+
req_headers, req_cl, req_chunked = await self._read_headers_only(
|
|
293
|
+
client_reader
|
|
294
|
+
)
|
|
295
|
+
req_body = await self._read_body_to_bytes(
|
|
296
|
+
client_reader, req_cl, req_chunked
|
|
297
|
+
)
|
|
298
|
+
req_body_size = len(req_body)
|
|
299
|
+
result = await try_reroute_to_matrx(
|
|
300
|
+
path=path,
|
|
301
|
+
method=method,
|
|
302
|
+
req_headers=req_headers,
|
|
303
|
+
req_body=req_body,
|
|
304
|
+
matrx_base_url=self.matrx_base_url,
|
|
305
|
+
matrx_key=self.matrx_key,
|
|
306
|
+
session_id=str(uuid.uuid4()),
|
|
307
|
+
)
|
|
308
|
+
if result is not None:
|
|
309
|
+
success, resp_headers, resp_body, is_streaming = result
|
|
310
|
+
if success and resp_body is not None:
|
|
311
|
+
self._request_count += 1
|
|
312
|
+
self._write_http_response(
|
|
313
|
+
client_writer, 200, resp_headers, resp_body
|
|
314
|
+
)
|
|
315
|
+
asyncio.create_task(
|
|
316
|
+
self._ship_telemetry(
|
|
317
|
+
hostname=hostname,
|
|
318
|
+
method=method,
|
|
319
|
+
path=path,
|
|
320
|
+
status_code=200,
|
|
321
|
+
req_body_size=len(req_body),
|
|
322
|
+
resp_body_size=len(resp_body),
|
|
323
|
+
elapsed_ms=0,
|
|
324
|
+
content_type=resp_headers.get("content-type", ""),
|
|
325
|
+
is_streaming=is_streaming,
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
continue
|
|
329
|
+
# Reroute returned but failed — fall through to forward
|
|
330
|
+
# Reroute not implemented or failed — forward to upstream
|
|
331
|
+
up_writer.write(req_line)
|
|
332
|
+
await self._write_headers(up_writer, req_headers)
|
|
333
|
+
up_writer.write(req_body)
|
|
334
|
+
await up_writer.drain()
|
|
335
|
+
else:
|
|
336
|
+
up_writer.write(req_line)
|
|
337
|
+
req_headers, req_cl, req_chunked = await self._forward_headers(
|
|
338
|
+
client_reader, up_writer
|
|
339
|
+
)
|
|
340
|
+
req_body_size = await self._forward_body(
|
|
341
|
+
client_reader, up_writer, req_cl, req_chunked
|
|
342
|
+
)
|
|
343
|
+
if req_body_size == 0 and req_cl > 0:
|
|
344
|
+
req_body_size = req_cl
|
|
284
345
|
|
|
285
346
|
started = time.monotonic()
|
|
286
347
|
|
|
@@ -336,6 +397,76 @@ class MITMProxy:
|
|
|
336
397
|
if "close" in conn_h:
|
|
337
398
|
break
|
|
338
399
|
|
|
400
|
+
async def _read_headers_only(
|
|
401
|
+
self, reader: asyncio.StreamReader
|
|
402
|
+
) -> tuple[dict[str, str], int, bool]:
|
|
403
|
+
"""Read headers without writing. Returns (headers_dict, content_length, is_chunked)."""
|
|
404
|
+
headers: dict[str, str] = {}
|
|
405
|
+
content_length = -1
|
|
406
|
+
chunked = False
|
|
407
|
+
while True:
|
|
408
|
+
line = await reader.readline()
|
|
409
|
+
decoded = line.decode("utf-8", errors="replace").strip()
|
|
410
|
+
if not decoded:
|
|
411
|
+
break
|
|
412
|
+
if ":" in decoded:
|
|
413
|
+
k, _, v = decoded.partition(":")
|
|
414
|
+
k_lower = k.strip().lower()
|
|
415
|
+
v_stripped = v.strip()
|
|
416
|
+
headers[k_lower] = v_stripped
|
|
417
|
+
if k_lower == "content-length":
|
|
418
|
+
content_length = int(v_stripped)
|
|
419
|
+
elif k_lower == "transfer-encoding" and "chunked" in v_stripped.lower():
|
|
420
|
+
chunked = True
|
|
421
|
+
return headers, content_length, chunked
|
|
422
|
+
|
|
423
|
+
async def _read_body_to_bytes(
|
|
424
|
+
self,
|
|
425
|
+
reader: asyncio.StreamReader,
|
|
426
|
+
content_length: int,
|
|
427
|
+
chunked: bool,
|
|
428
|
+
) -> bytes:
|
|
429
|
+
"""Read body into bytes (no writer)."""
|
|
430
|
+
if content_length > 0:
|
|
431
|
+
return await reader.read(content_length)
|
|
432
|
+
if chunked:
|
|
433
|
+
parts: list[bytes] = []
|
|
434
|
+
while True:
|
|
435
|
+
size_line = await reader.readline()
|
|
436
|
+
size_str = size_line.decode("utf-8", errors="replace").strip()
|
|
437
|
+
try:
|
|
438
|
+
chunk_size = int(size_str.split(";")[0], 16)
|
|
439
|
+
except ValueError:
|
|
440
|
+
break
|
|
441
|
+
if chunk_size == 0:
|
|
442
|
+
await reader.readline() # trailer
|
|
443
|
+
break
|
|
444
|
+
parts.append(await reader.read(chunk_size))
|
|
445
|
+
await reader.readline() # crlf
|
|
446
|
+
return b"".join(parts)
|
|
447
|
+
return b""
|
|
448
|
+
|
|
449
|
+
def _write_headers(
|
|
450
|
+
self, writer: asyncio.StreamWriter, headers: dict[str, str]
|
|
451
|
+
) -> None:
|
|
452
|
+
"""Write headers as HTTP lines (caller must drain)."""
|
|
453
|
+
for k, v in headers.items():
|
|
454
|
+
writer.write(f"{k}: {v}\r\n".encode())
|
|
455
|
+
writer.write(b"\r\n")
|
|
456
|
+
|
|
457
|
+
def _write_http_response(
|
|
458
|
+
self,
|
|
459
|
+
writer: asyncio.StreamWriter,
|
|
460
|
+
status: int,
|
|
461
|
+
resp_headers: dict[str, str],
|
|
462
|
+
resp_body: bytes,
|
|
463
|
+
) -> None:
|
|
464
|
+
"""Write a complete HTTP response."""
|
|
465
|
+
writer.write(f"HTTP/1.1 {status} OK\r\n".encode())
|
|
466
|
+
self._write_headers(writer, resp_headers)
|
|
467
|
+
writer.write(resp_body)
|
|
468
|
+
# Caller should drain
|
|
469
|
+
|
|
339
470
|
async def _forward_headers(
|
|
340
471
|
self,
|
|
341
472
|
reader: asyncio.StreamReader,
|
|
@@ -434,6 +565,81 @@ class MITMProxy:
|
|
|
434
565
|
await writer.drain()
|
|
435
566
|
return total
|
|
436
567
|
|
|
568
|
+
async def _read_headers_only(
|
|
569
|
+
self, reader: asyncio.StreamReader
|
|
570
|
+
) -> tuple[dict[str, str], int, bool]:
|
|
571
|
+
"""Read headers from reader without writing. Returns (headers, content_length, chunked)."""
|
|
572
|
+
headers: dict[str, str] = {}
|
|
573
|
+
content_length = -1
|
|
574
|
+
chunked = False
|
|
575
|
+
while True:
|
|
576
|
+
line = await reader.readline()
|
|
577
|
+
decoded = line.decode("utf-8", errors="replace").strip()
|
|
578
|
+
if not decoded:
|
|
579
|
+
break
|
|
580
|
+
if ":" in decoded:
|
|
581
|
+
k, _, v = decoded.partition(":")
|
|
582
|
+
k_lower = k.strip().lower()
|
|
583
|
+
v_stripped = v.strip()
|
|
584
|
+
headers[k_lower] = v_stripped
|
|
585
|
+
if k_lower == "content-length":
|
|
586
|
+
content_length = int(v_stripped)
|
|
587
|
+
elif k_lower == "transfer-encoding" and "chunked" in v_stripped.lower():
|
|
588
|
+
chunked = True
|
|
589
|
+
return headers, content_length, chunked
|
|
590
|
+
|
|
591
|
+
async def _read_body_to_bytes(
|
|
592
|
+
self,
|
|
593
|
+
reader: asyncio.StreamReader,
|
|
594
|
+
content_length: int,
|
|
595
|
+
chunked: bool,
|
|
596
|
+
) -> bytes:
|
|
597
|
+
"""Read body into bytes."""
|
|
598
|
+
if content_length > 0:
|
|
599
|
+
return await reader.readexactly(content_length)
|
|
600
|
+
if chunked:
|
|
601
|
+
parts: list[bytes] = []
|
|
602
|
+
while True:
|
|
603
|
+
size_line = await reader.readline()
|
|
604
|
+
size_str = size_line.decode("utf-8", errors="replace").strip()
|
|
605
|
+
try:
|
|
606
|
+
chunk_size = int(size_str.split(";")[0], 16)
|
|
607
|
+
except ValueError:
|
|
608
|
+
break
|
|
609
|
+
if chunk_size == 0:
|
|
610
|
+
await reader.readline() # trailer
|
|
611
|
+
break
|
|
612
|
+
parts.append(await reader.readexactly(chunk_size))
|
|
613
|
+
await reader.readline() # crlf
|
|
614
|
+
return b"".join(parts)
|
|
615
|
+
return b""
|
|
616
|
+
|
|
617
|
+
def _write_headers(
|
|
618
|
+
self, writer: asyncio.StreamWriter, headers: dict[str, str]
|
|
619
|
+
) -> None:
|
|
620
|
+
"""Write HTTP headers to writer."""
|
|
621
|
+
for k, v in headers.items():
|
|
622
|
+
# Capitalize header key (e.g. content-type -> Content-Type)
|
|
623
|
+
name = "-".join(p.capitalize() for p in k.split("-"))
|
|
624
|
+
writer.write(f"{name}: {v}\r\n".encode())
|
|
625
|
+
writer.write(b"\r\n")
|
|
626
|
+
|
|
627
|
+
def _write_http_response(
|
|
628
|
+
self,
|
|
629
|
+
writer: asyncio.StreamWriter,
|
|
630
|
+
status: int,
|
|
631
|
+
headers: dict[str, str],
|
|
632
|
+
body: bytes,
|
|
633
|
+
) -> None:
|
|
634
|
+
"""Write a complete HTTP response."""
|
|
635
|
+
writer.write(f"HTTP/1.1 {status} OK\r\n".encode())
|
|
636
|
+
if "content-length" not in {k.lower() for k in headers} and body:
|
|
637
|
+
headers = dict(headers)
|
|
638
|
+
headers["Content-Length"] = str(len(body))
|
|
639
|
+
self._write_headers(writer, headers)
|
|
640
|
+
writer.write(body)
|
|
641
|
+
# Note: drain is caller's responsibility
|
|
642
|
+
|
|
437
643
|
# -----------------------------------------------------------------
|
|
438
644
|
# Raw bidirectional pipe (for opaque tunnels)
|
|
439
645
|
# -----------------------------------------------------------------
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cursor → MTRX rerouting for live injection.
|
|
3
|
+
|
|
4
|
+
When the MITM proxy intercepts Cursor's AI traffic (RunSSE, StreamCpp, etc.),
|
|
5
|
+
this module reroutes it through MTRX instead of forwarding to Cursor's servers.
|
|
6
|
+
That enables the full suite: memory injection, compression, loop guard, etc.
|
|
7
|
+
|
|
8
|
+
Cursor uses Connect/gRPC protocol with binary protobuf. We parse the request,
|
|
9
|
+
convert to OpenAI/Anthropic format, call MTRX, and convert the response back.
|
|
10
|
+
|
|
11
|
+
Refs: cursor-tap (https://github.com/burpheart/cursor-tap), everestmz/cursor-rpc
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import re
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Cursor AI RPC paths (Connect protocol). RunSSE = main chat, StreamCpp = code completion.
|
|
24
|
+
_AI_PATH_PATTERNS = (
|
|
25
|
+
r"RunSSE",
|
|
26
|
+
r"StreamCpp",
|
|
27
|
+
r"BidiAppend",
|
|
28
|
+
r"AgentService",
|
|
29
|
+
r"AiService",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_ai_path(path: str) -> bool:
|
|
34
|
+
"""Return True if this path is an AI/LLM endpoint we should reroute to MTRX."""
|
|
35
|
+
if not path:
|
|
36
|
+
return False
|
|
37
|
+
return any(re.search(p, path, re.IGNORECASE) for p in _AI_PATH_PATTERNS)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cursor_model_to_openai(cursor_model: str) -> str:
|
|
41
|
+
"""Map Cursor model names to OpenAI-style names MTRX expects."""
|
|
42
|
+
# Cursor uses names like "claude-sonnet-4" or "gpt-4o" - usually compatible
|
|
43
|
+
normalized = (cursor_model or "").strip().lower()
|
|
44
|
+
if not normalized:
|
|
45
|
+
return "gpt-4o" # fallback
|
|
46
|
+
return cursor_model
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def try_reroute_to_matrx(
|
|
50
|
+
*,
|
|
51
|
+
path: str,
|
|
52
|
+
method: str,
|
|
53
|
+
req_headers: dict[str, str],
|
|
54
|
+
req_body: bytes,
|
|
55
|
+
matrx_base_url: str,
|
|
56
|
+
matrx_key: str,
|
|
57
|
+
session_id: str | None = None,
|
|
58
|
+
group_id: str | None = None,
|
|
59
|
+
project_id: str | None = None,
|
|
60
|
+
) -> tuple[bool, dict[str, str], bytes | None, bool] | None:
|
|
61
|
+
"""
|
|
62
|
+
Attempt to reroute a Cursor AI request through MTRX.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
(success, response_headers, response_body, is_streaming) if handled,
|
|
66
|
+
None to fall back to normal forward.
|
|
67
|
+
"""
|
|
68
|
+
if method != "POST" or not is_ai_path(path):
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
# TODO: Full protobuf parsing. Cursor uses Connect/gRPC with binary frames.
|
|
72
|
+
# For now we don't have the proto conversion - fall back to forward.
|
|
73
|
+
# When implemented: parse req_body, extract messages+model, call MTRX,
|
|
74
|
+
# convert response back to Cursor's gRPC format.
|
|
75
|
+
logger.debug("cursor_reroute: path=%s would reroute (protobuf conversion not yet implemented)", path)
|
|
76
|
+
return None
|
|
@@ -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
|
|
package/src/matrx/cli/main.py
CHANGED
|
@@ -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")
|
|
@@ -553,6 +556,7 @@ def _cmd_status() -> int:
|
|
|
553
556
|
print("Defaults:")
|
|
554
557
|
print(f" codex: {_default_route_label(configured_route(state, 'codex'))}")
|
|
555
558
|
print(f" claude: {_default_route_label(configured_route(state, 'claude'))}")
|
|
559
|
+
print(f" gemini: {_default_route_label(configured_route(state, 'gemini'))}")
|
|
556
560
|
print(f" cursor: {_default_route_label(configured_route(state, 'cursor'))}")
|
|
557
561
|
print("Auth:")
|
|
558
562
|
print(
|
|
@@ -567,7 +571,7 @@ def _cmd_status() -> int:
|
|
|
567
571
|
local = "present" if claude_oauth_available() else "missing"
|
|
568
572
|
print(f" claude-code oauth: {imported}, local credentials: {local}")
|
|
569
573
|
print("Tool config:")
|
|
570
|
-
for tool in ("codex", "claude"):
|
|
574
|
+
for tool in ("codex", "claude", "gemini"):
|
|
571
575
|
config_status = get_tool_config_status(state, tool)
|
|
572
576
|
route = configured_route(state, tool)
|
|
573
577
|
if tool == "codex" and config_status["verified"] and not config_status["configured"]:
|
|
@@ -589,6 +593,7 @@ def _cmd_status() -> int:
|
|
|
589
593
|
print("Executables:")
|
|
590
594
|
print(f" codex: {find_executable('codex') or 'not found'}")
|
|
591
595
|
print(f" claude: {find_executable('claude') or 'not found'}")
|
|
596
|
+
print(f" gemini: {find_executable('gemini') or 'not found'}")
|
|
592
597
|
profiles = _legacy_shell_proxy_profiles()
|
|
593
598
|
active_env = _active_claude_proxy_env()
|
|
594
599
|
if profiles or active_env:
|
|
@@ -703,7 +708,7 @@ def _cmd_doctor() -> int:
|
|
|
703
708
|
workspace_binding = get_workspace_binding(state, cwd=os.environ.get("PWD") or os.getcwd()) or {}
|
|
704
709
|
workspace_matrx_key = (workspace_binding.get("matrx_key") or "").strip()
|
|
705
710
|
|
|
706
|
-
for tool in ("codex", "claude"):
|
|
711
|
+
for tool in ("codex", "claude", "gemini"):
|
|
707
712
|
found = find_executable(tool)
|
|
708
713
|
if found:
|
|
709
714
|
print(f"[ok] {tool} executable: {found}")
|
|
@@ -768,7 +773,7 @@ def _cmd_doctor() -> int:
|
|
|
768
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`",
|
|
769
774
|
)
|
|
770
775
|
|
|
771
|
-
for tool in ("codex", "claude", "cursor"):
|
|
776
|
+
for tool in ("codex", "claude", "gemini", "cursor"):
|
|
772
777
|
route = configured_route(state, tool)
|
|
773
778
|
if route == "matrx" and not _has_matrx_login(state, env=os.environ):
|
|
774
779
|
print(f"[fail] Default {tool} route is matrx but no Matrx key is saved")
|
|
@@ -842,22 +847,28 @@ def _cmd_launch(tool: str, route: str | None, remainder: list[str]) -> int:
|
|
|
842
847
|
|
|
843
848
|
def _cmd_cursor(args) -> int:
|
|
844
849
|
from matrx.cli.cursor_hooks import install_mtrx_hooks, is_mtrx_hooks_installed
|
|
850
|
+
from matrx.cli.cursor_launcher import find_cursor_executable
|
|
845
851
|
|
|
846
852
|
route = args.route
|
|
847
853
|
|
|
848
|
-
# --status: report hooks status
|
|
854
|
+
# --status: report Base URL override + hooks status
|
|
849
855
|
if args.status:
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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}")
|
|
855
866
|
return 0
|
|
856
867
|
|
|
857
868
|
# --stop: tear down
|
|
858
869
|
if args.stop:
|
|
859
870
|
_restore_cursor_if_needed()
|
|
860
|
-
print("Cursor route set to direct — MTRX
|
|
871
|
+
print("Cursor route set to direct — MTRX disabled.")
|
|
861
872
|
return 0
|
|
862
873
|
|
|
863
874
|
state = load_state()
|
|
@@ -866,12 +877,12 @@ def _cmd_cursor(args) -> int:
|
|
|
866
877
|
|
|
867
878
|
if effective_route == "direct":
|
|
868
879
|
_restore_cursor_if_needed()
|
|
869
|
-
print("Cursor route set to direct — MTRX
|
|
880
|
+
print("Cursor route set to direct — MTRX proxy disabled.")
|
|
870
881
|
if cursor_is_running():
|
|
871
882
|
print(" Restart Cursor for settings to take effect.")
|
|
872
883
|
return 0
|
|
873
884
|
|
|
874
|
-
# --- matrx route:
|
|
885
|
+
# --- matrx route: Base URL override (works with any Cursor model: Claude, GPT, Gemini, etc.) ---
|
|
875
886
|
|
|
876
887
|
try:
|
|
877
888
|
state, login_changed = _complete_matrx_login(state)
|
|
@@ -895,6 +906,7 @@ def _cmd_cursor(args) -> int:
|
|
|
895
906
|
matrx_base_url = ensure_root_url(
|
|
896
907
|
state.get("auth", {}).get("matrx", {}).get("base_url")
|
|
897
908
|
)
|
|
909
|
+
matrx_proxy_url = ensure_v1_url(matrx_base_url)
|
|
898
910
|
|
|
899
911
|
if initialized or login_changed or promoted:
|
|
900
912
|
save_state(state)
|
|
@@ -904,17 +916,42 @@ def _cmd_cursor(args) -> int:
|
|
|
904
916
|
"Use `mtrx use cursor direct` to opt out.",
|
|
905
917
|
)
|
|
906
918
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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")
|
|
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")
|
|
915
924
|
else:
|
|
916
|
-
print(
|
|
917
|
-
|
|
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_...)")
|
|
930
|
+
|
|
931
|
+
# Hooks for session telemetry
|
|
932
|
+
install_mtrx_hooks(mx_key, matrx_base_url)
|
|
933
|
+
|
|
934
|
+
# Optional: launch Cursor
|
|
935
|
+
if getattr(args, "launch", False):
|
|
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)
|
|
944
|
+
else:
|
|
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}")
|
|
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.")
|
|
953
|
+
print(" Check status: mtrx cursor --status")
|
|
954
|
+
print(" To disable: mtrx use cursor direct")
|
|
918
955
|
return 0
|
|
919
956
|
|
|
920
957
|
|
package/src/matrx/cli/state.py
CHANGED
|
@@ -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
|
}
|