mtrx-cli 0.1.22 → 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.22",
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.22"
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(
@@ -587,6 +593,8 @@ def _build_gemini_env(
587
593
  if route == "matrx":
588
594
  if not mx_key:
589
595
  raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
596
+ for key in _PROXY_ENV_KEYS:
597
+ env.pop(key, None)
590
598
  env.pop("MTRX_KEY", None)
591
599
  group_id, project_id = _resolve_matrx_context_overrides(state, env)
592
600
  session_id = str(uuid.uuid4())
@@ -680,6 +688,8 @@ def _build_claude_env(
680
688
  if route == "matrx":
681
689
  if not mx_key:
682
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)
683
693
  env.pop("MTRX_KEY", None)
684
694
  env.pop("MATRX_CLAUDE_MODE", None)
685
695
  env["MATRX_BASE_URL"] = proxy_root
@@ -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