mtrx-cli 0.1.11 → 0.1.14

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.
@@ -1,14 +1,20 @@
1
1
  """
2
- Read / write Cursor IDE settings stored in the SQLite ``state.vscdb`` database.
2
+ Read / write Cursor IDE settings.
3
3
 
4
- Cursor stores its model-related settings (custom API keys, base URL overrides)
5
- in ``~/Library/Application Support/Cursor/User/globalStorage/state.vscdb``
6
- (macOS) inside an ``ItemTable`` key-value store.
4
+ Two configuration surfaces:
5
+
6
+ 1. **settings.json** -- Cursor's user preferences file (VS-Code–compatible).
7
+ The MITM proxy approach writes ``http.proxy`` and ``http.proxyStrictSSL``
8
+ here so that Cursor routes *all* traffic through the local proxy.
9
+
10
+ 2. **state.vscdb** -- Cursor's internal SQLite key-value store. The legacy
11
+ "Override OpenAI Base URL" approach writes API-key / base-URL overrides
12
+ here. These helpers are retained for ``mtrx use cursor direct`` restore.
7
13
 
8
14
  This module provides helpers to:
9
- - locate the database on each platform
10
- - discover which keys Cursor uses for the OpenAI API key / base URL override
11
- - read, write, backup, and restore those settings
15
+ - locate the database / settings file on each platform
16
+ - read, write, backup, and restore settings
17
+ - configure ``http.proxy`` for the MITM proxy
12
18
  - print manual setup instructions as a fallback
13
19
  """
14
20
 
@@ -36,6 +42,14 @@ _OPENAI_BASE_URL_FIELD = "openaiBaseUrl"
36
42
  _OVERRIDE_OPENAI_BASE_URL_FIELD = "enableOpenaiBaseUrlOverride"
37
43
  _ANTHROPIC_KEY_FIELD = "anthropicApiKey"
38
44
 
45
+ # Cursor v2.5+ moved BYOK settings to the reactive persistent storage blob.
46
+ _REACTIVE_STORAGE_KEY = (
47
+ "src.vs.platform.reactivestorage.browser."
48
+ "reactiveStorageServiceImpl.persistentStorage.applicationUser"
49
+ )
50
+ _RS_USE_OPENAI_KEY = "useOpenAIKey"
51
+ _RS_BASE_URL = "openAIBaseUrl"
52
+
39
53
 
40
54
  def cursor_state_db_path() -> Path:
41
55
  """Return the platform-specific path to Cursor's ``state.vscdb``."""
@@ -206,6 +220,34 @@ def write_cursor_settings(
206
220
  conn.close()
207
221
 
208
222
 
223
+ def _read_reactive_storage(conn: sqlite3.Connection) -> dict | None:
224
+ cursor = conn.cursor()
225
+ cursor.execute(
226
+ "SELECT value FROM ItemTable WHERE key = ?", (_REACTIVE_STORAGE_KEY,)
227
+ )
228
+ row = cursor.fetchone()
229
+ if row is None:
230
+ return None
231
+ try:
232
+ return json.loads(row[0]) if isinstance(row[0], str) else None
233
+ except (json.JSONDecodeError, TypeError):
234
+ return None
235
+
236
+
237
+ def _write_reactive_storage(conn: sqlite3.Connection, data: dict) -> bool:
238
+ try:
239
+ cursor = conn.cursor()
240
+ cursor.execute(
241
+ "INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)",
242
+ (_REACTIVE_STORAGE_KEY, json.dumps(data)),
243
+ )
244
+ conn.commit()
245
+ return True
246
+ except sqlite3.Error as exc:
247
+ logger.debug("cursor_config: reactive storage write error: %s", exc)
248
+ return False
249
+
250
+
209
251
  def configure_cursor_for_proxy(
210
252
  proxy_url: str,
211
253
  proxy_api_key: str = "mtrx-cursor-proxy",
@@ -213,56 +255,78 @@ def configure_cursor_for_proxy(
213
255
  db_path: Path | None = None,
214
256
  ) -> dict | None:
215
257
  """
216
- Configure Cursor to route through the local MTRX proxy.
258
+ Configure Cursor to route all model traffic through MTRX.
217
259
 
218
- Sets the OpenAI API key and Override Base URL in state.vscdb.
260
+ Writes to both the legacy ``storage.ServiceStorage`` blob and the
261
+ current reactive persistent storage used by Cursor v2.5+.
219
262
  Returns the previous settings dict (for later restoration), or None on failure.
220
263
  """
221
264
  db_path = db_path or cursor_state_db_path()
222
- current = read_cursor_settings(db_path)
265
+ conn = _open_db(db_path)
266
+ if conn is None:
267
+ return None
223
268
 
224
- if current is None:
225
- # Settings key not found may need user to set a dummy key via UI first.
226
- # Try to insert a fresh settings blob under the first candidate key.
227
- conn = _open_db(db_path)
228
- if conn is None:
229
- return None
230
- try:
269
+ try:
270
+ # --- Reactive storage (Cursor v2.5+) ---
271
+ rs_data = _read_reactive_storage(conn)
272
+ rs_previous = {}
273
+ if rs_data is not None:
274
+ rs_previous = {
275
+ _RS_USE_OPENAI_KEY: rs_data.get(_RS_USE_OPENAI_KEY),
276
+ _RS_BASE_URL: rs_data.get(_RS_BASE_URL),
277
+ }
278
+ rs_data[_RS_USE_OPENAI_KEY] = True
279
+ rs_data[_RS_BASE_URL] = proxy_url
280
+ _write_reactive_storage(conn, rs_data)
281
+
282
+ # --- Legacy storage (still written for older Cursor versions) ---
283
+ legacy_previous = {}
284
+ settings_key = _find_settings_key(conn) or _scan_for_settings_key(conn)
285
+ if settings_key:
286
+ cur = conn.cursor()
287
+ cur.execute("SELECT value FROM ItemTable WHERE key = ?", (settings_key,))
288
+ row = cur.fetchone()
289
+ if row:
290
+ current = json.loads(row[0]) if isinstance(row[0], str) else {}
291
+ legacy_previous = {
292
+ _OPENAI_KEY_FIELD: current.get(_OPENAI_KEY_FIELD),
293
+ _OPENAI_BASE_URL_FIELD: current.get(_OPENAI_BASE_URL_FIELD),
294
+ _OVERRIDE_OPENAI_BASE_URL_FIELD: current.get(_OVERRIDE_OPENAI_BASE_URL_FIELD),
295
+ _ANTHROPIC_KEY_FIELD: current.get(_ANTHROPIC_KEY_FIELD),
296
+ "_mtrx_settings_key": settings_key,
297
+ }
298
+ current[_OPENAI_KEY_FIELD] = proxy_api_key
299
+ current[_OPENAI_BASE_URL_FIELD] = proxy_url
300
+ current[_OVERRIDE_OPENAI_BASE_URL_FIELD] = True
301
+ cur.execute(
302
+ "INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)",
303
+ (settings_key, json.dumps(current)),
304
+ )
305
+ conn.commit()
306
+ if not legacy_previous:
231
307
  settings_key = _CANDIDATE_SETTINGS_KEYS[0]
232
308
  fresh: dict = {
233
309
  _OPENAI_KEY_FIELD: proxy_api_key,
234
310
  _OPENAI_BASE_URL_FIELD: proxy_url,
235
311
  _OVERRIDE_OPENAI_BASE_URL_FIELD: True,
236
312
  }
237
- cursor = conn.cursor()
238
- cursor.execute(
313
+ cur = conn.cursor()
314
+ cur.execute(
239
315
  "INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)",
240
316
  (settings_key, json.dumps(fresh)),
241
317
  )
242
318
  conn.commit()
243
- return {"_mtrx_settings_key": settings_key, "_mtrx_was_absent": True}
244
- except sqlite3.Error as exc:
245
- logger.debug("cursor_config: fresh insert error: %s", exc)
246
- return None
247
- finally:
248
- conn.close()
319
+ legacy_previous = {"_mtrx_settings_key": settings_key, "_mtrx_was_absent": True}
249
320
 
250
- settings_key = current.pop("_mtrx_settings_key", _CANDIDATE_SETTINGS_KEYS[0])
251
- previous = {
252
- _OPENAI_KEY_FIELD: current.get(_OPENAI_KEY_FIELD),
253
- _OPENAI_BASE_URL_FIELD: current.get(_OPENAI_BASE_URL_FIELD),
254
- _OVERRIDE_OPENAI_BASE_URL_FIELD: current.get(_OVERRIDE_OPENAI_BASE_URL_FIELD),
255
- _ANTHROPIC_KEY_FIELD: current.get(_ANTHROPIC_KEY_FIELD),
256
- "_mtrx_settings_key": settings_key,
257
- }
258
-
259
- current[_OPENAI_KEY_FIELD] = proxy_api_key
260
- current[_OPENAI_BASE_URL_FIELD] = proxy_url
261
- current[_OVERRIDE_OPENAI_BASE_URL_FIELD] = True
262
-
263
- if write_cursor_settings(db_path, settings_key, current):
264
- return previous
265
- return None
321
+ return {
322
+ **legacy_previous,
323
+ "_mtrx_rs_previous": rs_previous,
324
+ }
325
+ except sqlite3.Error as exc:
326
+ logger.debug("cursor_config: configure error: %s", exc)
327
+ return None
328
+ finally:
329
+ conn.close()
266
330
 
267
331
 
268
332
  def restore_cursor_settings(
@@ -272,46 +336,56 @@ def restore_cursor_settings(
272
336
  ) -> bool:
273
337
  """Restore Cursor settings to their pre-proxy state."""
274
338
  db_path = db_path or cursor_state_db_path()
275
- settings_key = previous.get("_mtrx_settings_key", "")
339
+ conn = _open_db(db_path)
340
+ if conn is None:
341
+ return False
276
342
 
277
- if previous.get("_mtrx_was_absent"):
278
- conn = _open_db(db_path)
279
- if conn is None:
280
- return False
281
- try:
343
+ ok = True
344
+ try:
345
+ # --- Reactive storage ---
346
+ rs_prev = previous.get("_mtrx_rs_previous", {})
347
+ if rs_prev:
348
+ rs_data = _read_reactive_storage(conn)
349
+ if rs_data is not None:
350
+ for field in (_RS_USE_OPENAI_KEY, _RS_BASE_URL):
351
+ old_value = rs_prev.get(field)
352
+ if old_value is None:
353
+ rs_data.pop(field, None)
354
+ else:
355
+ rs_data[field] = old_value
356
+ _write_reactive_storage(conn, rs_data)
357
+
358
+ # --- Legacy storage ---
359
+ settings_key = previous.get("_mtrx_settings_key", "")
360
+ if previous.get("_mtrx_was_absent") and settings_key:
282
361
  cursor = conn.cursor()
283
362
  cursor.execute(
284
363
  "DELETE FROM ItemTable WHERE key = ?", (settings_key,)
285
364
  )
286
365
  conn.commit()
287
- return True
288
- except sqlite3.Error:
289
- return False
290
- finally:
291
- conn.close()
292
-
293
- if not settings_key:
294
- return False
295
-
296
- current = read_cursor_settings(db_path)
297
- if current is None:
298
- return False
299
-
300
- current.pop("_mtrx_settings_key", None)
301
-
302
- for field in (
303
- _OPENAI_KEY_FIELD,
304
- _OPENAI_BASE_URL_FIELD,
305
- _OVERRIDE_OPENAI_BASE_URL_FIELD,
306
- _ANTHROPIC_KEY_FIELD,
307
- ):
308
- old_value = previous.get(field)
309
- if old_value is None:
310
- current.pop(field, None)
311
- else:
312
- current[field] = old_value
366
+ elif settings_key:
367
+ current = read_cursor_settings(db_path)
368
+ if current is not None:
369
+ current.pop("_mtrx_settings_key", None)
370
+ for field in (
371
+ _OPENAI_KEY_FIELD,
372
+ _OPENAI_BASE_URL_FIELD,
373
+ _OVERRIDE_OPENAI_BASE_URL_FIELD,
374
+ _ANTHROPIC_KEY_FIELD,
375
+ ):
376
+ old_value = previous.get(field)
377
+ if old_value is None:
378
+ current.pop(field, None)
379
+ else:
380
+ current[field] = old_value
381
+ ok = write_cursor_settings(db_path, settings_key, current)
382
+ except sqlite3.Error as exc:
383
+ logger.debug("cursor_config: restore error: %s", exc)
384
+ ok = False
385
+ finally:
386
+ conn.close()
313
387
 
314
- return write_cursor_settings(db_path, settings_key, current)
388
+ return ok
315
389
 
316
390
 
317
391
  def print_manual_setup_instructions(proxy_url: str) -> None:
@@ -329,3 +403,117 @@ def print_manual_setup_instructions(proxy_url: str) -> None:
329
403
  print()
330
404
  print(" All models (Claude, GPT, Gemini, etc.) will route through MTRX.")
331
405
  print()
406
+
407
+
408
+ # =========================================================================
409
+ # settings.json helpers (MITM proxy approach)
410
+ # =========================================================================
411
+
412
+ def cursor_settings_json_path() -> Path:
413
+ """Return the path to Cursor's ``settings.json``."""
414
+ system = platform.system()
415
+ if system == "Darwin":
416
+ return (
417
+ Path.home()
418
+ / "Library"
419
+ / "Application Support"
420
+ / "Cursor"
421
+ / "User"
422
+ / "settings.json"
423
+ )
424
+ if system == "Linux":
425
+ return Path.home() / ".config" / "Cursor" / "User" / "settings.json"
426
+ # Windows
427
+ appdata = os.environ.get("APPDATA", "")
428
+ if appdata:
429
+ return Path(appdata) / "Cursor" / "User" / "settings.json"
430
+ return Path.home() / "AppData" / "Roaming" / "Cursor" / "User" / "settings.json"
431
+
432
+
433
+ def _read_settings_json(path: Path | None = None) -> dict:
434
+ path = path or cursor_settings_json_path()
435
+ if not path.exists():
436
+ return {}
437
+ try:
438
+ return json.loads(path.read_text(encoding="utf-8"))
439
+ except (json.JSONDecodeError, OSError):
440
+ return {}
441
+
442
+
443
+ def _write_settings_json(data: dict, path: Path | None = None) -> bool:
444
+ path = path or cursor_settings_json_path()
445
+ try:
446
+ path.parent.mkdir(parents=True, exist_ok=True)
447
+ path.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")
448
+ return True
449
+ except OSError as exc:
450
+ logger.debug("cursor_config: settings.json write error: %s", exc)
451
+ return False
452
+
453
+
454
+ def configure_cursor_proxy_settings(
455
+ proxy_url: str,
456
+ ca_cert_path: str,
457
+ ) -> dict:
458
+ """Set ``http.proxy`` and ``NODE_EXTRA_CA_CERTS`` in Cursor's settings.
459
+
460
+ Returns the previous values of the modified keys (for restoration).
461
+ """
462
+ settings = _read_settings_json()
463
+
464
+ previous = {
465
+ "http.proxy": settings.get("http.proxy"),
466
+ "http.proxyStrictSSL": settings.get("http.proxyStrictSSL"),
467
+ "terminal.integrated.env.osx": settings.get("terminal.integrated.env.osx"),
468
+ "terminal.integrated.env.linux": settings.get("terminal.integrated.env.linux"),
469
+ "terminal.integrated.env.windows": settings.get("terminal.integrated.env.windows"),
470
+ }
471
+
472
+ settings["http.proxy"] = proxy_url
473
+ settings["http.proxyStrictSSL"] = False
474
+
475
+ # Inject NODE_EXTRA_CA_CERTS into integrated terminal env so Cursor's
476
+ # Node.js runtime trusts our CA. Cursor itself reads this from the
477
+ # process environment, but setting it here covers more cases.
478
+ for env_key in (
479
+ "terminal.integrated.env.osx",
480
+ "terminal.integrated.env.linux",
481
+ "terminal.integrated.env.windows",
482
+ ):
483
+ env_block = settings.get(env_key)
484
+ if not isinstance(env_block, dict):
485
+ env_block = {}
486
+ env_block["NODE_EXTRA_CA_CERTS"] = ca_cert_path
487
+ settings[env_key] = env_block
488
+
489
+ _write_settings_json(settings)
490
+ return previous
491
+
492
+
493
+ def restore_cursor_proxy_settings(previous: dict) -> bool:
494
+ """Restore Cursor's settings.json to pre-proxy values."""
495
+ settings = _read_settings_json()
496
+
497
+ for key in ("http.proxy", "http.proxyStrictSSL"):
498
+ old = previous.get(key)
499
+ if old is None:
500
+ settings.pop(key, None)
501
+ else:
502
+ settings[key] = old
503
+
504
+ for env_key in (
505
+ "terminal.integrated.env.osx",
506
+ "terminal.integrated.env.linux",
507
+ "terminal.integrated.env.windows",
508
+ ):
509
+ old = previous.get(env_key)
510
+ if old is None:
511
+ env_block = settings.get(env_key)
512
+ if isinstance(env_block, dict):
513
+ env_block.pop("NODE_EXTRA_CA_CERTS", None)
514
+ if not env_block:
515
+ settings.pop(env_key, None)
516
+ else:
517
+ settings[env_key] = old
518
+
519
+ return _write_settings_json(settings)
@@ -0,0 +1,64 @@
1
+ """
2
+ Daemon entry-point for the MTRX Cursor MITM proxy.
3
+
4
+ Launched by the platform service manager (Launch Agent, systemd, or
5
+ detached subprocess). Reads proxy configuration from a JSON file and
6
+ starts the MITM proxy loop.
7
+
8
+ Usage::
9
+
10
+ python -m matrx.cli.cursor_daemon --config ~/.config/mtrx/cursor-proxy.json \
11
+ --pid-file ~/.config/mtrx/cursor-proxy.pid
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import logging
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ logging.basicConfig(
23
+ level=logging.INFO,
24
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
25
+ )
26
+
27
+
28
+ def main() -> int:
29
+ parser = argparse.ArgumentParser(description="MTRX Cursor MITM proxy daemon")
30
+ parser.add_argument("--config", required=True, help="Path to proxy config JSON")
31
+ parser.add_argument("--pid-file", default=None, help="Path to write PID file")
32
+ args = parser.parse_args()
33
+
34
+ config_path = Path(args.config)
35
+ if not config_path.exists():
36
+ print(f"Config file not found: {config_path}", file=sys.stderr)
37
+ return 1
38
+
39
+ config = json.loads(config_path.read_text(encoding="utf-8"))
40
+ matrx_key = config.get("matrx_key", "")
41
+ matrx_base_url = config.get("matrx_base_url", "")
42
+ host = config.get("host", "127.0.0.1")
43
+ port = config.get("port", 8842)
44
+
45
+ if not matrx_key or not matrx_base_url:
46
+ print("Invalid proxy config: matrx_key and matrx_base_url required", file=sys.stderr)
47
+ return 1
48
+
49
+ pid_file = Path(args.pid_file) if args.pid_file else None
50
+
51
+ from matrx.cli.cursor_proxy import run_proxy
52
+
53
+ run_proxy(
54
+ matrx_key=matrx_key,
55
+ matrx_base_url=matrx_base_url,
56
+ host=host,
57
+ port=port,
58
+ pid_file=pid_file,
59
+ )
60
+ return 0
61
+
62
+
63
+ if __name__ == "__main__":
64
+ raise SystemExit(main())
@@ -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