mtrx-cli 0.1.18 → 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.18",
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": {
@@ -1 +1 @@
1
- __version__ = "0.1.18"
1
+ __version__ = "0.1.19"
@@ -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],
@@ -475,13 +506,16 @@ def _build_codex_env(
475
506
  proxy_base = ensure_v1_url(matrx.get("base_url"))
476
507
  mx_key, matrx_auth_source = _resolve_matrx_route_key(state, env)
477
508
  direct_key = (openai.get("key") or "").strip()
509
+ env_openai_key = (env.get("OPENAI_API_KEY") or "").strip()
478
510
 
479
511
  if route == "matrx":
480
512
  if not mx_key:
481
513
  raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
482
- access_token = read_codex_access_token()
483
- if not access_token:
484
- raise ValueError("Codex login required. Run: codex login")
514
+ provider_bearer = env_openai_key or direct_key or read_codex_access_token()
515
+ if not provider_bearer:
516
+ raise ValueError(
517
+ "Codex login required. Run: codex login or configure an OpenAI API key."
518
+ )
485
519
  for key in MATRX_ENV_KEYS:
486
520
  env.pop(key, None)
487
521
  env_snap = _capture_env_snapshot()
@@ -493,7 +527,7 @@ def _build_codex_env(
493
527
  or _runtime_agent_basename("codex")[0]
494
528
  )
495
529
  header_parts = [
496
- f'"Authorization" = "Bearer {access_token}"',
530
+ f'"Authorization" = "Bearer {provider_bearer}"',
497
531
  f'"X-Matrx-Key" = "{mx_key}"',
498
532
  f'"X-Matrx-Agent-Id" = "{runtime_agent_id}"',
499
533
  '"X-Matrx-Provider" = "codex"',
@@ -552,19 +586,36 @@ def _build_gemini_env(
552
586
  if route == "matrx":
553
587
  if not mx_key:
554
588
  raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
555
-
589
+
556
590
  # Clear existing Gemini config to force proxy usage
557
591
  env.pop("MTRX_KEY", None)
558
-
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
+
559
614
  # Set Proxy Config
560
- env["GOOGLE_GEMINI_BASE_URL"] = proxy_base
561
- 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
562
617
  env["GOOGLE_API_KEY"] = mx_key
563
-
564
- # Matrx-specific headers (if supported by the tool, or for our own tracking)
565
- # Note: Standard Gemini CLI might not support custom headers via env vars easily.
566
- # We rely on the Base URL routing to Matrx proxy which handles the logic.
567
-
618
+
568
619
  return env, matrx_auth_source
569
620
 
570
621
  # Direct route: clear any matrx-managed env vars
@@ -706,14 +757,14 @@ def _build_orchestration_metadata(
706
757
  }
707
758
 
708
759
 
709
- def _best_effort_register_cli_agent(orchestration: dict) -> None:
760
+ def _best_effort_register_cli_agent(orchestration: dict) -> bool:
710
761
  if not orchestration:
711
- return
762
+ return False
712
763
  base_url = (orchestration.get("base_url") or "").rstrip("/")
713
764
  matrx_key = (orchestration.get("matrx_key") or "").strip()
714
765
  agent_id = (orchestration.get("agent_id") or "").strip()
715
766
  if not base_url or not matrx_key or not agent_id:
716
- return
767
+ return False
717
768
 
718
769
  payload = {
719
770
  "agent_id": agent_id,
@@ -740,10 +791,11 @@ def _best_effort_register_cli_agent(orchestration: dict) -> None:
740
791
  json=payload,
741
792
  )
742
793
  if response.status_code in {404, 401, 403}:
743
- return
794
+ return False
744
795
  response.raise_for_status()
796
+ return True
745
797
  except httpx.HTTPError:
746
- return
798
+ return False
747
799
 
748
800
 
749
801
  def _heartbeat_cli_agent(orchestration: dict, stop_event) -> None:
@@ -770,6 +822,14 @@ def _heartbeat_cli_agent(orchestration: dict, stop_event) -> None:
770
822
  f"{base_url}/v1/orchestration/agents/{agent_id}/heartbeat",
771
823
  headers=headers,
772
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
+ )
773
833
  if response.status_code in {404, 401, 403}:
774
834
  return
775
835
  except httpx.HTTPError:
@@ -836,16 +896,18 @@ def _validate_gemini_launch_plan(plan: LaunchPlan, state: dict) -> None:
836
896
  return
837
897
 
838
898
  expected_base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
839
-
899
+
840
900
  base_url = (plan.env.get("GOOGLE_GEMINI_BASE_URL") or "").strip()
841
901
  if not base_url:
842
- # Try the other one
843
- base_url = (plan.env.get("GEMINI_API_ENDPOINT") or "").strip()
902
+ base_url = (plan.env.get("GEMINI_API_ENDPOINT") or "").strip()
844
903
 
845
904
  if not base_url:
846
905
  raise ValueError("Gemini Matrx route is missing GOOGLE_GEMINI_BASE_URL or GEMINI_API_ENDPOINT")
847
-
848
- 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:
849
911
  raise ValueError(
850
912
  "Gemini Matrx route must use the Matrx /v1 base URL. "
851
913
  f"Got: {base_url}"
@@ -918,13 +980,19 @@ def describe_launch_plan(plan: LaunchPlan, state: dict) -> list[str]:
918
980
 
919
981
  if plan.tool == "gemini":
920
982
  base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
921
- return [
983
+ _, project_id = _resolve_matrx_context_overrides(state, dict(plan.env))
984
+ lines = [
922
985
  "Launching gemini via Matrx",
923
986
  f" base_url: {base_url}",
924
987
  f" auth_source: {plan.auth_source}",
925
988
  " runtime_route: env injection",
926
989
  " persistent_route: disabled",
927
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
928
996
 
929
997
  return []
930
998
 
@@ -78,6 +78,12 @@ def main(argv: list[str] | None = None) -> int:
78
78
  return _cmd_launch(args.command, args.route, remainder)
79
79
  if args.command == "cursor":
80
80
  return _cmd_cursor(args)
81
+ if args.command == "init":
82
+ from matrx.cli.bootstrap import run_init
83
+ run_init()
84
+ return 0
85
+ if args.command == "project":
86
+ return _cmd_project(args)
81
87
 
82
88
  parser.print_help()
83
89
  return 1
@@ -104,6 +110,7 @@ def _build_parser() -> argparse.ArgumentParser:
104
110
  subparsers.add_parser("version")
105
111
  subparsers.add_parser("status")
106
112
  subparsers.add_parser("doctor")
113
+ subparsers.add_parser("init", help="Initialize Matrx for an existing project (seeds system registry)")
107
114
 
108
115
  personal = subparsers.add_parser("personal")
109
116
  personal_subparsers = personal.add_subparsers(dest="personal_command")
@@ -119,6 +126,17 @@ def _build_parser() -> argparse.ArgumentParser:
119
126
  gemini = subparsers.add_parser("gemini")
120
127
  gemini.add_argument("--route", choices=["direct", "matrx"])
121
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
+
122
140
  cursor = subparsers.add_parser("cursor")
123
141
  cursor.add_argument("--route", choices=["direct", "matrx"])
124
142
  cursor.add_argument("--status", action="store_true", help="Check proxy status")
@@ -553,11 +571,20 @@ def _restore_cursor_if_needed() -> None:
553
571
  def _cmd_status() -> int:
554
572
  state = load_state()
555
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
+
556
587
  print("Defaults:")
557
- print(f" codex: {_default_route_label(configured_route(state, 'codex'))}")
558
- print(f" claude: {_default_route_label(configured_route(state, 'claude'))}")
559
- print(f" gemini: {_default_route_label(configured_route(state, 'gemini'))}")
560
- print(f" cursor: {_default_route_label(configured_route(state, 'cursor'))}")
561
588
  print("Auth:")
562
589
  print(
563
590
  " matrx: "
@@ -971,5 +998,29 @@ def _default_route_label(route: str | None) -> str:
971
998
  return route or "unset"
972
999
 
973
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
+
974
1025
  if __name__ == "__main__":
975
1026
  raise SystemExit(main())