mtrx-cli 0.1.8 → 0.1.10

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.8",
3
+ "version": "0.1.10",
4
4
  "description": "MATRX CLI for routing Codex and Claude through Matrx",
5
5
  "homepage": "https://mtrx.so",
6
6
  "repository": {
@@ -24,6 +24,8 @@
24
24
  "bin/mtrx.js",
25
25
  "src/matrx/__init__.py",
26
26
  "src/matrx/cli/__init__.py",
27
+ "src/matrx/cli/cursor_config.py",
28
+ "src/matrx/cli/cursor_proxy.py",
27
29
  "src/matrx/cli/launcher.py",
28
30
  "src/matrx/cli/main.py",
29
31
  "src/matrx/cli/state.py"
@@ -1 +1 @@
1
- __version__ = "0.1.8"
1
+ __version__ = "0.1.10"
@@ -0,0 +1,331 @@
1
+ """
2
+ Read / write Cursor IDE settings stored in the SQLite ``state.vscdb`` database.
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.
7
+
8
+ 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
12
+ - print manual setup instructions as a fallback
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import os
20
+ import platform
21
+ import sqlite3
22
+ from pathlib import Path
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Known key patterns that Cursor uses (or has used) for BYOK settings.
27
+ # These are discovered empirically; Cursor may change them across versions.
28
+ _CANDIDATE_SETTINGS_KEYS = [
29
+ "storage.ServiceStorage",
30
+ "cursorai/cachedSettings",
31
+ "cursorai/modelSettings",
32
+ ]
33
+
34
+ _OPENAI_KEY_FIELD = "openaiApiKey"
35
+ _OPENAI_BASE_URL_FIELD = "openaiBaseUrl"
36
+ _OVERRIDE_OPENAI_BASE_URL_FIELD = "enableOpenaiBaseUrlOverride"
37
+ _ANTHROPIC_KEY_FIELD = "anthropicApiKey"
38
+
39
+
40
+ def cursor_state_db_path() -> Path:
41
+ """Return the platform-specific path to Cursor's ``state.vscdb``."""
42
+ system = platform.system()
43
+ if system == "Darwin":
44
+ return (
45
+ Path.home()
46
+ / "Library"
47
+ / "Application Support"
48
+ / "Cursor"
49
+ / "User"
50
+ / "globalStorage"
51
+ / "state.vscdb"
52
+ )
53
+ if system == "Linux":
54
+ return (
55
+ Path.home()
56
+ / ".config"
57
+ / "Cursor"
58
+ / "User"
59
+ / "globalStorage"
60
+ / "state.vscdb"
61
+ )
62
+ # Windows
63
+ appdata = os.environ.get("APPDATA", "")
64
+ if appdata:
65
+ return Path(appdata) / "Cursor" / "User" / "globalStorage" / "state.vscdb"
66
+ return Path.home() / "AppData" / "Roaming" / "Cursor" / "User" / "globalStorage" / "state.vscdb"
67
+
68
+
69
+ def cursor_is_running() -> bool:
70
+ """Best-effort check for whether a Cursor process is active."""
71
+ system = platform.system()
72
+ if system == "Darwin":
73
+ import subprocess
74
+ try:
75
+ result = subprocess.run(
76
+ ["pgrep", "-f", "Cursor"],
77
+ capture_output=True,
78
+ timeout=3,
79
+ )
80
+ return result.returncode == 0
81
+ except Exception:
82
+ return False
83
+ if system == "Linux":
84
+ import subprocess
85
+ try:
86
+ result = subprocess.run(
87
+ ["pgrep", "-f", "[Cc]ursor"],
88
+ capture_output=True,
89
+ timeout=3,
90
+ )
91
+ return result.returncode == 0
92
+ except Exception:
93
+ return False
94
+ return False
95
+
96
+
97
+ def _open_db(db_path: Path) -> sqlite3.Connection | None:
98
+ if not db_path.exists():
99
+ return None
100
+ try:
101
+ conn = sqlite3.connect(str(db_path), timeout=5)
102
+ return conn
103
+ except sqlite3.Error as exc:
104
+ logger.debug("cursor_config: cannot open %s: %s", db_path, exc)
105
+ return None
106
+
107
+
108
+ def _find_settings_key(conn: sqlite3.Connection) -> str | None:
109
+ """Discover which ItemTable key holds the model/BYOK settings blob."""
110
+ cursor = conn.cursor()
111
+ for candidate in _CANDIDATE_SETTINGS_KEYS:
112
+ cursor.execute(
113
+ "SELECT value FROM ItemTable WHERE key = ?", (candidate,)
114
+ )
115
+ row = cursor.fetchone()
116
+ if row is None:
117
+ continue
118
+ try:
119
+ data = json.loads(row[0]) if isinstance(row[0], str) else row[0]
120
+ except (json.JSONDecodeError, TypeError):
121
+ continue
122
+ if isinstance(data, dict) and (
123
+ _OPENAI_KEY_FIELD in data
124
+ or _OPENAI_BASE_URL_FIELD in data
125
+ or _OVERRIDE_OPENAI_BASE_URL_FIELD in data
126
+ ):
127
+ return candidate
128
+ return None
129
+
130
+
131
+ def _scan_for_settings_key(conn: sqlite3.Connection) -> str | None:
132
+ """Fallback: scan all keys looking for any JSON blob with openai fields."""
133
+ cursor = conn.cursor()
134
+ cursor.execute("SELECT key, value FROM ItemTable")
135
+ for key, value in cursor.fetchall():
136
+ if not isinstance(value, str):
137
+ continue
138
+ try:
139
+ data = json.loads(value)
140
+ except (json.JSONDecodeError, TypeError):
141
+ continue
142
+ if isinstance(data, dict) and (
143
+ _OPENAI_KEY_FIELD in data
144
+ or _OPENAI_BASE_URL_FIELD in data
145
+ or _OVERRIDE_OPENAI_BASE_URL_FIELD in data
146
+ or _ANTHROPIC_KEY_FIELD in data
147
+ ):
148
+ return key
149
+ return None
150
+
151
+
152
+ def read_cursor_settings(db_path: Path | None = None) -> dict | None:
153
+ """
154
+ Read Cursor's current BYOK/model settings.
155
+
156
+ Returns the parsed JSON blob, or None if the DB cannot be read or the
157
+ settings key is not found.
158
+ """
159
+ db_path = db_path or cursor_state_db_path()
160
+ conn = _open_db(db_path)
161
+ if conn is None:
162
+ return None
163
+ try:
164
+ settings_key = _find_settings_key(conn) or _scan_for_settings_key(conn)
165
+ if not settings_key:
166
+ return None
167
+ cursor = conn.cursor()
168
+ cursor.execute(
169
+ "SELECT value FROM ItemTable WHERE key = ?", (settings_key,)
170
+ )
171
+ row = cursor.fetchone()
172
+ if row is None:
173
+ return None
174
+ data = json.loads(row[0]) if isinstance(row[0], str) else {}
175
+ data["_mtrx_settings_key"] = settings_key
176
+ return data
177
+ except (sqlite3.Error, json.JSONDecodeError) as exc:
178
+ logger.debug("cursor_config: read error: %s", exc)
179
+ return None
180
+ finally:
181
+ conn.close()
182
+
183
+
184
+ def write_cursor_settings(
185
+ db_path: Path,
186
+ settings_key: str,
187
+ data: dict,
188
+ ) -> bool:
189
+ """Write the settings blob back to Cursor's state.vscdb."""
190
+ clean = {k: v for k, v in data.items() if not k.startswith("_mtrx_")}
191
+ conn = _open_db(db_path)
192
+ if conn is None:
193
+ return False
194
+ try:
195
+ cursor = conn.cursor()
196
+ cursor.execute(
197
+ "INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)",
198
+ (settings_key, json.dumps(clean)),
199
+ )
200
+ conn.commit()
201
+ return True
202
+ except sqlite3.Error as exc:
203
+ logger.debug("cursor_config: write error: %s", exc)
204
+ return False
205
+ finally:
206
+ conn.close()
207
+
208
+
209
+ def configure_cursor_for_proxy(
210
+ proxy_url: str,
211
+ proxy_api_key: str = "mtrx-cursor-proxy",
212
+ *,
213
+ db_path: Path | None = None,
214
+ ) -> dict | None:
215
+ """
216
+ Configure Cursor to route through the local MTRX proxy.
217
+
218
+ Sets the OpenAI API key and Override Base URL in state.vscdb.
219
+ Returns the previous settings dict (for later restoration), or None on failure.
220
+ """
221
+ db_path = db_path or cursor_state_db_path()
222
+ current = read_cursor_settings(db_path)
223
+
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:
231
+ settings_key = _CANDIDATE_SETTINGS_KEYS[0]
232
+ fresh: dict = {
233
+ _OPENAI_KEY_FIELD: proxy_api_key,
234
+ _OPENAI_BASE_URL_FIELD: proxy_url,
235
+ _OVERRIDE_OPENAI_BASE_URL_FIELD: True,
236
+ }
237
+ cursor = conn.cursor()
238
+ cursor.execute(
239
+ "INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)",
240
+ (settings_key, json.dumps(fresh)),
241
+ )
242
+ 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()
249
+
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
266
+
267
+
268
+ def restore_cursor_settings(
269
+ previous: dict,
270
+ *,
271
+ db_path: Path | None = None,
272
+ ) -> bool:
273
+ """Restore Cursor settings to their pre-proxy state."""
274
+ db_path = db_path or cursor_state_db_path()
275
+ settings_key = previous.get("_mtrx_settings_key", "")
276
+
277
+ if previous.get("_mtrx_was_absent"):
278
+ conn = _open_db(db_path)
279
+ if conn is None:
280
+ return False
281
+ try:
282
+ cursor = conn.cursor()
283
+ cursor.execute(
284
+ "DELETE FROM ItemTable WHERE key = ?", (settings_key,)
285
+ )
286
+ 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
313
+
314
+ return write_cursor_settings(db_path, settings_key, current)
315
+
316
+
317
+ def print_manual_setup_instructions(proxy_url: str) -> None:
318
+ """Print step-by-step instructions for the user to configure Cursor manually."""
319
+ print()
320
+ print(" Could not auto-configure Cursor settings.")
321
+ print(" Please configure manually in Cursor:")
322
+ print()
323
+ print(" 1. Open Cursor Settings (Cmd+, or Ctrl+,)")
324
+ print(" 2. Go to Models")
325
+ print(" 3. In the OpenAI API Keys section:")
326
+ print(f" - API Key: mtrx-cursor-proxy")
327
+ print(f" - Override Base URL: {proxy_url}")
328
+ print(" - Toggle ON 'Override OpenAI Base URL'")
329
+ print()
330
+ print(" All models (Claude, GPT, Gemini, etc.) will route through MTRX.")
331
+ print()
@@ -0,0 +1,351 @@
1
+ """
2
+ Local HTTP proxy server for routing Cursor IDE requests through MTRX.
3
+
4
+ Sits between Cursor and the MTRX API, injecting X-Matrx-* headers on every
5
+ request so that Cursor gets the same full-featured proxy pipeline as
6
+ ``mtrx claude`` and ``mtrx codex`` (injection, compression, memory, groups,
7
+ observability).
8
+
9
+ Cursor's "Override OpenAI Base URL" sends ALL model requests (OpenAI,
10
+ Anthropic, Google, etc.) in OpenAI Chat Completions format to the configured
11
+ URL. The MTRX router auto-detects the provider from the model name.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import base64
18
+ import json
19
+ import logging
20
+ import platform
21
+ import os
22
+ import shutil
23
+ import signal
24
+ import socket
25
+ import threading
26
+ import uuid
27
+ from typing import Any
28
+
29
+ import httpx
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _PROXY_API_KEY = "mtrx-cursor-proxy"
34
+ _FORWARDED_METHODS = {"POST", "GET", "PUT", "PATCH", "DELETE", "OPTIONS"}
35
+
36
+
37
+ def _capture_env_snapshot() -> dict:
38
+ out: dict = {
39
+ "os": platform.system(),
40
+ "shell": os.environ.get("SHELL", os.environ.get("COMSPEC", "")),
41
+ "cwd": os.getcwd(),
42
+ }
43
+ out["venv"] = os.environ.get("VIRTUAL_ENV", "") or None
44
+ node = shutil.which("node")
45
+ if node:
46
+ import subprocess as _sp
47
+ try:
48
+ r = _sp.run([node, "-v"], capture_output=True, text=True, timeout=2)
49
+ out["node"] = r.stdout.strip() if r.returncode == 0 else None
50
+ except Exception:
51
+ out["node"] = None
52
+ else:
53
+ out["node"] = None
54
+ return out
55
+
56
+
57
+ class CursorProxyServer:
58
+ """Async HTTP proxy that injects MTRX headers and forwards to the MTRX API."""
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ matrx_key: str,
64
+ matrx_base_url: str,
65
+ session_id: str | None = None,
66
+ group_id: str = "",
67
+ project_id: str = "",
68
+ host: str = "127.0.0.1",
69
+ port: int = 0,
70
+ ):
71
+ self.matrx_key = matrx_key
72
+ self.matrx_base_url = matrx_base_url.rstrip("/")
73
+ self.session_id = session_id or str(uuid.uuid4())
74
+ self.group_id = group_id
75
+ self.project_id = project_id
76
+ self.host = host
77
+ self.port = port
78
+
79
+ env_snap = _capture_env_snapshot()
80
+ self._env_b64 = (
81
+ base64.b64encode(json.dumps(env_snap).encode()).decode()
82
+ if env_snap
83
+ else ""
84
+ )
85
+ self._server: asyncio.Server | None = None
86
+ self._http_client: httpx.AsyncClient | None = None
87
+ self._loop: asyncio.AbstractEventLoop | None = None
88
+
89
+ @property
90
+ def url(self) -> str:
91
+ return f"http://{self.host}:{self.port}/v1"
92
+
93
+ def _build_matrx_headers(self) -> dict[str, str]:
94
+ headers: dict[str, str] = {
95
+ "X-Matrx-Key": self.matrx_key,
96
+ "X-Matrx-Agent-Id": "cursor",
97
+ "X-Matrx-Provider": "cursor",
98
+ "X-Matrx-Session-Id": self.session_id,
99
+ }
100
+ if self.group_id:
101
+ headers["X-Matrx-Group"] = self.group_id
102
+ if self.project_id:
103
+ headers["X-Matrx-Project-Id"] = self.project_id
104
+ if self._env_b64:
105
+ headers["X-Matrx-Env"] = self._env_b64
106
+ return headers
107
+
108
+ async def _handle_request(
109
+ self,
110
+ reader: asyncio.StreamReader,
111
+ writer: asyncio.StreamWriter,
112
+ ) -> None:
113
+ try:
114
+ await self._process_http(reader, writer)
115
+ except Exception:
116
+ logger.debug("cursor_proxy: connection error", exc_info=True)
117
+ finally:
118
+ try:
119
+ writer.close()
120
+ await writer.wait_closed()
121
+ except Exception:
122
+ pass
123
+
124
+ async def _process_http(
125
+ self,
126
+ reader: asyncio.StreamReader,
127
+ writer: asyncio.StreamWriter,
128
+ ) -> None:
129
+ request_line = await reader.readline()
130
+ if not request_line:
131
+ return
132
+ parts = request_line.decode("utf-8", errors="replace").strip().split(" ", 2)
133
+ if len(parts) < 3:
134
+ return
135
+ method, raw_path, _ = parts
136
+
137
+ headers_raw: dict[str, str] = {}
138
+ while True:
139
+ line = await reader.readline()
140
+ decoded = line.decode("utf-8", errors="replace").strip()
141
+ if not decoded:
142
+ break
143
+ if ":" in decoded:
144
+ key, _, value = decoded.partition(":")
145
+ headers_raw[key.strip().lower()] = value.strip()
146
+
147
+ content_length = int(headers_raw.get("content-length", "0"))
148
+ body = b""
149
+ if content_length > 0:
150
+ body = await reader.readexactly(content_length)
151
+
152
+ if method not in _FORWARDED_METHODS:
153
+ self._send_response(writer, 405, {"error": "Method not allowed"})
154
+ return
155
+
156
+ upstream_path = raw_path
157
+ if upstream_path.startswith("/v1/"):
158
+ upstream_path = "/" + upstream_path[4:]
159
+ elif upstream_path == "/v1":
160
+ upstream_path = "/"
161
+
162
+ upstream_url = f"{self.matrx_base_url}/v1{upstream_path}"
163
+
164
+ upstream_headers: dict[str, str] = {}
165
+ for key, value in headers_raw.items():
166
+ if key in {"host", "connection", "transfer-encoding"}:
167
+ continue
168
+ if key == "authorization":
169
+ continue
170
+ upstream_headers[key] = value
171
+
172
+ upstream_headers.update(self._build_matrx_headers())
173
+ upstream_headers["Authorization"] = f"Bearer {self.matrx_key}"
174
+
175
+ is_stream = False
176
+ if body and method == "POST":
177
+ try:
178
+ parsed = json.loads(body)
179
+ is_stream = parsed.get("stream", False)
180
+ except (json.JSONDecodeError, AttributeError):
181
+ pass
182
+
183
+ assert self._http_client is not None
184
+
185
+ if is_stream:
186
+ await self._proxy_streaming(
187
+ writer, method, upstream_url, upstream_headers, body
188
+ )
189
+ else:
190
+ await self._proxy_buffered(
191
+ writer, method, upstream_url, upstream_headers, body
192
+ )
193
+
194
+ async def _proxy_buffered(
195
+ self,
196
+ writer: asyncio.StreamWriter,
197
+ method: str,
198
+ url: str,
199
+ headers: dict[str, str],
200
+ body: bytes,
201
+ ) -> None:
202
+ assert self._http_client is not None
203
+ try:
204
+ resp = await self._http_client.request(
205
+ method, url, headers=headers, content=body, timeout=120
206
+ )
207
+ resp_headers = {
208
+ "Content-Type": resp.headers.get("content-type", "application/json"),
209
+ "Content-Length": str(len(resp.content)),
210
+ }
211
+ for h in ("x-matrx-request-id", "x-matrx-latency-ms", "x-matrx-tokens-saved"):
212
+ if h in resp.headers:
213
+ resp_headers[h] = resp.headers[h]
214
+ self._send_raw_response(writer, resp.status_code, resp_headers, resp.content)
215
+ except httpx.HTTPError as exc:
216
+ logger.warning("cursor_proxy: upstream error: %s", exc)
217
+ self._send_response(writer, 502, {"error": f"Upstream error: {exc}"})
218
+
219
+ async def _proxy_streaming(
220
+ self,
221
+ writer: asyncio.StreamWriter,
222
+ method: str,
223
+ url: str,
224
+ headers: dict[str, str],
225
+ body: bytes,
226
+ ) -> None:
227
+ assert self._http_client is not None
228
+ try:
229
+ async with self._http_client.stream(
230
+ method, url, headers=headers, content=body, timeout=300
231
+ ) as resp:
232
+ resp_headers = {
233
+ "Content-Type": resp.headers.get(
234
+ "content-type", "text/event-stream"
235
+ ),
236
+ "Cache-Control": "no-cache",
237
+ "Transfer-Encoding": "chunked",
238
+ }
239
+ for h in ("x-matrx-request-id", "x-matrx-latency-ms", "x-matrx-tokens-saved"):
240
+ if h in resp.headers:
241
+ resp_headers[h] = resp.headers[h]
242
+
243
+ header_block = f"HTTP/1.1 {resp.status_code} OK\r\n"
244
+ for k, v in resp_headers.items():
245
+ header_block += f"{k}: {v}\r\n"
246
+ header_block += "\r\n"
247
+ writer.write(header_block.encode("utf-8"))
248
+ await writer.drain()
249
+
250
+ async for chunk in resp.aiter_bytes():
251
+ chunk_header = f"{len(chunk):x}\r\n".encode("utf-8")
252
+ writer.write(chunk_header + chunk + b"\r\n")
253
+ await writer.drain()
254
+
255
+ writer.write(b"0\r\n\r\n")
256
+ await writer.drain()
257
+ except httpx.HTTPError as exc:
258
+ logger.warning("cursor_proxy: streaming error: %s", exc)
259
+ self._send_response(writer, 502, {"error": f"Upstream error: {exc}"})
260
+
261
+ @staticmethod
262
+ def _send_response(
263
+ writer: asyncio.StreamWriter,
264
+ status: int,
265
+ body: dict[str, Any],
266
+ ) -> None:
267
+ content = json.dumps(body).encode("utf-8")
268
+ CursorProxyServer._send_raw_response(
269
+ writer,
270
+ status,
271
+ {"Content-Type": "application/json", "Content-Length": str(len(content))},
272
+ content,
273
+ )
274
+
275
+ @staticmethod
276
+ def _send_raw_response(
277
+ writer: asyncio.StreamWriter,
278
+ status: int,
279
+ headers: dict[str, str],
280
+ content: bytes,
281
+ ) -> None:
282
+ reason = "OK" if 200 <= status < 300 else "Error"
283
+ header_block = f"HTTP/1.1 {status} {reason}\r\n"
284
+ for k, v in headers.items():
285
+ header_block += f"{k}: {v}\r\n"
286
+ header_block += "\r\n"
287
+ writer.write(header_block.encode("utf-8") + content)
288
+
289
+ def _pick_port(self) -> int:
290
+ if self.port:
291
+ return self.port
292
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
293
+ s.bind(("127.0.0.1", 0))
294
+ return s.getsockname()[1]
295
+
296
+ async def _run(self) -> None:
297
+ self.port = self._pick_port()
298
+ self._http_client = httpx.AsyncClient(http2=False, follow_redirects=True)
299
+ self._server = await asyncio.start_server(
300
+ self._handle_request, self.host, self.port
301
+ )
302
+ logger.info("cursor_proxy: listening on %s:%s", self.host, self.port)
303
+ async with self._server:
304
+ await self._server.serve_forever()
305
+
306
+ async def _shutdown(self) -> None:
307
+ if self._server:
308
+ self._server.close()
309
+ await self._server.wait_closed()
310
+ if self._http_client:
311
+ await self._http_client.aclose()
312
+
313
+ def start_background(self) -> None:
314
+ """Start the proxy in a background daemon thread. Returns once listening."""
315
+ ready = threading.Event()
316
+
317
+ def _run_loop() -> None:
318
+ loop = asyncio.new_event_loop()
319
+ asyncio.set_event_loop(loop)
320
+ self._loop = loop
321
+
322
+ async def _start_and_signal() -> None:
323
+ self.port = self._pick_port()
324
+ self._http_client = httpx.AsyncClient(
325
+ http2=False, follow_redirects=True
326
+ )
327
+ self._server = await asyncio.start_server(
328
+ self._handle_request, self.host, self.port
329
+ )
330
+ ready.set()
331
+ async with self._server:
332
+ await self._server.serve_forever()
333
+
334
+ try:
335
+ loop.run_until_complete(_start_and_signal())
336
+ except asyncio.CancelledError:
337
+ pass
338
+ finally:
339
+ loop.run_until_complete(self._shutdown())
340
+ loop.close()
341
+
342
+ thread = threading.Thread(target=_run_loop, daemon=True)
343
+ thread.start()
344
+ ready.wait(timeout=10)
345
+ if not ready.is_set():
346
+ raise RuntimeError("Cursor proxy failed to start within 10 seconds")
347
+
348
+ def stop(self) -> None:
349
+ """Stop the background proxy."""
350
+ if self._server and self._loop and self._loop.is_running():
351
+ self._loop.call_soon_threadsafe(self._server.close)