mtrx-cli 0.1.21 → 0.1.23
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 +2 -1
- package/src/matrx/__init__.py +1 -1
- package/src/matrx/cli/launcher.py +89 -40
- package/src/matrx/cli/project_cmds.py +526 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mtrx-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "MATRX CLI for routing Codex, Claude, and Cursor through Matrx",
|
|
5
5
|
"homepage": "https://mtrx.so",
|
|
6
6
|
"repository": {
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"src/matrx/cli/cursor_service.py",
|
|
36
36
|
"src/matrx/cli/launcher.py",
|
|
37
37
|
"src/matrx/cli/main.py",
|
|
38
|
+
"src/matrx/cli/project_cmds.py",
|
|
38
39
|
"src/matrx/cli/state.py"
|
|
39
40
|
],
|
|
40
41
|
"engines": {
|
package/src/matrx/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.1.
|
|
1
|
+
__version__ = "0.1.23"
|
|
@@ -64,6 +64,10 @@ MATRX_ENV_KEYS = {
|
|
|
64
64
|
"ANTHROPIC_CUSTOM_HEADERS",
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
# Avoid routing Codex/Claude/Gemini through Cursor's MITM proxy when running
|
|
68
|
+
# from Cursor's integrated terminal. These tools should talk directly to MATRX.
|
|
69
|
+
_PROXY_ENV_KEYS = ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy")
|
|
70
|
+
|
|
67
71
|
MTRX_CODEX_BLOCK_START = "# >>> mtrx managed codex route >>>"
|
|
68
72
|
MTRX_CODEX_BLOCK_END = "# <<< mtrx managed codex route <<<"
|
|
69
73
|
VALID_ROUTES = {"direct", "matrx"}
|
|
@@ -511,6 +515,8 @@ def _build_codex_env(
|
|
|
511
515
|
if route == "matrx":
|
|
512
516
|
if not mx_key:
|
|
513
517
|
raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
|
|
518
|
+
for key in _PROXY_ENV_KEYS:
|
|
519
|
+
env.pop(key, None)
|
|
514
520
|
provider_bearer = env_openai_key or direct_key or read_codex_access_token()
|
|
515
521
|
if not provider_bearer:
|
|
516
522
|
raise ValueError(
|
|
@@ -579,33 +585,23 @@ def _build_gemini_env(
|
|
|
579
585
|
orchestration: dict | None = None,
|
|
580
586
|
) -> tuple[dict[str, str], str]:
|
|
581
587
|
matrx = state["auth"]["matrx"]
|
|
582
|
-
|
|
583
|
-
# For now, we don't have a specific 'gemini' auth section in state.py, but we can assume
|
|
584
|
-
# if direct route, we use env var.
|
|
588
|
+
proxy_root = ensure_root_url(matrx.get("base_url"))
|
|
585
589
|
proxy_base = ensure_v1_url(matrx.get("base_url"))
|
|
586
590
|
mx_key, matrx_auth_source = _resolve_matrx_route_key(state, env)
|
|
587
|
-
|
|
588
|
-
# Check for direct key in env or potentially saved elsewhere
|
|
589
|
-
direct_key = (env.get("GOOGLE_API_KEY") or "").strip()
|
|
591
|
+
direct_key = (env.get("GEMINI_API_KEY") or env.get("GOOGLE_API_KEY") or "").strip()
|
|
590
592
|
|
|
591
593
|
if route == "matrx":
|
|
592
594
|
if not mx_key:
|
|
593
595
|
raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
|
|
594
|
-
|
|
595
|
-
|
|
596
|
+
for key in _PROXY_ENV_KEYS:
|
|
597
|
+
env.pop(key, None)
|
|
596
598
|
env.pop("MTRX_KEY", None)
|
|
597
|
-
|
|
598
599
|
group_id, project_id = _resolve_matrx_context_overrides(state, env)
|
|
599
600
|
session_id = str(uuid.uuid4())
|
|
600
601
|
runtime_agent_id = (
|
|
601
602
|
(orchestration or {}).get("agent_id")
|
|
602
603
|
or _runtime_agent_basename("gemini")[0]
|
|
603
604
|
)
|
|
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
605
|
ctx_params: list[str] = []
|
|
610
606
|
if project_id:
|
|
611
607
|
ctx_params.append(f"mtrx_project={project_id}")
|
|
@@ -613,31 +609,65 @@ def _build_gemini_env(
|
|
|
613
609
|
ctx_params.append(f"mtrx_session={session_id}")
|
|
614
610
|
if runtime_agent_id:
|
|
615
611
|
ctx_params.append(f"mtrx_agent={runtime_agent_id}")
|
|
616
|
-
|
|
617
|
-
|
|
612
|
+
git_branch, git_commit = _capture_git_context(_workspace_cwd(env))
|
|
613
|
+
if git_branch:
|
|
614
|
+
ctx_params.append(f"mtrx_branch={git_branch}")
|
|
615
|
+
if git_commit:
|
|
616
|
+
ctx_params.append(f"mtrx_commit={git_commit}")
|
|
617
|
+
|
|
618
|
+
query_suffix = f"?{'&'.join(ctx_params)}" if ctx_params else ""
|
|
619
|
+
env_snap = _capture_env_snapshot()
|
|
620
|
+
env_b64 = base64.b64encode(json.dumps(env_snap).encode()).decode() if env_snap else ""
|
|
621
|
+
custom_headers = [
|
|
622
|
+
f"x-matrx-key: {mx_key}",
|
|
623
|
+
f"x-matrx-agent-id: {runtime_agent_id}",
|
|
624
|
+
"x-matrx-provider: gemini_code",
|
|
625
|
+
f"x-matrx-session-id: {session_id}",
|
|
626
|
+
]
|
|
627
|
+
if group_id:
|
|
628
|
+
custom_headers.append(f"x-matrx-group: {group_id}")
|
|
629
|
+
if project_id:
|
|
630
|
+
custom_headers.append(f"x-matrx-project-id: {project_id}")
|
|
631
|
+
if git_branch:
|
|
632
|
+
custom_headers.append(f"x-matrx-branch: {git_branch}")
|
|
633
|
+
if git_commit:
|
|
634
|
+
custom_headers.append(f"x-matrx-commit: {git_commit}")
|
|
635
|
+
if env_b64:
|
|
636
|
+
custom_headers.append(f"x-matrx-env: {env_b64}")
|
|
618
637
|
|
|
619
|
-
|
|
620
|
-
env["
|
|
621
|
-
env["GEMINI_API_ENDPOINT"] =
|
|
622
|
-
env["
|
|
638
|
+
env["GOOGLE_GEMINI_BASE_URL"] = f"{proxy_base}/v1beta{query_suffix}"
|
|
639
|
+
env["GOOGLE_VERTEX_BASE_URL"] = f"{proxy_base}/v1beta{query_suffix}"
|
|
640
|
+
env["GEMINI_API_ENDPOINT"] = env["GOOGLE_GEMINI_BASE_URL"]
|
|
641
|
+
env["CODE_ASSIST_ENDPOINT"] = proxy_base
|
|
642
|
+
env["GEMINI_CLI_CUSTOM_HEADERS"] = ", ".join(custom_headers)
|
|
643
|
+
env["GEMINI_API_KEY_AUTH_MECHANISM"] = "bearer"
|
|
623
644
|
|
|
624
645
|
return env, matrx_auth_source
|
|
625
646
|
|
|
626
647
|
# Direct route: clear any matrx-managed env vars
|
|
627
648
|
env.pop("MTRX_KEY", None)
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
649
|
+
for key in (
|
|
650
|
+
"GOOGLE_GEMINI_BASE_URL",
|
|
651
|
+
"GOOGLE_VERTEX_BASE_URL",
|
|
652
|
+
"GEMINI_API_ENDPOINT",
|
|
653
|
+
"CODE_ASSIST_ENDPOINT",
|
|
654
|
+
):
|
|
655
|
+
value = (env.get(key) or "").strip()
|
|
656
|
+
if "matrx" in value.lower() or "mtrx.so" in value.lower():
|
|
657
|
+
env.pop(key, None)
|
|
658
|
+
|
|
659
|
+
custom_headers = (env.get("GEMINI_CLI_CUSTOM_HEADERS") or "").strip().lower()
|
|
660
|
+
if "x-matrx-" in custom_headers:
|
|
661
|
+
env.pop("GEMINI_CLI_CUSTOM_HEADERS", None)
|
|
662
|
+
if (env.get("GEMINI_API_KEY_AUTH_MECHANISM") or "").strip().lower() == "bearer":
|
|
663
|
+
env.pop("GEMINI_API_KEY_AUTH_MECHANISM", None)
|
|
664
|
+
|
|
665
|
+
if env.get("GEMINI_API_KEY") or env.get("GOOGLE_API_KEY"):
|
|
639
666
|
return env, "existing_google_env"
|
|
640
|
-
|
|
667
|
+
|
|
668
|
+
if direct_key:
|
|
669
|
+
return env, "existing_gemini_auth"
|
|
670
|
+
|
|
641
671
|
return env, "missing_auth"
|
|
642
672
|
|
|
643
673
|
|
|
@@ -658,6 +688,8 @@ def _build_claude_env(
|
|
|
658
688
|
if route == "matrx":
|
|
659
689
|
if not mx_key:
|
|
660
690
|
raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
|
|
691
|
+
for key in _PROXY_ENV_KEYS:
|
|
692
|
+
env.pop(key, None)
|
|
661
693
|
env.pop("MTRX_KEY", None)
|
|
662
694
|
env.pop("MATRX_CLAUDE_MODE", None)
|
|
663
695
|
env["MATRX_BASE_URL"] = proxy_root
|
|
@@ -906,26 +938,40 @@ def _validate_gemini_launch_plan(plan: LaunchPlan, state: dict) -> None:
|
|
|
906
938
|
return
|
|
907
939
|
|
|
908
940
|
expected_base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
|
|
941
|
+
expected_gemini_base = f"{expected_base_url}/v1beta"
|
|
909
942
|
|
|
910
943
|
base_url = (plan.env.get("GOOGLE_GEMINI_BASE_URL") or "").strip()
|
|
911
944
|
if not base_url:
|
|
912
945
|
base_url = (plan.env.get("GEMINI_API_ENDPOINT") or "").strip()
|
|
913
946
|
|
|
914
947
|
if not base_url:
|
|
915
|
-
raise ValueError("Gemini Matrx route is missing GOOGLE_GEMINI_BASE_URL
|
|
948
|
+
raise ValueError("Gemini Matrx route is missing GOOGLE_GEMINI_BASE_URL")
|
|
916
949
|
|
|
917
|
-
# Strip query params before comparing (project_id context may be appended as ?mtrx_project=...)
|
|
918
950
|
base_url_no_qs = base_url.split("?")[0].rstrip("/")
|
|
919
|
-
expected_no_qs =
|
|
951
|
+
expected_no_qs = expected_gemini_base.rstrip("/")
|
|
920
952
|
if base_url_no_qs != expected_no_qs:
|
|
921
953
|
raise ValueError(
|
|
922
|
-
"Gemini Matrx route must use the Matrx
|
|
954
|
+
"Gemini Matrx route must use the Matrx Gemini-native base URL. "
|
|
923
955
|
f"Got: {base_url}"
|
|
924
956
|
)
|
|
925
957
|
|
|
926
|
-
|
|
927
|
-
if
|
|
928
|
-
raise ValueError("Gemini Matrx route is missing a
|
|
958
|
+
vertex_base = (plan.env.get("GOOGLE_VERTEX_BASE_URL") or "").strip()
|
|
959
|
+
if vertex_base and vertex_base.split("?")[0].rstrip("/") != expected_no_qs:
|
|
960
|
+
raise ValueError("Gemini Matrx route is missing a Matrx GOOGLE_VERTEX_BASE_URL")
|
|
961
|
+
|
|
962
|
+
code_assist_endpoint = (plan.env.get("CODE_ASSIST_ENDPOINT") or "").strip().rstrip("/")
|
|
963
|
+
if code_assist_endpoint != expected_base_url.rstrip("/"):
|
|
964
|
+
raise ValueError("Gemini Matrx route is missing a Matrx CODE_ASSIST_ENDPOINT")
|
|
965
|
+
|
|
966
|
+
custom_headers = (plan.env.get("GEMINI_CLI_CUSTOM_HEADERS") or "").strip().lower()
|
|
967
|
+
if "x-matrx-key:" not in custom_headers:
|
|
968
|
+
raise ValueError("Gemini Matrx route is missing GEMINI_CLI_CUSTOM_HEADERS with X-Matrx-Key")
|
|
969
|
+
if "x-matrx-provider: gemini_code" not in custom_headers:
|
|
970
|
+
raise ValueError("Gemini Matrx route is missing GEMINI_CLI_CUSTOM_HEADERS with X-Matrx-Provider=gemini_code")
|
|
971
|
+
if "x-matrx-session-id:" not in custom_headers:
|
|
972
|
+
raise ValueError("Gemini Matrx route is missing GEMINI_CLI_CUSTOM_HEADERS with X-Matrx-Session-Id")
|
|
973
|
+
if "x-matrx-agent-id:" not in custom_headers:
|
|
974
|
+
raise ValueError("Gemini Matrx route is missing GEMINI_CLI_CUSTOM_HEADERS with X-Matrx-Agent-Id")
|
|
929
975
|
|
|
930
976
|
|
|
931
977
|
def _validate_codex_launch_plan(plan: LaunchPlan, state: dict) -> None:
|
|
@@ -991,10 +1037,13 @@ def describe_launch_plan(plan: LaunchPlan, state: dict) -> list[str]:
|
|
|
991
1037
|
if plan.tool == "gemini":
|
|
992
1038
|
base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
|
|
993
1039
|
_, project_id = _resolve_matrx_context_overrides(state, dict(plan.env))
|
|
1040
|
+
custom_headers = (plan.env.get("GEMINI_CLI_CUSTOM_HEADERS") or "").strip()
|
|
994
1041
|
lines = [
|
|
995
1042
|
"Launching gemini via Matrx",
|
|
996
|
-
f"
|
|
1043
|
+
f" gemini_base_url: {plan.env.get('GOOGLE_GEMINI_BASE_URL') or ''}",
|
|
1044
|
+
f" code_assist_endpoint: {plan.env.get('CODE_ASSIST_ENDPOINT') or ''}",
|
|
997
1045
|
f" auth_source: {plan.auth_source}",
|
|
1046
|
+
f" custom_headers_present: {bool(custom_headers)}",
|
|
998
1047
|
" runtime_route: env injection",
|
|
999
1048
|
" persistent_route: disabled",
|
|
1000
1049
|
]
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI commands for managing Matrx projects.
|
|
3
|
+
|
|
4
|
+
mtrx project list — list all projects in your org
|
|
5
|
+
mtrx project current — show which project is active in this workspace
|
|
6
|
+
mtrx project switch <name>— bind this workspace to a project
|
|
7
|
+
mtrx project create <name>— create a new project and optionally bind workspace
|
|
8
|
+
mtrx project init — interactive: link this git repo to a Matrx project
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from matrx.cli.state import (
|
|
21
|
+
ensure_root_url,
|
|
22
|
+
get_workspace_binding,
|
|
23
|
+
normalize_matrx_key,
|
|
24
|
+
resolve_workspace_root,
|
|
25
|
+
set_workspace_binding,
|
|
26
|
+
load_state,
|
|
27
|
+
save_state,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Internal API helper
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def _api(
|
|
36
|
+
state: dict,
|
|
37
|
+
*,
|
|
38
|
+
method: str,
|
|
39
|
+
path: str,
|
|
40
|
+
key: str,
|
|
41
|
+
json_body: dict | None = None,
|
|
42
|
+
) -> dict:
|
|
43
|
+
base_url = ensure_root_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
|
|
44
|
+
url = f"{base_url.rstrip('/')}/v1{path}"
|
|
45
|
+
headers: dict[str, str] = {"X-Matrx-Key": key}
|
|
46
|
+
if json_body is not None:
|
|
47
|
+
headers["Content-Type"] = "application/json"
|
|
48
|
+
try:
|
|
49
|
+
with httpx.Client(timeout=15) as client:
|
|
50
|
+
response = client.request(method, url, headers=headers, json=json_body)
|
|
51
|
+
except httpx.HTTPError as exc:
|
|
52
|
+
raise ValueError(f"Matrx API request failed: {exc}") from exc
|
|
53
|
+
if response.status_code >= 400:
|
|
54
|
+
detail = response.text.strip() or response.reason_phrase
|
|
55
|
+
raise ValueError(f"Matrx API error ({response.status_code}) for {path}: {detail}")
|
|
56
|
+
if response.status_code == 204 or not response.content:
|
|
57
|
+
return {}
|
|
58
|
+
return response.json()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_key(state: dict) -> str:
|
|
62
|
+
env_key = normalize_matrx_key(os.environ.get("MTRX_KEY"))
|
|
63
|
+
if env_key:
|
|
64
|
+
return env_key
|
|
65
|
+
binding = get_workspace_binding(state, cwd=os.getcwd()) or {}
|
|
66
|
+
binding_key = normalize_matrx_key(binding.get("matrx_key"))
|
|
67
|
+
if binding_key:
|
|
68
|
+
return binding_key
|
|
69
|
+
saved = normalize_matrx_key(state.get("auth", {}).get("matrx", {}).get("key"))
|
|
70
|
+
return saved
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _require_key(state: dict) -> str:
|
|
74
|
+
key = _resolve_key(state)
|
|
75
|
+
if not key:
|
|
76
|
+
raise ValueError("No Matrx key found. Run: mtrx login matrx --key mx_...")
|
|
77
|
+
return key
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# project list
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def cmd_list(args) -> int:
|
|
85
|
+
state = load_state()
|
|
86
|
+
try:
|
|
87
|
+
key = _require_key(state)
|
|
88
|
+
ctx = _api(state, method="GET", path="/auth/context", key=key)
|
|
89
|
+
org_id = (ctx.get("org_id") or "").strip()
|
|
90
|
+
if not org_id:
|
|
91
|
+
print("Could not determine org_id from auth context.", file=sys.stderr)
|
|
92
|
+
return 1
|
|
93
|
+
projects = _api(state, method="GET", path=f"/orgs/{org_id}/projects", key=key)
|
|
94
|
+
except ValueError as exc:
|
|
95
|
+
print(str(exc), file=sys.stderr)
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
items = projects if isinstance(projects, list) else projects.get("projects", [])
|
|
99
|
+
if not items:
|
|
100
|
+
print("No projects found. Create one with: mtrx project create <name>")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
# Determine which project is active for this workspace
|
|
104
|
+
binding = get_workspace_binding(state, cwd=os.getcwd()) or {}
|
|
105
|
+
active_project_id = (
|
|
106
|
+
os.environ.get("MTRX_PROJECT_ID")
|
|
107
|
+
or binding.get("project_id")
|
|
108
|
+
or ""
|
|
109
|
+
).strip()
|
|
110
|
+
|
|
111
|
+
print(f"{' ':2}{'NAME':<30} {'SLUG':<25} {'ID'}")
|
|
112
|
+
print("-" * 80)
|
|
113
|
+
for p in items:
|
|
114
|
+
pid = p.get("id", "")
|
|
115
|
+
name = p.get("name", "")
|
|
116
|
+
slug = p.get("slug", "")
|
|
117
|
+
marker = "* " if pid == active_project_id else " "
|
|
118
|
+
default_tag = " [default]" if p.get("is_default") else ""
|
|
119
|
+
print(f"{marker}{name:<30} {slug:<25} {pid}{default_tag}")
|
|
120
|
+
|
|
121
|
+
if active_project_id:
|
|
122
|
+
print(f"\n* = active project for this workspace")
|
|
123
|
+
else:
|
|
124
|
+
print("\nNo project bound to this workspace. Run: mtrx project switch <name>")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# project current
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def cmd_current(args) -> int:
|
|
133
|
+
state = load_state()
|
|
134
|
+
binding = get_workspace_binding(state, cwd=os.getcwd()) or {}
|
|
135
|
+
workspace_root = resolve_workspace_root(os.getcwd())
|
|
136
|
+
|
|
137
|
+
env_project_id = (os.environ.get("MTRX_PROJECT_ID") or "").strip()
|
|
138
|
+
env_group_id = (os.environ.get("MTRX_GROUP_ID") or "").strip()
|
|
139
|
+
binding_project_id = (binding.get("project_id") or "").strip()
|
|
140
|
+
binding_group_id = (binding.get("group_id") or "").strip()
|
|
141
|
+
|
|
142
|
+
print(f"Workspace: {workspace_root}")
|
|
143
|
+
|
|
144
|
+
# Try to fetch project name from API
|
|
145
|
+
active_project_id = env_project_id or binding_project_id
|
|
146
|
+
if active_project_id:
|
|
147
|
+
try:
|
|
148
|
+
key = _require_key(state)
|
|
149
|
+
ctx = _api(state, method="GET", path="/auth/context", key=key)
|
|
150
|
+
org_id = (ctx.get("org_id") or "").strip()
|
|
151
|
+
if org_id:
|
|
152
|
+
projects = _api(state, method="GET", path=f"/orgs/{org_id}/projects", key=key)
|
|
153
|
+
items = projects if isinstance(projects, list) else projects.get("projects", [])
|
|
154
|
+
match = next((p for p in items if p.get("id") == active_project_id), None)
|
|
155
|
+
if match:
|
|
156
|
+
source = "env var" if env_project_id else "workspace binding"
|
|
157
|
+
print(f"Active project: {match['name']} ({match['slug']}) [{source}]")
|
|
158
|
+
print(f" project_id: {active_project_id}")
|
|
159
|
+
if active_project_id == env_project_id:
|
|
160
|
+
print(f" override: MTRX_PROJECT_ID env var takes priority")
|
|
161
|
+
if env_group_id or binding_group_id:
|
|
162
|
+
print(f" group_id: {env_group_id or binding_group_id}")
|
|
163
|
+
return 0
|
|
164
|
+
except ValueError:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
source = "env var" if env_project_id else "workspace binding"
|
|
168
|
+
print(f"Active project: {active_project_id} [{source}] (could not fetch name)")
|
|
169
|
+
else:
|
|
170
|
+
print("No project bound to this workspace.")
|
|
171
|
+
print(" Run: mtrx project switch <name> to bind a project")
|
|
172
|
+
print(" Run: mtrx project list to see available projects")
|
|
173
|
+
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# project switch
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def cmd_switch(args) -> int:
|
|
182
|
+
name_or_slug = (getattr(args, "name", "") or "").strip()
|
|
183
|
+
if not name_or_slug:
|
|
184
|
+
print("Usage: mtrx project switch <name-or-slug>", file=sys.stderr)
|
|
185
|
+
return 1
|
|
186
|
+
|
|
187
|
+
state = load_state()
|
|
188
|
+
try:
|
|
189
|
+
key = _require_key(state)
|
|
190
|
+
ctx = _api(state, method="GET", path="/auth/context", key=key)
|
|
191
|
+
org_id = (ctx.get("org_id") or "").strip()
|
|
192
|
+
if not org_id:
|
|
193
|
+
print("Could not determine org_id from auth context.", file=sys.stderr)
|
|
194
|
+
return 1
|
|
195
|
+
projects = _api(state, method="GET", path=f"/orgs/{org_id}/projects", key=key)
|
|
196
|
+
except ValueError as exc:
|
|
197
|
+
print(str(exc), file=sys.stderr)
|
|
198
|
+
return 1
|
|
199
|
+
|
|
200
|
+
items = projects if isinstance(projects, list) else projects.get("projects", [])
|
|
201
|
+
needle = name_or_slug.lower()
|
|
202
|
+
match = next(
|
|
203
|
+
(
|
|
204
|
+
p for p in items
|
|
205
|
+
if p.get("name", "").lower() == needle
|
|
206
|
+
or p.get("slug", "").lower() == needle
|
|
207
|
+
or p.get("id", "").lower() == needle
|
|
208
|
+
),
|
|
209
|
+
None,
|
|
210
|
+
)
|
|
211
|
+
if match is None:
|
|
212
|
+
print(f"Project '{name_or_slug}' not found.", file=sys.stderr)
|
|
213
|
+
print(f"Available: {', '.join(p.get('slug') or p.get('name', '') for p in items)}", file=sys.stderr)
|
|
214
|
+
return 1
|
|
215
|
+
|
|
216
|
+
changed = set_workspace_binding(state, cwd=os.getcwd(), project_id=match["id"])
|
|
217
|
+
if changed:
|
|
218
|
+
save_state(state)
|
|
219
|
+
|
|
220
|
+
workspace_root = resolve_workspace_root(os.getcwd())
|
|
221
|
+
print(f"Switched to project: {match['name']} ({match['slug']})")
|
|
222
|
+
print(f" project_id: {match['id']}")
|
|
223
|
+
if match.get("repo_url"):
|
|
224
|
+
print(f" repo_url: {match['repo_url']}")
|
|
225
|
+
print(f" workspace: {workspace_root}")
|
|
226
|
+
print(f" This workspace will now route all LLM calls to this project.")
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# project create
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def cmd_create(args) -> int:
|
|
235
|
+
name = (getattr(args, "name", "") or "").strip()
|
|
236
|
+
if not name:
|
|
237
|
+
print("Usage: mtrx project create <name> [--description TEXT]", file=sys.stderr)
|
|
238
|
+
return 1
|
|
239
|
+
description = (getattr(args, "description", "") or "").strip() or None
|
|
240
|
+
|
|
241
|
+
state = load_state()
|
|
242
|
+
try:
|
|
243
|
+
key = _require_key(state)
|
|
244
|
+
ctx = _api(state, method="GET", path="/auth/context", key=key)
|
|
245
|
+
org_id = (ctx.get("org_id") or "").strip()
|
|
246
|
+
if not org_id:
|
|
247
|
+
print("Could not determine org_id from auth context.", file=sys.stderr)
|
|
248
|
+
return 1
|
|
249
|
+
|
|
250
|
+
body: dict = {"name": name}
|
|
251
|
+
if description:
|
|
252
|
+
body["description"] = description
|
|
253
|
+
|
|
254
|
+
project = _api(
|
|
255
|
+
state,
|
|
256
|
+
method="POST",
|
|
257
|
+
path=f"/orgs/{org_id}/projects",
|
|
258
|
+
key=key,
|
|
259
|
+
json_body=body,
|
|
260
|
+
)
|
|
261
|
+
except ValueError as exc:
|
|
262
|
+
print(str(exc), file=sys.stderr)
|
|
263
|
+
return 1
|
|
264
|
+
|
|
265
|
+
print(f"Created project: {project.get('name')} ({project.get('slug')})")
|
|
266
|
+
print(f" project_id: {project.get('id')}")
|
|
267
|
+
|
|
268
|
+
# Ask if they want to bind this workspace
|
|
269
|
+
if sys.stdin.isatty() and sys.stdout.isatty():
|
|
270
|
+
answer = input("Bind this workspace to the new project? [Y/n] ").strip().lower()
|
|
271
|
+
if answer in {"", "y", "yes"}:
|
|
272
|
+
changed = set_workspace_binding(state, cwd=os.getcwd(), project_id=project["id"])
|
|
273
|
+
if changed:
|
|
274
|
+
save_state(state)
|
|
275
|
+
print(f" Workspace bound to: {project.get('name')}")
|
|
276
|
+
else:
|
|
277
|
+
# Non-interactive: auto-bind
|
|
278
|
+
changed = set_workspace_binding(state, cwd=os.getcwd(), project_id=project["id"])
|
|
279
|
+
if changed:
|
|
280
|
+
save_state(state)
|
|
281
|
+
print(f" Workspace auto-bound to: {project.get('name')}")
|
|
282
|
+
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# project init
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def _git_remote_url() -> str | None:
|
|
291
|
+
try:
|
|
292
|
+
result = subprocess.run(
|
|
293
|
+
["git", "remote", "get-url", "origin"],
|
|
294
|
+
capture_output=True,
|
|
295
|
+
text=True,
|
|
296
|
+
timeout=3,
|
|
297
|
+
check=False,
|
|
298
|
+
)
|
|
299
|
+
if result.returncode == 0:
|
|
300
|
+
return result.stdout.strip() or None
|
|
301
|
+
except (OSError, subprocess.SubprocessError):
|
|
302
|
+
pass
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _git_current_branch() -> str | None:
|
|
307
|
+
try:
|
|
308
|
+
result = subprocess.run(
|
|
309
|
+
["git", "branch", "--show-current"],
|
|
310
|
+
capture_output=True,
|
|
311
|
+
text=True,
|
|
312
|
+
timeout=3,
|
|
313
|
+
check=False,
|
|
314
|
+
)
|
|
315
|
+
if result.returncode == 0:
|
|
316
|
+
return result.stdout.strip() or None
|
|
317
|
+
except (OSError, subprocess.SubprocessError):
|
|
318
|
+
pass
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _is_interactive() -> bool:
|
|
323
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _prompt(prompt: str, default: str = "") -> str:
|
|
327
|
+
if not _is_interactive():
|
|
328
|
+
return default
|
|
329
|
+
result = input(prompt).strip()
|
|
330
|
+
return result or default
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _patch_project_repo_metadata(
|
|
334
|
+
state: dict,
|
|
335
|
+
*,
|
|
336
|
+
key: str,
|
|
337
|
+
org_id: str,
|
|
338
|
+
project: dict,
|
|
339
|
+
repo_url: str | None,
|
|
340
|
+
branch: str | None,
|
|
341
|
+
) -> None:
|
|
342
|
+
if not repo_url or not project.get("id"):
|
|
343
|
+
return
|
|
344
|
+
try:
|
|
345
|
+
_api(
|
|
346
|
+
state,
|
|
347
|
+
method="PATCH",
|
|
348
|
+
path=f"/orgs/{org_id}/projects/{project['id']}",
|
|
349
|
+
key=key,
|
|
350
|
+
json_body={
|
|
351
|
+
"repo_url": repo_url,
|
|
352
|
+
"default_branch": branch or "main",
|
|
353
|
+
},
|
|
354
|
+
)
|
|
355
|
+
except ValueError:
|
|
356
|
+
pass
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def cmd_init(args) -> int:
|
|
360
|
+
state = load_state()
|
|
361
|
+
try:
|
|
362
|
+
key = _require_key(state)
|
|
363
|
+
except ValueError as exc:
|
|
364
|
+
print(str(exc), file=sys.stderr)
|
|
365
|
+
return 1
|
|
366
|
+
|
|
367
|
+
workspace_root = resolve_workspace_root(os.getcwd())
|
|
368
|
+
repo_url = _git_remote_url()
|
|
369
|
+
branch = _git_current_branch()
|
|
370
|
+
|
|
371
|
+
print(f"Matrx project init")
|
|
372
|
+
print(f" workspace: {workspace_root}")
|
|
373
|
+
if repo_url:
|
|
374
|
+
print(f" git remote: {repo_url}")
|
|
375
|
+
if branch:
|
|
376
|
+
print(f" branch: {branch}")
|
|
377
|
+
print()
|
|
378
|
+
|
|
379
|
+
# Check if already bound
|
|
380
|
+
binding = get_workspace_binding(state, cwd=os.getcwd()) or {}
|
|
381
|
+
existing_project_id = (binding.get("project_id") or "").strip()
|
|
382
|
+
if existing_project_id:
|
|
383
|
+
print(f"This workspace is already bound to project: {existing_project_id}")
|
|
384
|
+
if _is_interactive():
|
|
385
|
+
answer = _prompt("Rebind to a different project? [y/N] ", "n")
|
|
386
|
+
if answer.lower() not in {"y", "yes"}:
|
|
387
|
+
print("No changes made.")
|
|
388
|
+
return 0
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
ctx = _api(state, method="GET", path="/auth/context", key=key)
|
|
392
|
+
org_id = (ctx.get("org_id") or "").strip()
|
|
393
|
+
if not org_id:
|
|
394
|
+
print("Could not determine org_id from auth context.", file=sys.stderr)
|
|
395
|
+
return 1
|
|
396
|
+
projects = _api(state, method="GET", path=f"/orgs/{org_id}/projects", key=key)
|
|
397
|
+
except ValueError as exc:
|
|
398
|
+
print(str(exc), file=sys.stderr)
|
|
399
|
+
return 1
|
|
400
|
+
|
|
401
|
+
items = projects if isinstance(projects, list) else projects.get("projects", [])
|
|
402
|
+
|
|
403
|
+
if not items:
|
|
404
|
+
print("No projects found in your org.")
|
|
405
|
+
if _is_interactive():
|
|
406
|
+
answer = _prompt("Create a new project now? [Y/n] ", "y")
|
|
407
|
+
if answer.lower() in {"", "y", "yes"}:
|
|
408
|
+
return _create_and_bind(state, key, org_id, workspace_root, repo_url, branch)
|
|
409
|
+
return 1
|
|
410
|
+
|
|
411
|
+
# Show existing projects
|
|
412
|
+
print("Available projects:")
|
|
413
|
+
for i, p in enumerate(items, 1):
|
|
414
|
+
default_tag = " [default]" if p.get("is_default") else ""
|
|
415
|
+
print(f" {i}. {p.get('name')} ({p.get('slug')}){default_tag}")
|
|
416
|
+
print(f" {len(items) + 1}. Create a new project")
|
|
417
|
+
print()
|
|
418
|
+
|
|
419
|
+
if not _is_interactive():
|
|
420
|
+
# Non-interactive: bind to default project
|
|
421
|
+
default_proj = next((p for p in items if p.get("is_default")), items[0])
|
|
422
|
+
changed = set_workspace_binding(state, cwd=os.getcwd(), project_id=default_proj["id"])
|
|
423
|
+
if changed:
|
|
424
|
+
save_state(state)
|
|
425
|
+
_patch_project_repo_metadata(
|
|
426
|
+
state,
|
|
427
|
+
key=key,
|
|
428
|
+
org_id=org_id,
|
|
429
|
+
project=default_proj,
|
|
430
|
+
repo_url=repo_url,
|
|
431
|
+
branch=branch,
|
|
432
|
+
)
|
|
433
|
+
print(f"Bound to default project: {default_proj['name']}")
|
|
434
|
+
return 0
|
|
435
|
+
|
|
436
|
+
choice_str = _prompt(f"Select project [1-{len(items) + 1}]: ", "1")
|
|
437
|
+
try:
|
|
438
|
+
choice = int(choice_str)
|
|
439
|
+
except ValueError:
|
|
440
|
+
print("Invalid selection.", file=sys.stderr)
|
|
441
|
+
return 1
|
|
442
|
+
|
|
443
|
+
if choice == len(items) + 1:
|
|
444
|
+
return _create_and_bind(state, key, org_id, workspace_root, repo_url, branch)
|
|
445
|
+
|
|
446
|
+
if not (1 <= choice <= len(items)):
|
|
447
|
+
print("Invalid selection.", file=sys.stderr)
|
|
448
|
+
return 1
|
|
449
|
+
|
|
450
|
+
selected = items[choice - 1]
|
|
451
|
+
changed = set_workspace_binding(state, cwd=os.getcwd(), project_id=selected["id"])
|
|
452
|
+
if changed:
|
|
453
|
+
save_state(state)
|
|
454
|
+
_patch_project_repo_metadata(
|
|
455
|
+
state,
|
|
456
|
+
key=key,
|
|
457
|
+
org_id=org_id,
|
|
458
|
+
project=selected,
|
|
459
|
+
repo_url=repo_url,
|
|
460
|
+
branch=branch,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
print(f"\nBound workspace to project: {selected['name']} ({selected['slug']})")
|
|
464
|
+
print(f" project_id: {selected['id']}")
|
|
465
|
+
print(f" workspace: {workspace_root}")
|
|
466
|
+
print(f"\nAll LLM calls from this directory will now route to this project.")
|
|
467
|
+
return 0
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _create_and_bind(
|
|
471
|
+
state: dict,
|
|
472
|
+
key: str,
|
|
473
|
+
org_id: str,
|
|
474
|
+
workspace_root: str,
|
|
475
|
+
repo_url: str | None,
|
|
476
|
+
branch: str | None,
|
|
477
|
+
) -> int:
|
|
478
|
+
repo_name = None
|
|
479
|
+
if repo_url:
|
|
480
|
+
# Derive a default name from the repo URL (e.g. github.com/org/my-repo -> my-repo)
|
|
481
|
+
repo_name = repo_url.rstrip("/").split("/")[-1]
|
|
482
|
+
if repo_name.endswith(".git"):
|
|
483
|
+
repo_name = repo_name[:-4]
|
|
484
|
+
|
|
485
|
+
default_name = repo_name or Path(workspace_root).name
|
|
486
|
+
if _is_interactive():
|
|
487
|
+
name = _prompt(f"Project name [{default_name}]: ", default_name)
|
|
488
|
+
description = _prompt("Description (optional): ", "")
|
|
489
|
+
else:
|
|
490
|
+
name = default_name
|
|
491
|
+
description = ""
|
|
492
|
+
|
|
493
|
+
name = name.strip() or default_name
|
|
494
|
+
body: dict = {"name": name}
|
|
495
|
+
if description.strip():
|
|
496
|
+
body["description"] = description.strip()
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
project = _api(
|
|
500
|
+
state,
|
|
501
|
+
method="POST",
|
|
502
|
+
path=f"/orgs/{org_id}/projects",
|
|
503
|
+
key=key,
|
|
504
|
+
json_body=body,
|
|
505
|
+
)
|
|
506
|
+
except ValueError as exc:
|
|
507
|
+
print(str(exc), file=sys.stderr)
|
|
508
|
+
return 1
|
|
509
|
+
|
|
510
|
+
changed = set_workspace_binding(state, cwd=os.getcwd(), project_id=project["id"])
|
|
511
|
+
if changed:
|
|
512
|
+
save_state(state)
|
|
513
|
+
_patch_project_repo_metadata(
|
|
514
|
+
state,
|
|
515
|
+
key=key,
|
|
516
|
+
org_id=org_id,
|
|
517
|
+
project=project,
|
|
518
|
+
repo_url=repo_url,
|
|
519
|
+
branch=branch,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
print(f"\nCreated and bound project: {project.get('name')} ({project.get('slug')})")
|
|
523
|
+
print(f" project_id: {project.get('id')}")
|
|
524
|
+
print(f" workspace: {workspace_root}")
|
|
525
|
+
print(f"\nAll LLM calls from this directory will now route to this project.")
|
|
526
|
+
return 0
|