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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtrx-cli",
3
- "version": "0.1.21",
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": {
@@ -1 +1 @@
1
- __version__ = "0.1.21"
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
- # Assuming we might store Gemini-specific keys in future, or use OpenAI key fallback
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
- # Clear existing Gemini config to force proxy usage
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
- if ctx_params:
617
- proxy_base_with_ctx = f"{proxy_base}?{'&'.join(ctx_params)}"
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
- # Set Proxy Config
620
- env["GOOGLE_GEMINI_BASE_URL"] = proxy_base_with_ctx
621
- env["GEMINI_API_ENDPOINT"] = proxy_base_with_ctx
622
- env["GOOGLE_API_KEY"] = mx_key
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
- # Clear proxy overrides
630
- _clear_if_matches(env, "GOOGLE_GEMINI_BASE_URL", proxy_base)
631
- _clear_if_matches(env, "GEMINI_API_ENDPOINT", proxy_base)
632
-
633
- # Clear key if it was the Matrx key
634
- current_key = (env.get("GOOGLE_API_KEY") or "").strip()
635
- if current_key == mx_key or current_key.startswith("mx_"):
636
- env.pop("GOOGLE_API_KEY", None)
637
-
638
- if env.get("GOOGLE_API_KEY"):
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 or GEMINI_API_ENDPOINT")
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 = expected_base_url.rstrip("/")
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 /v1 base URL. "
954
+ "Gemini Matrx route must use the Matrx Gemini-native base URL. "
923
955
  f"Got: {base_url}"
924
956
  )
925
957
 
926
- mx_key = (plan.env.get("GOOGLE_API_KEY") or "").strip()
927
- if not mx_key.startswith("mx_"):
928
- raise ValueError("Gemini Matrx route is missing a valid GOOGLE_API_KEY (should be mx_...)")
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" base_url: {base_url}",
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