mtrx-cli 0.1.19 → 0.1.20

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.20",
4
4
  "description": "MATRX CLI for routing Codex, Claude, and Cursor through Matrx",
5
5
  "homepage": "https://mtrx.so",
6
6
  "repository": {
@@ -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],
@@ -555,19 +586,36 @@ def _build_gemini_env(
555
586
  if route == "matrx":
556
587
  if not mx_key:
557
588
  raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
558
-
589
+
559
590
  # Clear existing Gemini config to force proxy usage
560
591
  env.pop("MTRX_KEY", None)
561
-
592
+
593
+ group_id, project_id = _resolve_matrx_context_overrides(state, env)
594
+ session_id = str(uuid.uuid4())
595
+ runtime_agent_id = (
596
+ (orchestration or {}).get("agent_id")
597
+ or _runtime_agent_basename("gemini")[0]
598
+ )
599
+
600
+ # Build a project-aware proxy base URL so the proxy can identify the project
601
+ # even though Gemini CLI doesn't support custom request headers via env vars.
602
+ # We append matrx context as query params which the proxy reads.
603
+ proxy_base_with_ctx = proxy_base
604
+ ctx_params: list[str] = []
605
+ if project_id:
606
+ ctx_params.append(f"mtrx_project={project_id}")
607
+ if session_id:
608
+ ctx_params.append(f"mtrx_session={session_id}")
609
+ if runtime_agent_id:
610
+ ctx_params.append(f"mtrx_agent={runtime_agent_id}")
611
+ if ctx_params:
612
+ proxy_base_with_ctx = f"{proxy_base}?{'&'.join(ctx_params)}"
613
+
562
614
  # Set Proxy Config
563
- env["GOOGLE_GEMINI_BASE_URL"] = proxy_base
564
- env["GEMINI_API_ENDPOINT"] = proxy_base
615
+ env["GOOGLE_GEMINI_BASE_URL"] = proxy_base_with_ctx
616
+ env["GEMINI_API_ENDPOINT"] = proxy_base_with_ctx
565
617
  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
-
618
+
571
619
  return env, matrx_auth_source
572
620
 
573
621
  # Direct route: clear any matrx-managed env vars
@@ -709,14 +757,14 @@ def _build_orchestration_metadata(
709
757
  }
710
758
 
711
759
 
712
- def _best_effort_register_cli_agent(orchestration: dict) -> None:
760
+ def _best_effort_register_cli_agent(orchestration: dict) -> bool:
713
761
  if not orchestration:
714
- return
762
+ return False
715
763
  base_url = (orchestration.get("base_url") or "").rstrip("/")
716
764
  matrx_key = (orchestration.get("matrx_key") or "").strip()
717
765
  agent_id = (orchestration.get("agent_id") or "").strip()
718
766
  if not base_url or not matrx_key or not agent_id:
719
- return
767
+ return False
720
768
 
721
769
  payload = {
722
770
  "agent_id": agent_id,
@@ -743,10 +791,11 @@ def _best_effort_register_cli_agent(orchestration: dict) -> None:
743
791
  json=payload,
744
792
  )
745
793
  if response.status_code in {404, 401, 403}:
746
- return
794
+ return False
747
795
  response.raise_for_status()
796
+ return True
748
797
  except httpx.HTTPError:
749
- return
798
+ return False
750
799
 
751
800
 
752
801
  def _heartbeat_cli_agent(orchestration: dict, stop_event) -> None:
@@ -773,6 +822,14 @@ def _heartbeat_cli_agent(orchestration: dict, stop_event) -> None:
773
822
  f"{base_url}/v1/orchestration/agents/{agent_id}/heartbeat",
774
823
  headers=headers,
775
824
  )
825
+ if response.status_code == 404:
826
+ if not _best_effort_register_cli_agent(orchestration):
827
+ return
828
+ with httpx.Client(timeout=5) as client:
829
+ response = client.patch(
830
+ f"{base_url}/v1/orchestration/agents/{agent_id}/heartbeat",
831
+ headers=headers,
832
+ )
776
833
  if response.status_code in {404, 401, 403}:
777
834
  return
778
835
  except httpx.HTTPError:
@@ -839,16 +896,18 @@ def _validate_gemini_launch_plan(plan: LaunchPlan, state: dict) -> None:
839
896
  return
840
897
 
841
898
  expected_base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
842
-
899
+
843
900
  base_url = (plan.env.get("GOOGLE_GEMINI_BASE_URL") or "").strip()
844
901
  if not base_url:
845
- # Try the other one
846
- base_url = (plan.env.get("GEMINI_API_ENDPOINT") or "").strip()
902
+ base_url = (plan.env.get("GEMINI_API_ENDPOINT") or "").strip()
847
903
 
848
904
  if not base_url:
849
905
  raise ValueError("Gemini Matrx route is missing GOOGLE_GEMINI_BASE_URL or GEMINI_API_ENDPOINT")
850
-
851
- if base_url != expected_base_url:
906
+
907
+ # Strip query params before comparing (project_id context may be appended as ?mtrx_project=...)
908
+ base_url_no_qs = base_url.split("?")[0].rstrip("/")
909
+ expected_no_qs = expected_base_url.rstrip("/")
910
+ if base_url_no_qs != expected_no_qs:
852
911
  raise ValueError(
853
912
  "Gemini Matrx route must use the Matrx /v1 base URL. "
854
913
  f"Got: {base_url}"
@@ -921,13 +980,19 @@ def describe_launch_plan(plan: LaunchPlan, state: dict) -> list[str]:
921
980
 
922
981
  if plan.tool == "gemini":
923
982
  base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
924
- return [
983
+ _, project_id = _resolve_matrx_context_overrides(state, dict(plan.env))
984
+ lines = [
925
985
  "Launching gemini via Matrx",
926
986
  f" base_url: {base_url}",
927
987
  f" auth_source: {plan.auth_source}",
928
988
  " runtime_route: env injection",
929
989
  " persistent_route: disabled",
930
990
  ]
991
+ if project_id:
992
+ lines.append(f" project_id: {project_id}")
993
+ else:
994
+ lines.append(" project_id: (from API key scope)")
995
+ return lines
931
996
 
932
997
  return []
933
998
 
@@ -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")
@@ -558,11 +571,20 @@ def _restore_cursor_if_needed() -> None:
558
571
  def _cmd_status() -> int:
559
572
  state = load_state()
560
573
  auth = state["auth"]
574
+
575
+ # Show active project for this workspace
576
+ workspace_binding = get_workspace_binding(state, cwd=os.environ.get("PWD") or os.getcwd()) or {}
577
+ active_project_id = (
578
+ (os.environ.get("MTRX_PROJECT_ID") or "").strip()
579
+ or (workspace_binding.get("project_id") or "").strip()
580
+ )
581
+ if active_project_id:
582
+ source = "env" if (os.environ.get("MTRX_PROJECT_ID") or "").strip() else "workspace"
583
+ print(f"Active project: {active_project_id} [{source}]")
584
+ else:
585
+ print("Active project: none (run: mtrx project switch <name>)")
586
+
561
587
  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'))}")
566
588
  print("Auth:")
567
589
  print(
568
590
  " matrx: "
@@ -976,5 +998,29 @@ def _default_route_label(route: str | None) -> str:
976
998
  return route or "unset"
977
999
 
978
1000
 
1001
+ def _cmd_project(args) -> int:
1002
+ from matrx.cli.project_cmds import cmd_list, cmd_current, cmd_switch, cmd_create, cmd_init
1003
+
1004
+ sub = getattr(args, "project_command", None)
1005
+ if sub == "list":
1006
+ return cmd_list(args)
1007
+ if sub == "current":
1008
+ return cmd_current(args)
1009
+ if sub == "switch":
1010
+ return cmd_switch(args)
1011
+ if sub == "create":
1012
+ return cmd_create(args)
1013
+ if sub == "init":
1014
+ return cmd_init(args)
1015
+
1016
+ print("Usage: mtrx project <list|current|switch|create|init>", file=sys.stderr)
1017
+ print(" list List all projects in your org")
1018
+ print(" current Show active project for this workspace")
1019
+ print(" switch <name> Bind this workspace to a project")
1020
+ print(" create <name> Create a new project")
1021
+ print(" init Link this git repo to a Matrx project")
1022
+ return 1
1023
+
1024
+
979
1025
  if __name__ == "__main__":
980
1026
  raise SystemExit(main())