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.
@@ -0,0 +1,343 @@
1
+ """
2
+ Cross-platform background service management for the MTRX Cursor proxy.
3
+
4
+ Handles installing, starting, stopping, and checking the proxy daemon on
5
+ macOS (Launch Agent), Linux (systemd user unit), and Windows (detached
6
+ subprocess with PID tracking).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import platform
15
+ import shutil
16
+ import signal
17
+ import subprocess
18
+ import sys
19
+ import textwrap
20
+ import time
21
+ from pathlib import Path
22
+
23
+ import httpx
24
+
25
+ from matrx.cli.cursor_proxy import DEFAULT_PORT, HEALTH_PATH, PROXY_HOST
26
+ from matrx.cli.state import config_dir
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _LAUNCH_AGENT_LABEL = "so.mtrx.cursor-proxy"
31
+ _SYSTEMD_UNIT_NAME = "mtrx-cursor-proxy.service"
32
+
33
+
34
+ def pid_file_path() -> Path:
35
+ return config_dir() / "cursor-proxy.pid"
36
+
37
+
38
+ def proxy_config_path() -> Path:
39
+ return config_dir() / "cursor-proxy.json"
40
+
41
+
42
+ def _find_python() -> str:
43
+ """Return the path to the current Python interpreter."""
44
+ return sys.executable
45
+
46
+
47
+ def _launch_agent_plist_path() -> Path:
48
+ return Path.home() / "Library" / "LaunchAgents" / f"{_LAUNCH_AGENT_LABEL}.plist"
49
+
50
+
51
+ def _systemd_unit_path() -> Path:
52
+ return Path.home() / ".config" / "systemd" / "user" / _SYSTEMD_UNIT_NAME
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Proxy config persistence (key + base_url needed to start the daemon)
57
+ # ---------------------------------------------------------------------------
58
+
59
+ def save_proxy_config(
60
+ *,
61
+ matrx_key: str,
62
+ matrx_base_url: str,
63
+ host: str = PROXY_HOST,
64
+ port: int = DEFAULT_PORT,
65
+ ) -> Path:
66
+ conf = proxy_config_path()
67
+ conf.parent.mkdir(parents=True, exist_ok=True)
68
+ conf.write_text(
69
+ json.dumps({
70
+ "matrx_key": matrx_key,
71
+ "matrx_base_url": matrx_base_url,
72
+ "host": host,
73
+ "port": port,
74
+ }),
75
+ encoding="utf-8",
76
+ )
77
+ os.chmod(conf, 0o600)
78
+ return conf
79
+
80
+
81
+ def load_proxy_config() -> dict | None:
82
+ conf = proxy_config_path()
83
+ if not conf.exists():
84
+ return None
85
+ try:
86
+ return json.loads(conf.read_text(encoding="utf-8"))
87
+ except Exception:
88
+ return None
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Service lifecycle
93
+ # ---------------------------------------------------------------------------
94
+
95
+ def install_service(
96
+ *,
97
+ matrx_key: str,
98
+ matrx_base_url: str,
99
+ host: str = PROXY_HOST,
100
+ port: int = DEFAULT_PORT,
101
+ ) -> bool:
102
+ """Install and start the proxy as a background service."""
103
+ save_proxy_config(
104
+ matrx_key=matrx_key,
105
+ matrx_base_url=matrx_base_url,
106
+ host=host,
107
+ port=port,
108
+ )
109
+
110
+ system = platform.system()
111
+ if system == "Darwin":
112
+ return _install_macos(host, port)
113
+ if system == "Linux":
114
+ return _install_linux(host, port)
115
+ return _install_generic(host, port)
116
+
117
+
118
+ def uninstall_service() -> bool:
119
+ """Stop and remove the background service."""
120
+ system = platform.system()
121
+ ok = True
122
+ if system == "Darwin":
123
+ ok = _uninstall_macos()
124
+ elif system == "Linux":
125
+ ok = _uninstall_linux()
126
+ else:
127
+ ok = _stop_by_pid()
128
+
129
+ proxy_config_path().unlink(missing_ok=True)
130
+ pid_file_path().unlink(missing_ok=True)
131
+ return ok
132
+
133
+
134
+ def is_proxy_running(host: str = PROXY_HOST, port: int = DEFAULT_PORT) -> bool:
135
+ """Check if the proxy is running and healthy."""
136
+ try:
137
+ resp = httpx.get(
138
+ f"http://{host}:{port}{HEALTH_PATH}",
139
+ timeout=3,
140
+ )
141
+ return resp.status_code == 200
142
+ except Exception:
143
+ return False
144
+
145
+
146
+ def get_proxy_status(host: str = PROXY_HOST, port: int = DEFAULT_PORT) -> dict | None:
147
+ """Get proxy status info, or None if not reachable."""
148
+ try:
149
+ resp = httpx.get(
150
+ f"http://{host}:{port}{HEALTH_PATH}",
151
+ timeout=3,
152
+ )
153
+ if resp.status_code == 200:
154
+ return resp.json()
155
+ except Exception:
156
+ pass
157
+ return None
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # macOS: Launch Agent
162
+ # ---------------------------------------------------------------------------
163
+
164
+ def _install_macos(host: str, port: int) -> bool:
165
+ _unload_launchd()
166
+
167
+ python = _find_python()
168
+ conf = str(proxy_config_path())
169
+ pid = str(pid_file_path())
170
+ log_dir = config_dir() / "logs"
171
+ log_dir.mkdir(parents=True, exist_ok=True)
172
+
173
+ plist_content = textwrap.dedent(f"""\
174
+ <?xml version="1.0" encoding="UTF-8"?>
175
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
176
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
177
+ <plist version="1.0">
178
+ <dict>
179
+ <key>Label</key>
180
+ <string>{_LAUNCH_AGENT_LABEL}</string>
181
+ <key>ProgramArguments</key>
182
+ <array>
183
+ <string>{python}</string>
184
+ <string>-m</string>
185
+ <string>matrx.cli.cursor_daemon</string>
186
+ <string>--config</string>
187
+ <string>{conf}</string>
188
+ <string>--pid-file</string>
189
+ <string>{pid}</string>
190
+ </array>
191
+ <key>RunAtLoad</key>
192
+ <true/>
193
+ <key>KeepAlive</key>
194
+ <true/>
195
+ <key>StandardOutPath</key>
196
+ <string>{log_dir / "cursor-proxy.out.log"}</string>
197
+ <key>StandardErrorPath</key>
198
+ <string>{log_dir / "cursor-proxy.err.log"}</string>
199
+ </dict>
200
+ </plist>
201
+ """)
202
+
203
+ plist_path = _launch_agent_plist_path()
204
+ plist_path.parent.mkdir(parents=True, exist_ok=True)
205
+ plist_path.write_text(plist_content, encoding="utf-8")
206
+
207
+ result = subprocess.run(
208
+ ["launchctl", "load", "-w", str(plist_path)],
209
+ capture_output=True,
210
+ timeout=10,
211
+ )
212
+ if result.returncode != 0:
213
+ logger.warning("launchctl load failed: %s", result.stderr.decode(errors="replace"))
214
+ return _install_generic(host, port)
215
+
216
+ time.sleep(1)
217
+ return is_proxy_running(host, port)
218
+
219
+
220
+ def _unload_launchd() -> None:
221
+ plist_path = _launch_agent_plist_path()
222
+ if plist_path.exists():
223
+ subprocess.run(
224
+ ["launchctl", "unload", str(plist_path)],
225
+ capture_output=True,
226
+ timeout=10,
227
+ )
228
+
229
+
230
+ def _uninstall_macos() -> bool:
231
+ _unload_launchd()
232
+ plist_path = _launch_agent_plist_path()
233
+ plist_path.unlink(missing_ok=True)
234
+ return True
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # Linux: systemd user unit
239
+ # ---------------------------------------------------------------------------
240
+
241
+ def _install_linux(host: str, port: int) -> bool:
242
+ python = _find_python()
243
+ conf = str(proxy_config_path())
244
+ pid = str(pid_file_path())
245
+
246
+ unit_content = textwrap.dedent(f"""\
247
+ [Unit]
248
+ Description=MTRX Cursor MITM Proxy
249
+ After=network.target
250
+
251
+ [Service]
252
+ Type=simple
253
+ ExecStart={python} -m matrx.cli.cursor_daemon --config {conf} --pid-file {pid}
254
+ Restart=on-failure
255
+ RestartSec=5
256
+
257
+ [Install]
258
+ WantedBy=default.target
259
+ """)
260
+
261
+ unit_path = _systemd_unit_path()
262
+ unit_path.parent.mkdir(parents=True, exist_ok=True)
263
+ unit_path.write_text(unit_content, encoding="utf-8")
264
+
265
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True, timeout=10)
266
+ result = subprocess.run(
267
+ ["systemctl", "--user", "enable", "--now", _SYSTEMD_UNIT_NAME],
268
+ capture_output=True,
269
+ timeout=10,
270
+ )
271
+ if result.returncode != 0:
272
+ logger.warning("systemctl enable failed: %s", result.stderr.decode(errors="replace"))
273
+ return _install_generic(host, port)
274
+
275
+ time.sleep(1)
276
+ return is_proxy_running(host, port)
277
+
278
+
279
+ def _uninstall_linux() -> bool:
280
+ subprocess.run(
281
+ ["systemctl", "--user", "disable", "--now", _SYSTEMD_UNIT_NAME],
282
+ capture_output=True,
283
+ timeout=10,
284
+ )
285
+ unit_path = _systemd_unit_path()
286
+ unit_path.unlink(missing_ok=True)
287
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True, timeout=10)
288
+ return True
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Generic: detached subprocess with PID file
293
+ # ---------------------------------------------------------------------------
294
+
295
+ def _install_generic(host: str, port: int) -> bool:
296
+ """Start the proxy as a detached subprocess (works everywhere)."""
297
+ _stop_by_pid()
298
+
299
+ python = _find_python()
300
+ conf = str(proxy_config_path())
301
+ pid = str(pid_file_path())
302
+
303
+ kwargs: dict = {
304
+ "start_new_session": True,
305
+ }
306
+ if sys.platform == "win32":
307
+ kwargs = {
308
+ "creationflags": subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS,
309
+ }
310
+
311
+ log_dir = config_dir() / "logs"
312
+ log_dir.mkdir(parents=True, exist_ok=True)
313
+ stdout_log = open(log_dir / "cursor-proxy.out.log", "a")
314
+ stderr_log = open(log_dir / "cursor-proxy.err.log", "a")
315
+
316
+ subprocess.Popen(
317
+ [python, "-m", "matrx.cli.cursor_daemon", "--config", conf, "--pid-file", pid],
318
+ stdout=stdout_log,
319
+ stderr=stderr_log,
320
+ stdin=subprocess.DEVNULL,
321
+ **kwargs,
322
+ )
323
+
324
+ # Wait a moment for startup
325
+ for _ in range(10):
326
+ time.sleep(0.5)
327
+ if is_proxy_running(host, port):
328
+ return True
329
+ return False
330
+
331
+
332
+ def _stop_by_pid() -> bool:
333
+ pf = pid_file_path()
334
+ if not pf.exists():
335
+ return True
336
+ try:
337
+ pid = int(pf.read_text(encoding="utf-8").strip())
338
+ os.kill(pid, signal.SIGTERM)
339
+ time.sleep(0.5)
340
+ except (ValueError, ProcessLookupError, PermissionError):
341
+ pass
342
+ pf.unlink(missing_ok=True)
343
+ return True
@@ -80,6 +80,15 @@ class LaunchPlan:
80
80
  orchestration: dict | None = None
81
81
 
82
82
 
83
+ def _runtime_agent_basename(tool: str) -> tuple[str, str, list[str], str]:
84
+ if tool == "codex":
85
+ return "codex-cli", "Codex CLI", ["cli", "codex"], "codex"
86
+ if tool == "claude":
87
+ return "claude-cli", "Claude CLI", ["claude", "cli"], "claude_code"
88
+ normalized = f"{tool}-cli"
89
+ return normalized, f"{tool.capitalize()} CLI", ["cli", tool], tool
90
+
91
+
83
92
  def configured_route(state: dict, tool: str) -> str | None:
84
93
  route = state.get("defaults", {}).get(tool)
85
94
  if route in VALID_ROUTES:
@@ -132,6 +141,12 @@ def build_launch_plan(
132
141
  env = dict(source_env)
133
142
  auth_source = ""
134
143
  launch_args = list(passthrough_args or [])
144
+ orchestration = _build_orchestration_metadata(
145
+ state=state,
146
+ tool=tool,
147
+ route=route,
148
+ env=source_env,
149
+ )
135
150
 
136
151
  if tool == "codex":
137
152
  env, auth_source, launch_args = _build_codex_env(
@@ -139,9 +154,15 @@ def build_launch_plan(
139
154
  route,
140
155
  env,
141
156
  launch_args,
157
+ orchestration=orchestration,
142
158
  )
143
159
  elif tool == "claude":
144
- env, auth_source = _build_claude_env(state, route, env)
160
+ env, auth_source = _build_claude_env(
161
+ state,
162
+ route,
163
+ env,
164
+ orchestration=orchestration,
165
+ )
145
166
  else:
146
167
  raise ValueError(f"Unsupported tool: {tool}")
147
168
 
@@ -152,12 +173,7 @@ def build_launch_plan(
152
173
  args=launch_args,
153
174
  env=env,
154
175
  auth_source=auth_source,
155
- orchestration=_build_orchestration_metadata(
156
- state=state,
157
- tool=tool,
158
- route=route,
159
- env=source_env,
160
- ),
176
+ orchestration=orchestration,
161
177
  )
162
178
 
163
179
 
@@ -438,6 +454,8 @@ def _build_codex_env(
438
454
  route: str,
439
455
  env: dict[str, str],
440
456
  passthrough_args: list[str],
457
+ *,
458
+ orchestration: dict | None = None,
441
459
  ) -> tuple[dict[str, str], str, list[str]]:
442
460
  matrx = state["auth"]["matrx"]
443
461
  openai = state["auth"]["openai"]
@@ -457,10 +475,14 @@ def _build_codex_env(
457
475
  env_b64 = base64.b64encode(json.dumps(env_snap).encode()).decode() if env_snap else ""
458
476
  session_id = str(uuid.uuid4())
459
477
  group_id, project_id = _resolve_matrx_context_overrides(state, env)
478
+ runtime_agent_id = (
479
+ (orchestration or {}).get("agent_id")
480
+ or _runtime_agent_basename("codex")[0]
481
+ )
460
482
  header_parts = [
461
483
  f'"Authorization" = "Bearer {access_token}"',
462
484
  f'"X-Matrx-Key" = "{mx_key}"',
463
- '"X-Matrx-Agent-Id" = "codex-cli"',
485
+ f'"X-Matrx-Agent-Id" = "{runtime_agent_id}"',
464
486
  '"X-Matrx-Provider" = "codex"',
465
487
  f'"X-Matrx-Session-Id" = "{session_id}"',
466
488
  ]
@@ -501,6 +523,8 @@ def _build_claude_env(
501
523
  state: dict,
502
524
  route: str,
503
525
  env: dict[str, str],
526
+ *,
527
+ orchestration: dict | None = None,
504
528
  ) -> tuple[dict[str, str], str]:
505
529
  matrx = state["auth"]["matrx"]
506
530
  anthropic = state["auth"]["anthropic"]
@@ -520,13 +544,17 @@ def _build_claude_env(
520
544
  env.pop("ANTHROPIC_API_KEY", None)
521
545
  group_id, project_id = _resolve_matrx_context_overrides(state, env)
522
546
  session_id = str(uuid.uuid4())
547
+ runtime_agent_id = (
548
+ (orchestration or {}).get("agent_id")
549
+ or _runtime_agent_basename("claude")[0]
550
+ )
523
551
  # Evolutionary scaffolding: env snapshot for AI context injection
524
552
  env_snap = _capture_env_snapshot()
525
553
  env_b64 = base64.b64encode(json.dumps(env_snap).encode()).decode() if env_snap else ""
526
554
  custom_headers = "\n".join(
527
555
  [
528
556
  f"x-matrx-key: {mx_key}",
529
- "x-matrx-agent-id: claude-cli",
557
+ f"x-matrx-agent-id: {runtime_agent_id}",
530
558
  "x-matrx-provider: claude_code",
531
559
  f"x-matrx-session-id: {session_id}",
532
560
  ]
@@ -592,27 +620,18 @@ def _build_orchestration_metadata(
592
620
  if not mx_key:
593
621
  return None
594
622
 
595
- if tool == "codex":
596
- agent_id = "codex-cli"
597
- provider = "codex"
598
- capabilities = ["cli", "codex"]
599
- name = "Codex CLI"
600
- elif tool == "claude":
601
- agent_id = "claude-cli"
602
- provider = "claude_code"
603
- capabilities = ["claude", "cli"]
604
- name = "Claude CLI"
605
- else:
606
- agent_id = f"{tool}-cli"
607
- provider = tool
608
- capabilities = ["cli", tool]
609
- name = f"{tool.capitalize()} CLI"
623
+ agent_kind, name, capabilities, provider = _runtime_agent_basename(tool)
624
+ agent_id = f"{agent_kind}-{uuid.uuid4().hex[:8]}"
610
625
 
611
626
  _, project_id = _resolve_matrx_context_overrides(state, env)
627
+ base_url = ensure_root_url(
628
+ env.get("MATRX_BASE_URL") or state.get("auth", {}).get("matrx", {}).get("base_url")
629
+ )
612
630
  return {
613
- "base_url": ensure_root_url(state.get("auth", {}).get("matrx", {}).get("base_url")),
631
+ "base_url": base_url,
614
632
  "matrx_key": mx_key,
615
633
  "agent_id": agent_id,
634
+ "agent_kind": agent_kind,
616
635
  "provider": provider,
617
636
  "name": name,
618
637
  "capabilities": capabilities,
@@ -633,6 +652,7 @@ def _best_effort_register_cli_agent(orchestration: dict) -> None:
633
652
  payload = {
634
653
  "agent_id": agent_id,
635
654
  "name": orchestration.get("name") or agent_id,
655
+ "agent_kind": orchestration.get("agent_kind"),
636
656
  "capabilities": list(orchestration.get("capabilities") or []),
637
657
  "max_concurrent_tasks": 1,
638
658
  }
@@ -736,8 +756,8 @@ def _validate_claude_launch_plan(plan: LaunchPlan, state: dict) -> None:
736
756
  raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with X-Matrx-Session-Id")
737
757
  if "x-matrx-provider: claude_code" not in lowered_headers:
738
758
  raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with X-Matrx-Provider=claude_code")
739
- if "x-matrx-agent-id: claude-cli" not in lowered_headers:
740
- raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with X-Matrx-Agent-Id=claude-cli")
759
+ if "x-matrx-agent-id:" not in lowered_headers:
760
+ raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with X-Matrx-Agent-Id")
741
761
 
742
762
  if plan.env.get("ANTHROPIC_AUTH_TOKEN"):
743
763
  raise ValueError("Claude Matrx route should not set ANTHROPIC_AUTH_TOKEN")