mtrx-cli 0.1.19 → 0.1.21

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.19",
3
+ "version": "0.1.21",
4
4
  "description": "MATRX CLI for routing Codex, Claude, and Cursor through Matrx",
5
5
  "homepage": "https://mtrx.so",
6
6
  "repository": {
@@ -1 +1 @@
1
- __version__ = "0.1.19"
1
+ __version__ = "0.1.21"
@@ -214,6 +214,11 @@ def prepare_routed_setup(
214
214
  if route != "matrx":
215
215
  return state, changed
216
216
 
217
+ if tool == "claude":
218
+ env = dict(os.environ if base_env is None else base_env)
219
+ if _sync_claude_subscription_to_matrx(state, env):
220
+ changed = True
221
+
217
222
  return state, changed
218
223
 
219
224
 
@@ -432,6 +437,32 @@ def _workspace_cwd(env: dict[str, str]) -> str:
432
437
  return (env.get("PWD") or os.getcwd()).strip()
433
438
 
434
439
 
440
+ def _capture_git_context(cwd: str | None = None) -> tuple[str, str]:
441
+ """Return (branch, commit_sha) for the current git repo, or ('', '') on failure."""
442
+ root = cwd or os.getcwd()
443
+ branch = ""
444
+ commit = ""
445
+ try:
446
+ r = subprocess.run(
447
+ ["git", "-C", root, "branch", "--show-current"],
448
+ capture_output=True, text=True, timeout=2, check=False,
449
+ )
450
+ if r.returncode == 0:
451
+ branch = r.stdout.strip()
452
+ except (OSError, subprocess.SubprocessError):
453
+ pass
454
+ try:
455
+ r = subprocess.run(
456
+ ["git", "-C", root, "rev-parse", "--short", "HEAD"],
457
+ capture_output=True, text=True, timeout=2, check=False,
458
+ )
459
+ if r.returncode == 0:
460
+ commit = r.stdout.strip()
461
+ except (OSError, subprocess.SubprocessError):
462
+ pass
463
+ return branch, commit
464
+
465
+
435
466
  def _resolve_matrx_context_overrides(
436
467
  state: dict,
437
468
  env: dict[str, str],
@@ -506,6 +537,11 @@ def _build_codex_env(
506
537
  header_parts.append(f'"X-Matrx-Group" = "{group_id}"')
507
538
  if project_id:
508
539
  header_parts.append(f'"X-Matrx-Project-Id" = "{project_id}"')
540
+ _git_branch, _git_commit = _capture_git_context(_workspace_cwd(env))
541
+ if _git_branch:
542
+ header_parts.append(f'"X-Matrx-Branch" = "{_git_branch}"')
543
+ if _git_commit:
544
+ header_parts.append(f'"X-Matrx-Commit" = "{_git_commit}"')
509
545
  if env_b64:
510
546
  header_parts.append(f'"X-Matrx-Env" = "{env_b64}"')
511
547
  headers_str = ", ".join(header_parts)
@@ -555,19 +591,36 @@ def _build_gemini_env(
555
591
  if route == "matrx":
556
592
  if not mx_key:
557
593
  raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
558
-
594
+
559
595
  # Clear existing Gemini config to force proxy usage
560
596
  env.pop("MTRX_KEY", None)
561
-
597
+
598
+ group_id, project_id = _resolve_matrx_context_overrides(state, env)
599
+ session_id = str(uuid.uuid4())
600
+ runtime_agent_id = (
601
+ (orchestration or {}).get("agent_id")
602
+ or _runtime_agent_basename("gemini")[0]
603
+ )
604
+
605
+ # Build a project-aware proxy base URL so the proxy can identify the project
606
+ # even though Gemini CLI doesn't support custom request headers via env vars.
607
+ # We append matrx context as query params which the proxy reads.
608
+ proxy_base_with_ctx = proxy_base
609
+ ctx_params: list[str] = []
610
+ if project_id:
611
+ ctx_params.append(f"mtrx_project={project_id}")
612
+ if session_id:
613
+ ctx_params.append(f"mtrx_session={session_id}")
614
+ if runtime_agent_id:
615
+ ctx_params.append(f"mtrx_agent={runtime_agent_id}")
616
+ if ctx_params:
617
+ proxy_base_with_ctx = f"{proxy_base}?{'&'.join(ctx_params)}"
618
+
562
619
  # Set Proxy Config
563
- env["GOOGLE_GEMINI_BASE_URL"] = proxy_base
564
- env["GEMINI_API_ENDPOINT"] = proxy_base
620
+ env["GOOGLE_GEMINI_BASE_URL"] = proxy_base_with_ctx
621
+ env["GEMINI_API_ENDPOINT"] = proxy_base_with_ctx
565
622
  env["GOOGLE_API_KEY"] = mx_key
566
-
567
- # Matrx-specific headers (if supported by the tool, or for our own tracking)
568
- # Note: Standard Gemini CLI might not support custom headers via env vars easily.
569
- # We rely on the Base URL routing to Matrx proxy which handles the logic.
570
-
623
+
571
624
  return env, matrx_auth_source
572
625
 
573
626
  # Direct route: clear any matrx-managed env vars
@@ -632,6 +685,11 @@ def _build_claude_env(
632
685
  custom_headers += f"\nx-matrx-group: {group_id}"
633
686
  if project_id:
634
687
  custom_headers += f"\nx-matrx-project-id: {project_id}"
688
+ _git_branch, _git_commit = _capture_git_context(_workspace_cwd(env))
689
+ if _git_branch:
690
+ custom_headers += f"\nx-matrx-branch: {_git_branch}"
691
+ if _git_commit:
692
+ custom_headers += f"\nx-matrx-commit: {_git_commit}"
635
693
  if env_b64:
636
694
  custom_headers += f"\nx-matrx-env: {env_b64}"
637
695
  env["ANTHROPIC_CUSTOM_HEADERS"] = custom_headers
@@ -709,14 +767,14 @@ def _build_orchestration_metadata(
709
767
  }
710
768
 
711
769
 
712
- def _best_effort_register_cli_agent(orchestration: dict) -> None:
770
+ def _best_effort_register_cli_agent(orchestration: dict) -> bool:
713
771
  if not orchestration:
714
- return
772
+ return False
715
773
  base_url = (orchestration.get("base_url") or "").rstrip("/")
716
774
  matrx_key = (orchestration.get("matrx_key") or "").strip()
717
775
  agent_id = (orchestration.get("agent_id") or "").strip()
718
776
  if not base_url or not matrx_key or not agent_id:
719
- return
777
+ return False
720
778
 
721
779
  payload = {
722
780
  "agent_id": agent_id,
@@ -743,10 +801,11 @@ def _best_effort_register_cli_agent(orchestration: dict) -> None:
743
801
  json=payload,
744
802
  )
745
803
  if response.status_code in {404, 401, 403}:
746
- return
804
+ return False
747
805
  response.raise_for_status()
806
+ return True
748
807
  except httpx.HTTPError:
749
- return
808
+ return False
750
809
 
751
810
 
752
811
  def _heartbeat_cli_agent(orchestration: dict, stop_event) -> None:
@@ -773,6 +832,14 @@ def _heartbeat_cli_agent(orchestration: dict, stop_event) -> None:
773
832
  f"{base_url}/v1/orchestration/agents/{agent_id}/heartbeat",
774
833
  headers=headers,
775
834
  )
835
+ if response.status_code == 404:
836
+ if not _best_effort_register_cli_agent(orchestration):
837
+ return
838
+ with httpx.Client(timeout=5) as client:
839
+ response = client.patch(
840
+ f"{base_url}/v1/orchestration/agents/{agent_id}/heartbeat",
841
+ headers=headers,
842
+ )
776
843
  if response.status_code in {404, 401, 403}:
777
844
  return
778
845
  except httpx.HTTPError:
@@ -839,16 +906,18 @@ def _validate_gemini_launch_plan(plan: LaunchPlan, state: dict) -> None:
839
906
  return
840
907
 
841
908
  expected_base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
842
-
909
+
843
910
  base_url = (plan.env.get("GOOGLE_GEMINI_BASE_URL") or "").strip()
844
911
  if not base_url:
845
- # Try the other one
846
- base_url = (plan.env.get("GEMINI_API_ENDPOINT") or "").strip()
912
+ base_url = (plan.env.get("GEMINI_API_ENDPOINT") or "").strip()
847
913
 
848
914
  if not base_url:
849
915
  raise ValueError("Gemini Matrx route is missing GOOGLE_GEMINI_BASE_URL or GEMINI_API_ENDPOINT")
850
-
851
- if base_url != expected_base_url:
916
+
917
+ # Strip query params before comparing (project_id context may be appended as ?mtrx_project=...)
918
+ base_url_no_qs = base_url.split("?")[0].rstrip("/")
919
+ expected_no_qs = expected_base_url.rstrip("/")
920
+ if base_url_no_qs != expected_no_qs:
852
921
  raise ValueError(
853
922
  "Gemini Matrx route must use the Matrx /v1 base URL. "
854
923
  f"Got: {base_url}"
@@ -921,13 +990,19 @@ def describe_launch_plan(plan: LaunchPlan, state: dict) -> list[str]:
921
990
 
922
991
  if plan.tool == "gemini":
923
992
  base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
924
- return [
993
+ _, project_id = _resolve_matrx_context_overrides(state, dict(plan.env))
994
+ lines = [
925
995
  "Launching gemini via Matrx",
926
996
  f" base_url: {base_url}",
927
997
  f" auth_source: {plan.auth_source}",
928
998
  " runtime_route: env injection",
929
999
  " persistent_route: disabled",
930
1000
  ]
1001
+ if project_id:
1002
+ lines.append(f" project_id: {project_id}")
1003
+ else:
1004
+ lines.append(" project_id: (from API key scope)")
1005
+ return lines
931
1006
 
932
1007
  return []
933
1008
 
@@ -82,6 +82,8 @@ def main(argv: list[str] | None = None) -> int:
82
82
  from matrx.cli.bootstrap import run_init
83
83
  run_init()
84
84
  return 0
85
+ if args.command == "project":
86
+ return _cmd_project(args)
85
87
 
86
88
  parser.print_help()
87
89
  return 1
@@ -124,6 +126,17 @@ def _build_parser() -> argparse.ArgumentParser:
124
126
  gemini = subparsers.add_parser("gemini")
125
127
  gemini.add_argument("--route", choices=["direct", "matrx"])
126
128
 
129
+ project = subparsers.add_parser("project", help="Manage Matrx projects")
130
+ project_sub = project.add_subparsers(dest="project_command")
131
+ project_sub.add_parser("list", help="List projects in your org")
132
+ project_sub.add_parser("current", help="Show active project for this workspace")
133
+ project_switch = project_sub.add_parser("switch", help="Bind this workspace to a project")
134
+ project_switch.add_argument("name", help="Project name, slug, or ID")
135
+ project_create = project_sub.add_parser("create", help="Create a new project")
136
+ project_create.add_argument("name", help="Project name")
137
+ project_create.add_argument("--description", help="Optional description")
138
+ project_sub.add_parser("init", help="Link this git repo to a Matrx project")
139
+
127
140
  cursor = subparsers.add_parser("cursor")
128
141
  cursor.add_argument("--route", choices=["direct", "matrx"])
129
142
  cursor.add_argument("--status", action="store_true", help="Check proxy status")
@@ -453,10 +466,13 @@ def _cmd_login(args) -> int:
453
466
 
454
467
  key = (args.key or "").strip()
455
468
  if provider == "matrx":
469
+ url_changed = False
456
470
  if args.base_url:
457
471
  state["auth"]["matrx"]["base_url"] = args.base_url.strip()
472
+ url_changed = True
458
473
  if args.app_url:
459
474
  state["auth"]["matrx"]["app_url"] = args.app_url.strip()
475
+ url_changed = True
460
476
  elif not state["auth"]["matrx"].get("app_url"):
461
477
  state["auth"]["matrx"]["app_url"] = ensure_app_url(
462
478
  None,
@@ -471,13 +487,14 @@ def _cmd_login(args) -> int:
471
487
  except ValueError as exc:
472
488
  print(str(exc), file=sys.stderr)
473
489
  return 1
474
- if changed:
490
+ if changed or url_changed:
475
491
  path = save_state(state)
476
492
  print(f"Saved {args.provider} credentials to {path}")
477
493
  print(f"Matrx base URL: {ensure_v1_url(state['auth']['matrx']['base_url'])}")
478
494
  return 0
479
- print("Matrx login did not change the current state", file=sys.stderr)
480
- return 1
495
+ if not url_changed:
496
+ print("Matrx login did not change the current state", file=sys.stderr)
497
+ return 1
481
498
 
482
499
  if not key:
483
500
  print("--key is required for this login command", file=sys.stderr)
@@ -558,16 +575,32 @@ def _restore_cursor_if_needed() -> None:
558
575
  def _cmd_status() -> int:
559
576
  state = load_state()
560
577
  auth = state["auth"]
578
+
579
+ # Show active project for this workspace
580
+ workspace_binding = get_workspace_binding(state, cwd=os.environ.get("PWD") or os.getcwd()) or {}
581
+ active_project_id = (
582
+ (os.environ.get("MTRX_PROJECT_ID") or "").strip()
583
+ or (workspace_binding.get("project_id") or "").strip()
584
+ )
585
+ if active_project_id:
586
+ source = "env" if (os.environ.get("MTRX_PROJECT_ID") or "").strip() else "workspace"
587
+ print(f"Active project: {active_project_id} [{source}]")
588
+ else:
589
+ print("Active project: none (run: mtrx project switch <name>)")
590
+
561
591
  print("Defaults:")
562
- print(f" codex: {_default_route_label(configured_route(state, 'codex'))}")
563
- print(f" claude: {_default_route_label(configured_route(state, 'claude'))}")
564
- print(f" gemini: {_default_route_label(configured_route(state, 'gemini'))}")
565
- print(f" cursor: {_default_route_label(configured_route(state, 'cursor'))}")
592
+ for tool in ("codex", "claude", "gemini", "cursor"):
593
+ route = configured_route(state, tool) or "not set"
594
+ print(f" {tool}: {route}")
566
595
  print("Auth:")
596
+ config_base = auth["matrx"].get("base_url")
597
+ env_base = (os.environ.get("MATRX_BASE_URL") or "").strip()
598
+ effective_base = ensure_v1_url(env_base or config_base)
599
+ base_source = "MATRX_BASE_URL env" if env_base else "config"
567
600
  print(
568
601
  " matrx: "
569
602
  f"{mask_secret(auth['matrx'].get('key'))} "
570
- f"({ensure_v1_url(auth['matrx'].get('base_url'))})"
603
+ f"({effective_base}) [{base_source}]"
571
604
  )
572
605
  print(f" matrx app: {ensure_app_url(auth['matrx'].get('app_url'), base_url=auth['matrx'].get('base_url'))}")
573
606
  print(f" openai: {mask_secret(auth['openai'].get('key'))}")
@@ -595,6 +628,24 @@ def _cmd_status() -> int:
595
628
  proxy_running = is_proxy_running()
596
629
  print(f" cursor proxy: {'running' if proxy_running else 'not running'}")
597
630
 
631
+ # Matrx API proxy health (when base_url configured)
632
+ base_url = auth["matrx"].get("base_url")
633
+ if base_url:
634
+ try:
635
+ health_url = f"{ensure_root_url(base_url).rstrip('/')}/health/wiring"
636
+ with httpx.Client(timeout=5) as client:
637
+ resp = client.get(health_url)
638
+ if resp.status_code == 200:
639
+ data = resp.json()
640
+ wiring = "ok" if data.get("proxy_wiring_ok") else "incomplete"
641
+ kv = data.get("key_vault", {})
642
+ enc = "configured" if kv.get("encryption_configured") else "not configured"
643
+ print(f" matrx proxy: reachable (wiring={wiring}, key_vault={enc})")
644
+ else:
645
+ print(f" matrx proxy: HTTP {resp.status_code}")
646
+ except Exception as exc:
647
+ print(f" matrx proxy: unreachable ({exc})")
648
+
598
649
  print("Executables:")
599
650
  print(f" codex: {find_executable('codex') or 'not found'}")
600
651
  print(f" claude: {find_executable('claude') or 'not found'}")
@@ -800,6 +851,35 @@ def _cmd_doctor() -> int:
800
851
  else:
801
852
  print(f"[warn] {tool} native config not configured")
802
853
 
854
+ # Proxy health check — when using matrx route, verify server is reachable
855
+ base_url = state.get("auth", {}).get("matrx", {}).get("base_url")
856
+ mx_key = matrx_key or workspace_matrx_key or env_matrx_key
857
+ if base_url and mx_key:
858
+ try:
859
+ health_url = f"{ensure_root_url(base_url).rstrip('/')}/health/wiring"
860
+ with httpx.Client(timeout=10) as client:
861
+ resp = client.get(health_url)
862
+ if resp.status_code == 200:
863
+ data = resp.json()
864
+ if data.get("proxy_wiring_ok"):
865
+ print("[ok] Matrx proxy reachable and wired")
866
+ else:
867
+ comps = data.get("components", {})
868
+ missing = [k for k, v in comps.items() if not v]
869
+ print(f"[warn] Matrx proxy incomplete: missing {missing}")
870
+ kv = data.get("key_vault", {})
871
+ if not kv.get("encryption_configured"):
872
+ print(
873
+ "[warn] Key vault encryption not configured on server — "
874
+ "provider keys may fail to decrypt; set KEY_VAULT_ENCRYPTION_KEY"
875
+ )
876
+ else:
877
+ print(f"[warn] Matrx proxy health check failed: {resp.status_code}")
878
+ except httpx.HTTPError as exc:
879
+ print(f"[warn] Matrx proxy unreachable: {exc}")
880
+ except Exception as exc:
881
+ print(f"[warn] Matrx proxy check failed: {exc}")
882
+
803
883
  return 1 if failures else 0
804
884
 
805
885
 
@@ -976,5 +1056,29 @@ def _default_route_label(route: str | None) -> str:
976
1056
  return route or "unset"
977
1057
 
978
1058
 
1059
+ def _cmd_project(args) -> int:
1060
+ from matrx.cli.project_cmds import cmd_list, cmd_current, cmd_switch, cmd_create, cmd_init
1061
+
1062
+ sub = getattr(args, "project_command", None)
1063
+ if sub == "list":
1064
+ return cmd_list(args)
1065
+ if sub == "current":
1066
+ return cmd_current(args)
1067
+ if sub == "switch":
1068
+ return cmd_switch(args)
1069
+ if sub == "create":
1070
+ return cmd_create(args)
1071
+ if sub == "init":
1072
+ return cmd_init(args)
1073
+
1074
+ print("Usage: mtrx project <list|current|switch|create|init>", file=sys.stderr)
1075
+ print(" list List all projects in your org")
1076
+ print(" current Show active project for this workspace")
1077
+ print(" switch <name> Bind this workspace to a project")
1078
+ print(" create <name> Create a new project")
1079
+ print(" init Link this git repo to a Matrx project")
1080
+ return 1
1081
+
1082
+
979
1083
  if __name__ == "__main__":
980
1084
  raise SystemExit(main())