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.
@@ -1,16 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import datetime as dt
3
4
  import base64
4
5
  import hashlib
5
- import datetime as dt
6
- import httpx
7
6
  import json
8
7
  import os
9
8
  import platform
10
9
  import re
11
10
  import shutil
12
11
  import subprocess
12
+ import threading
13
13
  import uuid
14
+
15
+ import httpx
14
16
  try:
15
17
  import tomllib
16
18
  except ModuleNotFoundError: # pragma: no cover - Python 3.10 compatibility
@@ -23,6 +25,7 @@ from matrx.cli.state import (
23
25
  ensure_root_url,
24
26
  ensure_v1_url,
25
27
  get_workspace_binding,
28
+ normalize_matrx_key,
26
29
  set_workspace_binding,
27
30
  )
28
31
 
@@ -74,6 +77,7 @@ class LaunchPlan:
74
77
  args: list[str]
75
78
  env: dict[str, str]
76
79
  auth_source: str
80
+ orchestration: dict | None = None
77
81
 
78
82
 
79
83
  def configured_route(state: dict, tool: str) -> str | None:
@@ -124,7 +128,8 @@ def build_launch_plan(
124
128
  raise ValueError(f"{tool} executable not found in PATH")
125
129
 
126
130
  route = resolve_route(state, tool, route_override)
127
- env = dict(os.environ if base_env is None else base_env)
131
+ source_env = dict(os.environ if base_env is None else base_env)
132
+ env = dict(source_env)
128
133
  auth_source = ""
129
134
  launch_args = list(passthrough_args or [])
130
135
 
@@ -147,6 +152,12 @@ def build_launch_plan(
147
152
  args=launch_args,
148
153
  env=env,
149
154
  auth_source=auth_source,
155
+ orchestration=_build_orchestration_metadata(
156
+ state=state,
157
+ tool=tool,
158
+ route=route,
159
+ env=source_env,
160
+ ),
150
161
  )
151
162
 
152
163
 
@@ -180,8 +191,20 @@ def prepare_routed_setup(
180
191
 
181
192
 
182
193
  def launch(plan: LaunchPlan) -> int:
183
- result = subprocess.run([plan.executable, *plan.args], env=plan.env, check=False)
184
- return int(result.returncode)
194
+ heartbeat_stop = None
195
+ heartbeat_thread = None
196
+ if plan.orchestration:
197
+ _best_effort_register_cli_agent(plan.orchestration)
198
+ heartbeat_stop, heartbeat_thread = _start_orchestration_heartbeat(plan.orchestration)
199
+
200
+ try:
201
+ result = subprocess.run([plan.executable, *plan.args], env=plan.env, check=False)
202
+ return int(result.returncode)
203
+ finally:
204
+ if heartbeat_stop is not None:
205
+ heartbeat_stop.set()
206
+ if heartbeat_thread is not None:
207
+ heartbeat_thread.join(timeout=2)
185
208
 
186
209
 
187
210
  def validate_launch_plan(plan: LaunchPlan, state: dict) -> None:
@@ -360,16 +383,16 @@ def _resolve_matrx_route_key(
360
383
  state: dict,
361
384
  env: dict[str, str],
362
385
  ) -> tuple[str, str]:
363
- env_key = (env.get("MTRX_KEY") or "").strip()
386
+ env_key = normalize_matrx_key(env.get("MTRX_KEY"))
364
387
  if env_key:
365
388
  return env_key, "env_matrx_key"
366
389
 
367
390
  binding = get_workspace_binding(state, cwd=_workspace_cwd(env))
368
- binding_key = ((binding or {}).get("matrx_key") or "").strip()
391
+ binding_key = normalize_matrx_key((binding or {}).get("matrx_key"))
369
392
  if binding_key:
370
393
  return binding_key, "workspace_matrx_key"
371
394
 
372
- saved_key = (state.get("auth", {}).get("matrx", {}).get("key") or "").strip()
395
+ saved_key = normalize_matrx_key(state.get("auth", {}).get("matrx", {}).get("key"))
373
396
  if saved_key:
374
397
  return saved_key, "saved_matrx_key"
375
398
 
@@ -555,6 +578,135 @@ def _build_claude_env(
555
578
  return env, "existing_claude_auth"
556
579
 
557
580
 
581
+ def _build_orchestration_metadata(
582
+ *,
583
+ state: dict,
584
+ tool: str,
585
+ route: str,
586
+ env: dict[str, str],
587
+ ) -> dict | None:
588
+ if route != "matrx":
589
+ return None
590
+
591
+ mx_key, _ = _resolve_matrx_route_key(state, env)
592
+ if not mx_key:
593
+ return None
594
+
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"
610
+
611
+ _, project_id = _resolve_matrx_context_overrides(state, env)
612
+ return {
613
+ "base_url": ensure_root_url(state.get("auth", {}).get("matrx", {}).get("base_url")),
614
+ "matrx_key": mx_key,
615
+ "agent_id": agent_id,
616
+ "provider": provider,
617
+ "name": name,
618
+ "capabilities": capabilities,
619
+ "project_id": project_id or None,
620
+ "heartbeat_interval_seconds": 60,
621
+ }
622
+
623
+
624
+ def _best_effort_register_cli_agent(orchestration: dict) -> None:
625
+ if not orchestration:
626
+ return
627
+ base_url = (orchestration.get("base_url") or "").rstrip("/")
628
+ matrx_key = (orchestration.get("matrx_key") or "").strip()
629
+ agent_id = (orchestration.get("agent_id") or "").strip()
630
+ if not base_url or not matrx_key or not agent_id:
631
+ return
632
+
633
+ payload = {
634
+ "agent_id": agent_id,
635
+ "name": orchestration.get("name") or agent_id,
636
+ "capabilities": list(orchestration.get("capabilities") or []),
637
+ "max_concurrent_tasks": 1,
638
+ }
639
+ headers = {
640
+ "X-Matrx-Key": matrx_key,
641
+ "X-Matrx-Agent-Id": agent_id,
642
+ "X-Matrx-Provider": orchestration.get("provider") or "cli",
643
+ "Content-Type": "application/json",
644
+ }
645
+ project_id = (orchestration.get("project_id") or "").strip()
646
+ if project_id:
647
+ headers["X-Matrx-Project-Id"] = project_id
648
+
649
+ try:
650
+ with httpx.Client(timeout=5) as client:
651
+ response = client.post(
652
+ f"{base_url}/v1/orchestration/agents/register",
653
+ headers=headers,
654
+ json=payload,
655
+ )
656
+ if response.status_code in {404, 401, 403}:
657
+ return
658
+ response.raise_for_status()
659
+ except httpx.HTTPError:
660
+ return
661
+
662
+
663
+ def _heartbeat_cli_agent(orchestration: dict, stop_event) -> None:
664
+ base_url = (orchestration.get("base_url") or "").rstrip("/")
665
+ matrx_key = (orchestration.get("matrx_key") or "").strip()
666
+ agent_id = (orchestration.get("agent_id") or "").strip()
667
+ if not base_url or not matrx_key or not agent_id:
668
+ return
669
+
670
+ headers = {
671
+ "X-Matrx-Key": matrx_key,
672
+ "X-Matrx-Agent-Id": agent_id,
673
+ "X-Matrx-Provider": orchestration.get("provider") or "cli",
674
+ }
675
+ project_id = (orchestration.get("project_id") or "").strip()
676
+ if project_id:
677
+ headers["X-Matrx-Project-Id"] = project_id
678
+
679
+ interval = int(orchestration.get("heartbeat_interval_seconds") or 60)
680
+ while True:
681
+ try:
682
+ with httpx.Client(timeout=5) as client:
683
+ response = client.patch(
684
+ f"{base_url}/v1/orchestration/agents/{agent_id}/heartbeat",
685
+ headers=headers,
686
+ )
687
+ if response.status_code in {404, 401, 403}:
688
+ return
689
+ except httpx.HTTPError:
690
+ return
691
+ if stop_event.wait(interval):
692
+ return
693
+
694
+
695
+ def _start_orchestration_heartbeat(
696
+ orchestration: dict,
697
+ ) -> tuple[threading.Event, threading.Thread] | tuple[None, None]:
698
+ if not orchestration:
699
+ return None, None
700
+ stop_event = threading.Event()
701
+ thread = threading.Thread(
702
+ target=_heartbeat_cli_agent,
703
+ args=(orchestration, stop_event),
704
+ daemon=True,
705
+ )
706
+ thread.start()
707
+ return stop_event, thread
708
+
709
+
558
710
  def _validate_claude_launch_plan(plan: LaunchPlan, state: dict) -> None:
559
711
  """Validate Claude launch plan before spawning."""
560
712
  if plan.route != "matrx":
@@ -9,6 +9,7 @@ import sys
9
9
  import threading
10
10
  import urllib.parse
11
11
  import webbrowser
12
+ from pathlib import Path
12
13
 
13
14
  import httpx
14
15
 
@@ -29,6 +30,15 @@ from matrx.cli.launcher import (
29
30
  resolve_route,
30
31
  validate_launch_plan,
31
32
  )
33
+ from matrx.cli.cursor_config import (
34
+ configure_cursor_for_proxy,
35
+ cursor_is_running,
36
+ cursor_state_db_path,
37
+ print_manual_setup_instructions,
38
+ read_cursor_settings,
39
+ restore_cursor_settings,
40
+ )
41
+ from matrx.cli.cursor_proxy import CursorProxyServer
32
42
  from matrx.cli.state import (
33
43
  ensure_app_url,
34
44
  ensure_root_url,
@@ -36,6 +46,7 @@ from matrx.cli.state import (
36
46
  get_workspace_binding,
37
47
  load_state,
38
48
  mask_secret,
49
+ normalize_matrx_key,
39
50
  save_state,
40
51
  )
41
52
 
@@ -62,6 +73,8 @@ def main(argv: list[str] | None = None) -> int:
62
73
  return _cmd_personal(args)
63
74
  if args.command in {"codex", "claude"}:
64
75
  return _cmd_launch(args.command, args.route, remainder)
76
+ if args.command == "cursor":
77
+ return _cmd_cursor(args.route, args.port)
65
78
 
66
79
  parser.print_help()
67
80
  return 1
@@ -81,7 +94,7 @@ def _build_parser() -> argparse.ArgumentParser:
81
94
  login.add_argument("--import", dest="do_import", action="store_true")
82
95
 
83
96
  use = subparsers.add_parser("use")
84
- use.add_argument("tool", choices=["codex", "claude"])
97
+ use.add_argument("tool", choices=["codex", "claude", "cursor"])
85
98
  use.add_argument("route", choices=["direct", "matrx"])
86
99
 
87
100
  subparsers.add_parser("help")
@@ -100,6 +113,10 @@ def _build_parser() -> argparse.ArgumentParser:
100
113
  claude = subparsers.add_parser("claude")
101
114
  claude.add_argument("--route", choices=["direct", "matrx"])
102
115
 
116
+ cursor = subparsers.add_parser("cursor")
117
+ cursor.add_argument("--route", choices=["direct", "matrx"])
118
+ cursor.add_argument("--port", type=int, default=0)
119
+
103
120
  return parser
104
121
 
105
122
 
@@ -123,6 +140,73 @@ def _prompt_for_secret(prompt: str) -> str:
123
140
  return input(f"{prompt}: ").strip()
124
141
 
125
142
 
143
+ def _legacy_shell_proxy_profiles() -> list[Path]:
144
+ markers = (
145
+ "# BEGIN MATRX PROXY (CODEX)",
146
+ 'claude() { matrx_refresh_routing; command claude "$@"; }',
147
+ )
148
+ profiles: list[Path] = []
149
+ for path in (
150
+ Path.home() / ".zshrc",
151
+ Path.home() / ".zprofile",
152
+ Path.home() / ".bashrc",
153
+ Path.home() / ".bash_profile",
154
+ ):
155
+ if not path.exists():
156
+ continue
157
+ try:
158
+ text = path.read_text(encoding="utf-8", errors="replace")
159
+ except OSError:
160
+ continue
161
+ if any(marker in text for marker in markers):
162
+ profiles.append(path)
163
+ return profiles
164
+
165
+
166
+ def _active_claude_proxy_env(env: dict[str, str] | None = None) -> dict[str, str]:
167
+ env = env or os.environ
168
+ active: dict[str, str] = {}
169
+
170
+ base_url = (env.get("ANTHROPIC_BASE_URL") or "").strip()
171
+ if base_url and "api.anthropic.com" not in base_url:
172
+ active["ANTHROPIC_BASE_URL"] = base_url
173
+
174
+ custom_headers = (env.get("ANTHROPIC_CUSTOM_HEADERS") or "").strip()
175
+ if "x-matrx-" in custom_headers.lower():
176
+ active["ANTHROPIC_CUSTOM_HEADERS"] = "x-matrx-*"
177
+
178
+ active_route = (env.get("MATRX_ACTIVE_ROUTE") or "").strip()
179
+ if active_route in {"proxy", "proxy_forced"}:
180
+ active["MATRX_ACTIVE_ROUTE"] = active_route
181
+
182
+ return active
183
+
184
+
185
+ def _print_claude_shell_proxy_hint(*, stream=None) -> None:
186
+ stream = stream or sys.stdout
187
+ profiles = _legacy_shell_proxy_profiles()
188
+ active_env = _active_claude_proxy_env()
189
+ if not profiles and not active_env:
190
+ return
191
+
192
+ if profiles:
193
+ joined = ", ".join(str(path) for path in profiles)
194
+ print(
195
+ f"[warn] Legacy shell MATRX proxy setup found in {joined}; plain `claude` will still be proxied outside `mtrx claude`.",
196
+ file=stream,
197
+ )
198
+ if active_env:
199
+ keys = ", ".join(sorted(active_env))
200
+ print(
201
+ f"[warn] Current shell still exports Claude proxy env ({keys}); plain `claude` in this shell will still be proxied.",
202
+ file=stream,
203
+ )
204
+ print(
205
+ "[warn] Cleanup: run `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`.",
206
+ file=stream,
207
+ )
208
+
209
+
126
210
  def _derive_callback_payload(params: dict[str, list[str]]) -> dict[str, str | None]:
127
211
  def _one(name: str) -> str | None:
128
212
  values = params.get(name) or []
@@ -140,7 +224,22 @@ def _derive_callback_payload(params: dict[str, list[str]]) -> dict[str, str | No
140
224
  }
141
225
 
142
226
 
143
- def _matrx_browser_callback_html(payload: dict[str, str | None]) -> bytes:
227
+ def _matrx_browser_redirect_url(app_url: str | None, payload: dict[str, str | None]) -> str | None:
228
+ base = (app_url or "").strip().rstrip("/")
229
+ if not base:
230
+ return None
231
+
232
+ project_id = (payload.get("project_id") or "").strip()
233
+ if project_id:
234
+ return f"{base}/dashboard/projects/{urllib.parse.quote(project_id, safe='')}"
235
+ return f"{base}/dashboard"
236
+
237
+
238
+ def _matrx_browser_callback_html(
239
+ payload: dict[str, str | None],
240
+ *,
241
+ app_url: str | None = None,
242
+ ) -> bytes:
144
243
  error = (payload.get("error") or "").strip()
145
244
  if error:
146
245
  escaped_error = html.escape(error)
@@ -152,11 +251,23 @@ def _matrx_browser_callback_html(payload: dict[str, str | None]) -> bytes:
152
251
  "</body></html>"
153
252
  )
154
253
  else:
155
- body = (
156
- "<html><body><h1>Matrx login complete.</h1>"
157
- "<p>You can close this window and return to the terminal.</p>"
158
- "</body></html>"
159
- )
254
+ redirect_url = _matrx_browser_redirect_url(app_url, payload)
255
+ if redirect_url:
256
+ escaped_url = html.escape(redirect_url, quote=True)
257
+ body = (
258
+ "<html><head>"
259
+ f'<meta http-equiv="refresh" content="0;url={escaped_url}">'
260
+ "</head><body><h1>Matrx login complete.</h1>"
261
+ "<p>Redirecting to the Matrx dashboard.</p>"
262
+ f'<p>If you are not redirected automatically, <a href="{escaped_url}">open the dashboard</a>.</p>'
263
+ "</body></html>"
264
+ )
265
+ else:
266
+ body = (
267
+ "<html><body><h1>Matrx login complete.</h1>"
268
+ "<p>You can close this window and return to the terminal.</p>"
269
+ "</body></html>"
270
+ )
160
271
  return body.encode("utf-8")
161
272
 
162
273
 
@@ -184,7 +295,7 @@ def _run_matrx_browser_login(
184
295
  payload = _derive_callback_payload(urllib.parse.parse_qs(parsed.query))
185
296
  result.update(payload)
186
297
  event.set()
187
- body = _matrx_browser_callback_html(payload)
298
+ body = _matrx_browser_callback_html(payload, app_url=app_url)
188
299
  self.send_response(200)
189
300
  self.send_header("Content-Type", "text/html; charset=utf-8")
190
301
  self.send_header("Content-Length", str(len(body)))
@@ -376,6 +487,8 @@ def _cmd_use(args) -> int:
376
487
  path = save_state(state)
377
488
  print(f"Default route for {args.tool}: {args.route}")
378
489
  print(f"Saved to {path}")
490
+ if args.tool == "claude" and args.route == "direct":
491
+ _print_claude_shell_proxy_hint()
379
492
  return 0
380
493
 
381
494
 
@@ -385,6 +498,7 @@ def _cmd_status() -> int:
385
498
  print("Defaults:")
386
499
  print(f" codex: {_default_route_label(configured_route(state, 'codex'))}")
387
500
  print(f" claude: {_default_route_label(configured_route(state, 'claude'))}")
501
+ print(f" cursor: {_default_route_label(configured_route(state, 'cursor'))}")
388
502
  print("Auth:")
389
503
  print(
390
504
  " matrx: "
@@ -415,6 +529,16 @@ def _cmd_status() -> int:
415
529
  print("Executables:")
416
530
  print(f" codex: {find_executable('codex') or 'not found'}")
417
531
  print(f" claude: {find_executable('claude') or 'not found'}")
532
+ profiles = _legacy_shell_proxy_profiles()
533
+ active_env = _active_claude_proxy_env()
534
+ if profiles or active_env:
535
+ print("Shell:")
536
+ if profiles:
537
+ joined = ", ".join(str(path) for path in profiles)
538
+ print(f" legacy claude proxy setup: present in {joined}")
539
+ if active_env:
540
+ keys = ", ".join(sorted(active_env))
541
+ print(f" active claude proxy env: {keys}")
418
542
  return 0
419
543
 
420
544
 
@@ -527,6 +651,17 @@ def _cmd_doctor() -> int:
527
651
  print(f"[fail] {tool} executable not found")
528
652
  failures += 1
529
653
 
654
+ db_path = cursor_state_db_path()
655
+ if db_path.exists():
656
+ print(f"[ok] Cursor settings DB: {db_path}")
657
+ cursor_settings = read_cursor_settings(db_path)
658
+ if cursor_settings:
659
+ print("[ok] Cursor settings readable")
660
+ else:
661
+ print("[warn] Cursor settings key not found (set a custom API key in Cursor first)")
662
+ else:
663
+ print(f"[warn] Cursor settings DB not found at {db_path}")
664
+
530
665
  matrx_key = (state["auth"]["matrx"].get("key") or "").strip()
531
666
  if matrx_key:
532
667
  print("[ok] Matrx key saved")
@@ -558,13 +693,30 @@ def _cmd_doctor() -> int:
558
693
  else:
559
694
  print(f"[warn] Local Claude OAuth not found at {claude_credentials_path()}")
560
695
 
561
- for tool in ("codex", "claude"):
696
+ profiles = _legacy_shell_proxy_profiles()
697
+ if profiles:
698
+ joined = ", ".join(str(path) for path in profiles)
699
+ print(
700
+ f"[warn] Legacy shell MATRX proxy setup found in {joined}; plain `claude` will still be proxied outside `mtrx claude`.",
701
+ )
702
+
703
+ active_env = _active_claude_proxy_env()
704
+ if active_env:
705
+ keys = ", ".join(sorted(active_env))
706
+ print(f"[warn] Current shell still exports Claude proxy env ({keys})")
707
+ print(
708
+ "[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`",
709
+ )
710
+
711
+ for tool in ("codex", "claude", "cursor"):
562
712
  route = configured_route(state, tool)
563
713
  if route == "matrx" and not _has_matrx_login(state, env=os.environ):
564
714
  print(f"[fail] Default {tool} route is matrx but no Matrx key is saved")
565
715
  failures += 1
566
716
  elif route is None:
567
717
  print(f"[warn] Default {tool} route not chosen yet (first `mtrx {tool}` launch will initialize it to matrx)")
718
+ if tool == "cursor":
719
+ continue
568
720
  config_status = get_tool_config_status(state, tool)
569
721
  if tool == "codex" and config_status["verified"] and not config_status["configured"]:
570
722
  if route == "matrx":
@@ -591,6 +743,8 @@ def _cmd_launch(tool: str, route: str | None, remainder: list[str]) -> int:
591
743
  if effective_route == "matrx":
592
744
  state, login_changed = _complete_matrx_login(state)
593
745
  auth_changed = auth_changed or login_changed
746
+ if login_changed:
747
+ save_state(state)
594
748
  if tool == "codex":
595
749
  _complete_codex_login()
596
750
  if tool == "claude":
@@ -626,16 +780,128 @@ def _cmd_launch(tool: str, route: str | None, remainder: list[str]) -> int:
626
780
  return launch(plan)
627
781
 
628
782
 
783
+ def _cmd_cursor(route: str | None, port: int) -> int:
784
+ import signal as _signal
785
+
786
+ state = load_state()
787
+ route, promoted = _maybe_promote_direct_route(state, "cursor", route)
788
+ effective_route = resolve_route(state, "cursor", route)
789
+
790
+ if effective_route == "direct":
791
+ print("Cursor route set to direct — MTRX proxy disabled.")
792
+ return 0
793
+
794
+ try:
795
+ state, login_changed = _complete_matrx_login(state)
796
+ except ValueError as exc:
797
+ print(str(exc), file=sys.stderr)
798
+ return 1
799
+
800
+ initialized = initialize_first_launch_route(state, "cursor", route)
801
+
802
+ mx_key = (
803
+ normalize_matrx_key(os.environ.get("MTRX_KEY"))
804
+ or normalize_matrx_key(
805
+ (get_workspace_binding(state, cwd=os.getcwd()) or {}).get("matrx_key")
806
+ )
807
+ or normalize_matrx_key(state.get("auth", {}).get("matrx", {}).get("key"))
808
+ )
809
+ if not mx_key:
810
+ print("No Matrx key available. Run: mtrx login matrx --key mx_...", file=sys.stderr)
811
+ return 1
812
+
813
+ matrx_base_url = ensure_root_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
814
+
815
+ binding = get_workspace_binding(state, cwd=os.getcwd()) or {}
816
+ group_id = (os.environ.get("MTRX_GROUP_ID") or binding.get("group_id") or "").strip()
817
+ project_id = (os.environ.get("MTRX_PROJECT_ID") or binding.get("project_id") or "").strip()
818
+
819
+ if initialized or login_changed or promoted:
820
+ save_state(state)
821
+ if initialized:
822
+ print(
823
+ "First-time setup: default route for cursor set to matrx. "
824
+ "Use `mtrx use cursor direct` to opt out.",
825
+ )
826
+
827
+ proxy = CursorProxyServer(
828
+ matrx_key=mx_key,
829
+ matrx_base_url=matrx_base_url,
830
+ group_id=group_id,
831
+ project_id=project_id,
832
+ port=port,
833
+ )
834
+ try:
835
+ proxy.start_background()
836
+ except RuntimeError as exc:
837
+ print(f"Failed to start proxy: {exc}", file=sys.stderr)
838
+ return 1
839
+
840
+ proxy_url = proxy.url
841
+ print(f"MTRX Cursor proxy running at {proxy_url}")
842
+ print(f" session: {proxy.session_id}")
843
+ print(f" matrx_key: {mask_secret(mx_key)}")
844
+ print(f" upstream: {matrx_base_url}")
845
+ if group_id:
846
+ print(f" group: {group_id}")
847
+ if project_id:
848
+ print(f" project: {project_id}")
849
+
850
+ previous_settings = None
851
+ db_path = cursor_state_db_path()
852
+ if db_path.exists():
853
+ if cursor_is_running():
854
+ print()
855
+ print(" [warn] Cursor is currently running.")
856
+ print(" Settings changes may require restarting Cursor to take effect.")
857
+ previous_settings = configure_cursor_for_proxy(proxy_url, db_path=db_path)
858
+ if previous_settings is not None:
859
+ print()
860
+ print(" Cursor settings configured automatically.")
861
+ print(" Restart Cursor if it is already open.")
862
+ else:
863
+ print_manual_setup_instructions(proxy_url)
864
+ else:
865
+ print_manual_setup_instructions(proxy_url)
866
+
867
+ print()
868
+ print("Press Ctrl+C to stop the proxy and restore settings.")
869
+
870
+ stop_event = threading.Event()
871
+
872
+ def _on_signal(sig, frame):
873
+ stop_event.set()
874
+
875
+ _signal.signal(_signal.SIGINT, _on_signal)
876
+ _signal.signal(_signal.SIGTERM, _on_signal)
877
+
878
+ stop_event.wait()
879
+
880
+ print()
881
+ print("Shutting down MTRX Cursor proxy...")
882
+ proxy.stop()
883
+
884
+ if previous_settings is not None:
885
+ if restore_cursor_settings(previous_settings, db_path=db_path):
886
+ print("Cursor settings restored to previous values.")
887
+ else:
888
+ print("[warn] Could not restore Cursor settings automatically.")
889
+ print(" You may need to reset your OpenAI API Key and Base URL in Cursor Settings > Models.")
890
+
891
+ print("Done.")
892
+ return 0
893
+
894
+
629
895
  def _has_matrx_login(state: dict, env: dict[str, str] | None = None) -> bool:
630
896
  env = env or {}
631
- env_key = (env.get("MTRX_KEY") or "").strip()
897
+ env_key = normalize_matrx_key(env.get("MTRX_KEY"))
632
898
  if env_key:
633
899
  return True
634
900
  workspace_binding = get_workspace_binding(state, cwd=env.get("PWD") or os.getcwd()) or {}
635
- workspace_key = (workspace_binding.get("matrx_key") or "").strip()
901
+ workspace_key = normalize_matrx_key(workspace_binding.get("matrx_key"))
636
902
  if workspace_key:
637
903
  return True
638
- return bool((state.get("auth", {}).get("matrx", {}).get("key") or "").strip())
904
+ return bool(normalize_matrx_key(state.get("auth", {}).get("matrx", {}).get("key")))
639
905
 
640
906
 
641
907
  def _default_route_label(route: str | None) -> str: