mtrx-cli 0.1.24 → 0.1.26

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.
@@ -84,6 +84,8 @@ def main(argv: list[str] | None = None) -> int:
84
84
  return 0
85
85
  if args.command == "project":
86
86
  return _cmd_project(args)
87
+ if args.command == "agents":
88
+ return _cmd_agents(args)
87
89
 
88
90
  parser.print_help()
89
91
  return 1
@@ -147,6 +149,53 @@ def _build_parser() -> argparse.ArgumentParser:
147
149
  help="Launch Cursor after applying the current Matrx settings",
148
150
  )
149
151
 
152
+ # ── mtrx agents ──────────────────────────────────────────────────────────
153
+ agents = subparsers.add_parser("agents", help="Real-time multi-agent coordination")
154
+ agents_sub = agents.add_subparsers(dest="agents_command")
155
+
156
+ agents_list = agents_sub.add_parser("list", help="Show agents active in the group")
157
+ agents_list.add_argument("--group", metavar="GROUP_ID", help="Group to query")
158
+ agents_list.add_argument(
159
+ "--history",
160
+ action="store_true",
161
+ help="Also include recent completed activities",
162
+ )
163
+ agents_list.add_argument(
164
+ "--limit",
165
+ type=int,
166
+ default=10,
167
+ help="Max history entries to return (default: 10)",
168
+ )
169
+
170
+ agents_push = agents_sub.add_parser(
171
+ "push", help="Push a task into another agent's inbox"
172
+ )
173
+ agents_push.add_argument("target", help="Target agent ID")
174
+ agents_push.add_argument(
175
+ "prompt_parts",
176
+ nargs="+",
177
+ metavar="WORD",
178
+ help="Task prompt (all remaining words)",
179
+ )
180
+ agents_push.add_argument("--group", metavar="GROUP_ID", help="Override group")
181
+ agents_push.add_argument(
182
+ "--from-agent",
183
+ dest="from_agent",
184
+ metavar="AGENT_ID",
185
+ help="Sender identity (defaults to 'cli')",
186
+ )
187
+
188
+ agents_watch = agents_sub.add_parser(
189
+ "watch", help="Stream live group events to the terminal"
190
+ )
191
+ agents_watch.add_argument("--group", metavar="GROUP_ID", help="Group to watch")
192
+
193
+ agents_status = agents_sub.add_parser(
194
+ "status", help="Show this workspace's group/agent binding"
195
+ )
196
+ agents_status.add_argument("--group", metavar="GROUP_ID", help="Override group")
197
+ agents_status.add_argument("--agent", metavar="AGENT_ID", help="Override agent")
198
+
150
199
  return parser
151
200
 
152
201
 
@@ -367,8 +416,115 @@ def _run_matrx_browser_login(
367
416
  return result
368
417
 
369
418
 
419
+ def _matrx_skip_remote_key_verify() -> bool:
420
+ return (os.environ.get("MTRX_SKIP_KEY_VERIFY") or "").strip().lower() in {
421
+ "1",
422
+ "true",
423
+ "yes",
424
+ }
425
+
426
+
427
+ def _verify_matrx_key_with_server(state: dict, key: str) -> str:
428
+ """
429
+ Ping the configured Matrx API with the key.
430
+
431
+ Returns:
432
+ "valid" — server accepted the key (HTTP 200).
433
+ "invalid" — 401/403 (revoked, wrong deployment, etc.).
434
+ "unreachable" — network error or unexpected status (caller may proceed with a warning).
435
+ """
436
+ if _matrx_skip_remote_key_verify():
437
+ return "valid"
438
+ if not key.startswith("mx_"):
439
+ return "valid"
440
+
441
+ base_url = ensure_root_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
442
+ url = f"{base_url.rstrip('/')}/v1/auth/context"
443
+ try:
444
+ with httpx.Client(timeout=12) as client:
445
+ resp = client.get(url, headers={"X-Matrx-Key": key})
446
+ except httpx.HTTPError as exc:
447
+ print(
448
+ f"[warn] Could not reach Matrx at {base_url} to verify your API key ({exc}). "
449
+ "Continuing; if requests fail with 403, run: mtrx login matrx --web",
450
+ file=sys.stderr,
451
+ )
452
+ return "unreachable"
453
+
454
+ if resp.status_code == 200:
455
+ return "valid"
456
+ if resp.status_code in (401, 403):
457
+ return "invalid"
458
+
459
+ print(
460
+ f"[warn] Matrx key check returned HTTP {resp.status_code}; continuing. "
461
+ "If tools fail, run: mtrx login matrx --web",
462
+ file=sys.stderr,
463
+ )
464
+ return "unreachable"
465
+
466
+
467
+ def _handle_rejected_matrx_key(state: dict, key: str) -> None:
468
+ """
469
+ React to a server-rejected key. Clears saved config when appropriate, or raises
470
+ with instructions when the key comes from MTRX_KEY / workspace binding.
471
+ """
472
+ auth_cfg = state.setdefault("auth", {}).setdefault("matrx", {})
473
+ env_key = normalize_matrx_key(os.environ.get("MTRX_KEY"))
474
+ workspace_binding = get_workspace_binding(
475
+ state, cwd=os.environ.get("PWD") or os.getcwd()
476
+ ) or {}
477
+ ws_key = normalize_matrx_key(workspace_binding.get("matrx_key"))
478
+ saved = normalize_matrx_key(auth_cfg.get("key"))
479
+
480
+ print(
481
+ "Matrx rejected this API key (wrong server, revoked, expired, or not in this deployment).",
482
+ file=sys.stderr,
483
+ )
484
+
485
+ if env_key == key:
486
+ raise ValueError(
487
+ "MTRX_KEY in your environment is not valid for this Matrx server. "
488
+ "Unset it or set a fresh key, then run:\n"
489
+ " mtrx login matrx --web\n"
490
+ " # or: mtrx login matrx --key mx_..."
491
+ )
492
+ if ws_key == key:
493
+ raise ValueError(
494
+ "The Matrx key stored for this workspace is invalid for the configured server. "
495
+ "Run:\n"
496
+ " mtrx project switch <name>\n"
497
+ "after signing in with: mtrx login matrx --web"
498
+ )
499
+ if saved == key:
500
+ auth_cfg["key"] = None
501
+ print(
502
+ "Cleared the saved Matrx key from ~/.config/mtrx (or MTRX_CONFIG_DIR).",
503
+ file=sys.stderr,
504
+ )
505
+ return
506
+
507
+ raise ValueError(
508
+ "Matrx API key was rejected. Refresh it with:\n"
509
+ " mtrx login matrx --web\n"
510
+ " # or: mtrx login matrx --key mx_..."
511
+ )
512
+
513
+
370
514
  def _complete_matrx_login(state: dict, *, force: bool = False) -> tuple[dict, bool]:
371
515
  auth_cfg = state.setdefault("auth", {}).setdefault("matrx", {})
516
+ if not force and _has_matrx_login(state, env=os.environ):
517
+ key = _resolved_matrx_key(state, os.environ)
518
+ if key.startswith("mx_"):
519
+ outcome = _verify_matrx_key_with_server(state, key)
520
+ if outcome == "invalid":
521
+ _handle_rejected_matrx_key(state, key)
522
+ elif outcome == "valid":
523
+ return state, False
524
+ # unreachable: already warned; treat as OK for offline / flaky networks
525
+ else:
526
+ return state, False
527
+
372
528
  if not force and _has_matrx_login(state, env=os.environ):
373
529
  return state, False
374
530
  if not _is_interactive_terminal():
@@ -428,14 +584,20 @@ def _maybe_promote_direct_route(state: dict, tool: str, route: str | None) -> tu
428
584
  return route, False
429
585
  if configured_route(state, tool) != "direct":
430
586
  return route, False
431
- if _has_matrx_login(state, env=os.environ):
587
+ if state.get("explicit_direct_route", {}).get(tool):
432
588
  return route, False
589
+ # Matrx credentials present: align saved default with `mtrx <tool>` (same as `mtrx use <tool> matrx`).
590
+ if _has_matrx_login(state, env=os.environ):
591
+ state.setdefault("defaults", {})[tool] = "matrx"
592
+ state.setdefault("explicit_direct_route", {})[tool] = False
593
+ return "matrx", True
433
594
  if not _is_interactive_terminal():
434
595
  return route, False
435
596
 
436
597
  print(f"`mtrx {tool}` is currently configured to use the direct route.")
437
598
  if _prompt_yes_no(f"Switch {tool} to the Matrx route and continue with Matrx sign-in?", default=True):
438
599
  state.setdefault("defaults", {})[tool] = "matrx"
600
+ state.setdefault("explicit_direct_route", {})[tool] = False
439
601
  return "matrx", True
440
602
  return route, False
441
603
 
@@ -514,10 +676,19 @@ def _cmd_login(args) -> int:
514
676
 
515
677
  def _cmd_use(args) -> int:
516
678
  state = load_state()
517
- if not _has_matrx_login(state, env=os.environ):
518
- print("Login required. Run: mtrx login matrx --key mx_...", file=sys.stderr)
679
+ try:
680
+ state, login_changed = _complete_matrx_login(state)
681
+ except ValueError as exc:
682
+ print(str(exc), file=sys.stderr)
519
683
  return 1
684
+ if login_changed:
685
+ save_state(state)
520
686
  state["defaults"][args.tool] = args.route
687
+ explicit = state.setdefault("explicit_direct_route", {})
688
+ if args.route == "direct":
689
+ explicit[args.tool] = True
690
+ else:
691
+ explicit[args.tool] = False
521
692
  path = save_state(state)
522
693
  print(f"Default route for {args.tool}: {args.route}")
523
694
  print(f"Saved to {path}")
@@ -716,9 +887,16 @@ def _resolve_personal_policy_target(state: dict, *, key: str) -> tuple[dict, dic
716
887
 
717
888
  def _cmd_personal_optimize(args) -> int:
718
889
  state = load_state()
890
+ try:
891
+ state, login_changed = _complete_matrx_login(state)
892
+ except ValueError as exc:
893
+ print(str(exc), file=sys.stderr)
894
+ return 1
895
+ if login_changed:
896
+ save_state(state)
719
897
  key = _personal_matrx_key(state)
720
898
  if not key:
721
- print("Personal Matrx login required. Run: mtrx login matrx", file=sys.stderr)
899
+ print("No Matrx key available. Run: mtrx login matrx --key mx_...", file=sys.stderr)
722
900
  return 1
723
901
 
724
902
  try:
@@ -909,6 +1087,11 @@ def _cmd_launch(tool: str, route: str | None, remainder: list[str]) -> int:
909
1087
  )
910
1088
  if initialized or changed or auth_changed or promoted:
911
1089
  save_state(state)
1090
+ if promoted:
1091
+ print(
1092
+ f"Default route for {tool} was direct — updated to matrx. "
1093
+ f"Use `mtrx use {tool} direct` if you want to stay on the direct route.",
1094
+ )
912
1095
  if initialized:
913
1096
  print(
914
1097
  f"First-time setup: default route for {tool} set to matrx. "
@@ -931,33 +1114,60 @@ def _cmd_launch(tool: str, route: str | None, remainder: list[str]) -> int:
931
1114
 
932
1115
 
933
1116
  def _cmd_cursor(args) -> int:
1117
+ import json as _json
1118
+ import subprocess
1119
+ import time
1120
+
1121
+ from matrx.cli.cursor_ca import (
1122
+ ca_cert_path,
1123
+ ca_exists,
1124
+ generate_ca,
1125
+ is_ca_trusted,
1126
+ trust_ca_system,
1127
+ windows_trust_status,
1128
+ )
934
1129
  from matrx.cli.cursor_hooks import install_mtrx_hooks, is_mtrx_hooks_installed
935
- from matrx.cli.cursor_service import is_proxy_running
936
- from matrx.cli.cursor_launcher import find_cursor_executable
1130
+ from matrx.cli.cursor_launcher import find_cursor_executable, launch_cursor_with_proxy
1131
+ from matrx.cli.cursor_proxy import DEFAULT_PORT, PROXY_HOST
1132
+ from matrx.cli.cursor_service import get_proxy_status, install_service, is_proxy_running, uninstall_service
937
1133
 
938
1134
  route = args.route
939
1135
 
940
- # --status: report Base URL override + hooks status
1136
+ # --status
941
1137
  if args.status:
942
- state = load_state()
1138
+ proxy_prev_path = config_dir() / "cursor-proxy-previous-settings.json"
943
1139
  hooks_installed = is_mtrx_hooks_installed()
944
- base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
945
- prev_path = config_dir() / "cursor-previous-settings.json"
946
- legacy_proxy_prev_path = config_dir() / "cursor-proxy-previous-settings.json"
947
- configured = prev_path.exists()
948
- legacy_proxy_active = is_proxy_running() or legacy_proxy_prev_path.exists()
1140
+ proxy_running = is_proxy_running()
1141
+ trust_status = windows_trust_status() if os.name == "nt" and ca_exists() else None
949
1142
  print("MTRX Cursor integration:")
950
- print(f" mode: {'Base URL override (all models)' if configured else 'not configured'}")
1143
+ print(f" mode: {'MITM proxy (all models)' if proxy_prev_path.exists() else 'not configured'}")
1144
+ if proxy_running:
1145
+ status = get_proxy_status()
1146
+ req_count = status.get("requests", "?") if status else "?"
1147
+ print(f" proxy: running ({PROXY_HOST}:{DEFAULT_PORT}, {req_count} requests)")
1148
+ else:
1149
+ print(f" proxy: not running")
1150
+ print(f" ca cert: {'generated' if ca_exists() else 'not generated'} — {ca_cert_path()}")
1151
+ if trust_status is not None:
1152
+ print(
1153
+ " trust: "
1154
+ f"user_root={'yes' if trust_status['user_root'] else 'no'}, "
1155
+ f"machine_root={'yes' if trust_status['machine_root'] else 'no'}, "
1156
+ f"fully_trusted={'yes' if trust_status['fully_trusted'] else 'no'}"
1157
+ )
1158
+ elif ca_exists():
1159
+ print(f" trust: {'trusted' if is_ca_trusted() else 'not trusted'}")
1160
+ print(f" proxy settings: {'written' if proxy_prev_path.exists() else 'not written'}")
1161
+ print(" launch env: `mtrx cursor --launch` sets NODE_EXTRA_CA_CERTS for the launched Cursor process")
951
1162
  print(f" hooks: {'active (sessionEnd, stop → telemetry)' if hooks_installed else 'not installed'}")
952
- print(f" legacy MITM proxy: {'active' if legacy_proxy_active else 'not active'}")
953
- if configured:
954
- print(f" matrx: {base_url}")
955
1163
  return 0
956
1164
 
957
- # --stop: tear down
1165
+ # --stop
958
1166
  if args.stop:
959
1167
  _restore_cursor_if_needed()
960
- print("Cursor route set to direct — MTRX disabled.")
1168
+ print("MTRX Cursor proxy stopped.")
1169
+ if cursor_is_running():
1170
+ print(" Restart Cursor for settings to take effect.")
961
1171
  return 0
962
1172
 
963
1173
  state = load_state()
@@ -971,7 +1181,7 @@ def _cmd_cursor(args) -> int:
971
1181
  print(" Restart Cursor for settings to take effect.")
972
1182
  return 0
973
1183
 
974
- # --- matrx route: Base URL override (works with any Cursor model: Claude, GPT, Gemini, etc.) ---
1184
+ # --- matrx route: MITM proxy (intercepts all Cursor traffic, any model) ---
975
1185
 
976
1186
  try:
977
1187
  state, login_changed = _complete_matrx_login(state)
@@ -995,70 +1205,135 @@ def _cmd_cursor(args) -> int:
995
1205
  matrx_base_url = ensure_root_url(
996
1206
  state.get("auth", {}).get("matrx", {}).get("base_url")
997
1207
  )
998
- matrx_proxy_url = ensure_v1_url(matrx_base_url)
999
1208
 
1000
1209
  if initialized or login_changed or promoted:
1001
1210
  save_state(state)
1211
+ if promoted:
1212
+ print(
1213
+ "Default route for cursor was direct — updated to matrx. "
1214
+ "Use `mtrx use cursor direct` if you want to stay on the direct route.",
1215
+ )
1002
1216
  if initialized:
1003
1217
  print(
1004
1218
  "First-time setup: default route for cursor set to matrx. "
1005
1219
  "Use `mtrx use cursor direct` to opt out.",
1006
1220
  )
1007
1221
 
1008
- # Ensure legacy MITM routing is fully torn down before enabling the
1009
- # current Cursor base-URL override flow. Leaving both active causes
1010
- # Cursor traffic to keep flowing through the old telemetry proxy.
1011
- _restore_cursor_if_needed()
1222
+ # 1. Generate CA cert if not present
1223
+ if not ca_exists():
1224
+ generate_ca()
1225
+ print("MTRX CA certificate generated.")
1226
+ cert_path = ca_cert_path()
1227
+
1228
+ # 2. Attempt system-wide CA trust (requires elevation on Windows/Mac)
1229
+ trusted_before = is_ca_trusted()
1230
+ trust_ok = trusted_before or trust_ca_system(cert_path)
1231
+ trusted_after = trusted_before or is_ca_trusted()
1232
+ if os.name == "nt":
1233
+ trust_status = windows_trust_status(cert_path)
1234
+ print(
1235
+ "Windows trust status:"
1236
+ f" user_root={'yes' if trust_status['user_root'] else 'no'},"
1237
+ f" machine_root={'yes' if trust_status['machine_root'] else 'no'}"
1238
+ )
1239
+ if not trust_status["user_root"]:
1240
+ print("[warn] CurrentUser Root store is missing the MTRX CA.")
1241
+ print(f" Run without elevation: certutil -user -addstore Root \"{cert_path}\"")
1242
+ if not trust_status["machine_root"]:
1243
+ print("[warn] LocalMachine Root store is missing the MTRX CA.")
1244
+ print(f" Run as Admin: certutil -addstore Root \"{cert_path}\"")
1245
+ if not (trust_ok or trusted_after or trust_status["user_root"]):
1246
+ print("[warn] The MTRX CA is not trusted by a Windows root store yet.")
1247
+ print(" Cursor's native network stack may still reject intercepted TLS connections.")
1248
+ elif trust_status["user_root"] and not trust_status["machine_root"]:
1249
+ print("[warn] Current-user trust is present, but machine-wide trust is still missing.")
1250
+ print(" Some Cursor native network paths may still reject intercepted TLS connections.")
1251
+ print(" `mtrx cursor --launch` sets NODE_EXTRA_CA_CERTS and disables QUIC for the launched Cursor process.")
1252
+ elif not trust_ok and not trusted_after:
1253
+ print("[warn] The MTRX CA is not trusted by the OS certificate store yet.")
1254
+ print(" Cursor's native network stack may still reject intercepted TLS connections.")
1255
+ print(" On Mac:")
1256
+ print(
1257
+ f" sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain \"{cert_path}\""
1258
+ )
1259
+ print(" `mtrx cursor --launch` still sets NODE_EXTRA_CA_CERTS for Node-based requests only.")
1012
1260
 
1013
- # Configure Cursor's Override Base URL — sends chat to MTRX (any model: Claude, GPT-5, Gemini, etc.)
1014
- prev_path = config_dir() / "cursor-previous-settings.json"
1015
- previous = configure_cursor_for_proxy(matrx_proxy_url, mx_key)
1016
- if previous is not None:
1017
- prev_path.write_text(__import__("json").dumps(previous), encoding="utf-8")
1261
+ # 3. Start proxy daemon
1262
+ if is_proxy_running():
1263
+ print(f"MTRX proxy already running on {PROXY_HOST}:{DEFAULT_PORT}.")
1018
1264
  else:
1019
- print(
1020
- "[warn] Could not write Cursor state.vscdb. Try manual setup:",
1021
- file=sys.stderr,
1265
+ print("Starting MTRX proxy...", end=" ", flush=True)
1266
+ ok = install_service(
1267
+ matrx_key=mx_key,
1268
+ matrx_base_url=matrx_base_url,
1022
1269
  )
1023
- print_manual_setup_instructions(matrx_proxy_url, api_key_hint="your Matrx key (mx_...)")
1270
+ if not ok:
1271
+ # Poll a bit longer before giving up
1272
+ for _ in range(10):
1273
+ time.sleep(0.5)
1274
+ if is_proxy_running():
1275
+ ok = True
1276
+ break
1277
+ if ok:
1278
+ print("done.")
1279
+ else:
1280
+ print("failed.")
1281
+ print("[error] Proxy did not start. Check logs:", file=sys.stderr)
1282
+ print(f" {config_dir() / 'logs' / 'cursor-proxy.err.log'}", file=sys.stderr)
1283
+ return 1
1024
1284
 
1025
- # Hooks for session telemetry
1285
+ # 4. Write http.proxy + NODE_EXTRA_CA_CERTS to Cursor's settings.json (idempotent)
1286
+ proxy_prev_path = config_dir() / "cursor-proxy-previous-settings.json"
1287
+ if not proxy_prev_path.exists():
1288
+ previous = configure_cursor_proxy_settings(
1289
+ proxy_url=f"http://{PROXY_HOST}:{DEFAULT_PORT}",
1290
+ ca_cert_path=str(cert_path),
1291
+ )
1292
+ proxy_prev_path.write_text(_json.dumps(previous), encoding="utf-8")
1293
+
1294
+ # 5. Install hooks for session telemetry
1026
1295
  install_mtrx_hooks(mx_key, matrx_base_url)
1027
1296
 
1028
- # Optional: launch Cursor
1297
+ # 6. Optional: launch Cursor with NODE_EXTRA_CA_CERTS set on the process
1029
1298
  if getattr(args, "launch", False):
1030
- exe = find_cursor_executable()
1031
- if exe:
1032
- import subprocess
1033
- try:
1034
- subprocess.Popen([exe], start_new_session=True)
1035
- print("Launched Cursor.")
1036
- except Exception:
1037
- print("[warn] Could not launch Cursor.", file=sys.stderr)
1299
+ launched = launch_cursor_with_proxy(
1300
+ proxy_url=f"http://{PROXY_HOST}:{DEFAULT_PORT}",
1301
+ ca_cert_path=str(cert_path),
1302
+ )
1303
+ if launched:
1304
+ print("Launched Cursor with MTRX proxy environment.")
1038
1305
  else:
1039
- print("[warn] Could not find Cursor executable.", file=sys.stderr)
1306
+ print("[warn] Could not find Cursor executable. Open Cursor manually.", file=sys.stderr)
1040
1307
 
1041
1308
  print()
1042
- print("Cursor configured for MTRX — chat routes through Matrx (all models).")
1043
- print(f" base URL: {matrx_proxy_url}")
1309
+ print("Cursor configured for MTRX — all traffic routed through MITM proxy.")
1310
+ print(f" proxy: {PROXY_HOST}:{DEFAULT_PORT}")
1311
+ print(f" ca cert: {cert_path}")
1044
1312
  print()
1045
- print(" Works with any Cursor Pro model: Claude, GPT-5, Gemini, and more.")
1046
- print(" Restart Cursor for settings to take effect.")
1313
+ print(" Works with all Cursor Pro models: Claude, GPT, Gemini, cursor-fast, and more.")
1314
+ if not getattr(args, "launch", False):
1315
+ print(" Restart Cursor for settings to take effect.")
1316
+ print(" Tip: use `mtrx cursor --launch` to open Cursor with the proxy pre-wired.")
1047
1317
  print(" Check status: mtrx cursor --status")
1048
- print(" To disable: mtrx use cursor direct")
1318
+ print(" To disable: mtrx cursor --stop")
1049
1319
  return 0
1050
1320
 
1051
1321
 
1052
- def _has_matrx_login(state: dict, env: dict[str, str] | None = None) -> bool:
1322
+ def _resolved_matrx_key(state: dict, env: dict[str, str] | None = None) -> str:
1323
+ """Matrx API key from MTRX_KEY, workspace binding, or saved login (first match)."""
1053
1324
  env = env or {}
1054
1325
  env_key = normalize_matrx_key(env.get("MTRX_KEY"))
1055
1326
  if env_key:
1056
- return True
1327
+ return env_key
1057
1328
  workspace_binding = get_workspace_binding(state, cwd=env.get("PWD") or os.getcwd()) or {}
1058
1329
  workspace_key = normalize_matrx_key(workspace_binding.get("matrx_key"))
1059
1330
  if workspace_key:
1060
- return True
1061
- return bool(normalize_matrx_key(state.get("auth", {}).get("matrx", {}).get("key")))
1331
+ return workspace_key
1332
+ return normalize_matrx_key(state.get("auth", {}).get("matrx", {}).get("key")) or ""
1333
+
1334
+
1335
+ def _has_matrx_login(state: dict, env: dict[str, str] | None = None) -> bool:
1336
+ return bool(_resolved_matrx_key(state, env))
1062
1337
 
1063
1338
 
1064
1339
  def _default_route_label(route: str | None) -> str:
@@ -1069,6 +1344,27 @@ def _cmd_project(args) -> int:
1069
1344
  from matrx.cli.project_cmds import cmd_list, cmd_current, cmd_switch, cmd_create, cmd_init
1070
1345
 
1071
1346
  sub = getattr(args, "project_command", None)
1347
+ if sub is None:
1348
+ print("Usage: mtrx project <list|current|switch|create|init>", file=sys.stderr)
1349
+ print(" list List all projects in your org")
1350
+ print(" current Show active project for this workspace")
1351
+ print(" switch <name> Bind this workspace to a project")
1352
+ print(" create <name> Create a new project")
1353
+ print(" init Link this git repo to a Matrx project")
1354
+ return 1
1355
+
1356
+ # Ensure Matrx auth before any project subcommand; trigger interactive
1357
+ # login if needed so the user isn't forced to run `mtrx login` separately.
1358
+ state = load_state()
1359
+ try:
1360
+ state, login_changed = _complete_matrx_login(state)
1361
+ except ValueError as exc:
1362
+ print(str(exc), file=sys.stderr)
1363
+ return 1
1364
+ if login_changed:
1365
+ # Persist immediately so sub-commands that call load_state() find the key.
1366
+ save_state(state)
1367
+
1072
1368
  if sub == "list":
1073
1369
  return cmd_list(args)
1074
1370
  if sub == "current":
@@ -1081,11 +1377,40 @@ def _cmd_project(args) -> int:
1081
1377
  return cmd_init(args)
1082
1378
 
1083
1379
  print("Usage: mtrx project <list|current|switch|create|init>", file=sys.stderr)
1084
- print(" list List all projects in your org")
1085
- print(" current Show active project for this workspace")
1086
- print(" switch <name> Bind this workspace to a project")
1087
- print(" create <name> Create a new project")
1088
- print(" init Link this git repo to a Matrx project")
1380
+ return 1
1381
+
1382
+
1383
+ def _cmd_agents(args) -> int:
1384
+ from matrx.cli.agent_cmds import cmd_list, cmd_push, cmd_watch, cmd_status
1385
+
1386
+ sub = getattr(args, "agents_command", None)
1387
+ if sub is None:
1388
+ print("Usage: mtrx agents <list|push|watch|status>", file=sys.stderr)
1389
+ print(" list [--group ID] [--history] Show agents active in the group")
1390
+ print(" push <target> <prompt> [--group ID] Push a task into an agent's inbox")
1391
+ print(" watch [--group ID] Stream live group events")
1392
+ print(" status [--group ID] Show workspace group/agent identity")
1393
+ return 1
1394
+
1395
+ state = load_state()
1396
+ try:
1397
+ state, login_changed = _complete_matrx_login(state)
1398
+ except ValueError as exc:
1399
+ print(str(exc), file=sys.stderr)
1400
+ return 1
1401
+ if login_changed:
1402
+ save_state(state)
1403
+
1404
+ if sub == "list":
1405
+ return cmd_list(args)
1406
+ if sub == "push":
1407
+ return cmd_push(args)
1408
+ if sub == "watch":
1409
+ return cmd_watch(args)
1410
+ if sub == "status":
1411
+ return cmd_status(args)
1412
+
1413
+ print("Usage: mtrx agents <list|push|watch|status>", file=sys.stderr)
1089
1414
  return 1
1090
1415
 
1091
1416
 
@@ -49,6 +49,13 @@ DEFAULT_STATE: dict = {
49
49
  "gemini": None,
50
50
  "cursor": None,
51
51
  },
52
+ # True = user ran `mtrx use <tool> direct` and we should not auto-switch to matrx.
53
+ "explicit_direct_route": {
54
+ "codex": False,
55
+ "claude": False,
56
+ "gemini": False,
57
+ "cursor": False,
58
+ },
52
59
  "workspaces": {
53
60
  "bindings": {},
54
61
  },
@@ -292,6 +299,10 @@ def _merge_dicts(target: dict, source: dict) -> None:
292
299
 
293
300
 
294
301
  def _normalize_state(state: dict) -> None:
302
+ explicit = state.setdefault("explicit_direct_route", {})
303
+ for tool in ("codex", "claude", "gemini", "cursor"):
304
+ explicit.setdefault(tool, False)
305
+
295
306
  auth = state.setdefault("auth", {}).setdefault("matrx", {})
296
307
  auth["key"] = normalize_matrx_key(auth.get("key")) or None
297
308
  auth["base_url"] = _normalize_base_url(auth.get("base_url"))