mtrx-cli 0.1.22 → 0.1.24

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.24",
4
4
  "description": "MATRX CLI for routing Codex, Claude, and Cursor through Matrx",
5
5
  "homepage": "https://mtrx.so",
6
6
  "repository": {
@@ -33,8 +33,11 @@
33
33
  "src/matrx/cli/cursor_proxy.py",
34
34
  "src/matrx/cli/cursor_reroute.py",
35
35
  "src/matrx/cli/cursor_service.py",
36
+ "src/matrx/cli/bootstrap.py",
37
+ "src/matrx/cli/gemini_env_bootstrap.cjs",
36
38
  "src/matrx/cli/launcher.py",
37
39
  "src/matrx/cli/main.py",
40
+ "src/matrx/cli/project_cmds.py",
38
41
  "src/matrx/cli/state.py"
39
42
  ],
40
43
  "engines": {
@@ -1 +1 @@
1
- __version__ = "0.1.22"
1
+ __version__ = "0.1.24"
@@ -0,0 +1,119 @@
1
+ """
2
+ Bootstrap command — warms the system registry for an existing project.
3
+ Called by `mtrx init`.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ _DEFAULT_SYSTEMS_TEMPLATE = [
12
+ {
13
+ "id": "memory",
14
+ "name": "Memory System",
15
+ "description": "Memory flywheel: extract, store, retrieve, inject",
16
+ "file_patterns": ["core/memory.py", "services/memory.py", "core/extractor.py", "core/profile_builder.py"]
17
+ },
18
+ {
19
+ "id": "proxy",
20
+ "name": "Proxy Service",
21
+ "description": "Intercepts all LLM calls, applies compression + injection",
22
+ "file_patterns": ["services/proxy.py", "core/compressor.py", "core/summarizer.py"]
23
+ },
24
+ {
25
+ "id": "auth",
26
+ "name": "Auth & Multi-Org",
27
+ "description": "Clerk JWT validation, org/project scoping, API keys",
28
+ "file_patterns": ["middleware/auth.py", "api/auth.py", "api/orgs.py", "models/org_member.py"]
29
+ },
30
+ {
31
+ "id": "analytics",
32
+ "name": "Analytics",
33
+ "description": "Usage snapshots, memory hit rate, token tracking",
34
+ "file_patterns": ["services/analytics.py", "api/analytics.py"]
35
+ },
36
+ ]
37
+
38
+
39
+ def run_init(project_root: str = ".") -> None:
40
+ """Entry point for `mtrx init`."""
41
+ root = Path(project_root).resolve()
42
+ print(f"Matrx init: analyzing {root}")
43
+
44
+ # Step 1: Load or create .matrx/systems.json
45
+ matrx_dir = root / ".matrx"
46
+ matrx_dir.mkdir(exist_ok=True)
47
+ systems_path = matrx_dir / "systems.json"
48
+
49
+ if not systems_path.exists():
50
+ _seed_systems_json(root, systems_path)
51
+ print(f" Created {systems_path}")
52
+ else:
53
+ print(f" Found existing {systems_path}")
54
+
55
+ # Step 2: Analyze git log for hot systems
56
+ hot_files = _get_hot_files(root, days=30)
57
+ print(f" Found {len(hot_files)} recently modified files")
58
+
59
+ # Step 3: Map to systems
60
+ systems = json.loads(systems_path.read_text()).get("systems", [])
61
+ hot_systems = _rank_systems_by_activity(hot_files, systems)
62
+
63
+ if hot_systems:
64
+ print("\n Active systems detected:")
65
+ for sys_id, count, depth in hot_systems:
66
+ meta = next((s for s in systems if s["id"] == sys_id), None)
67
+ if meta:
68
+ print(f" {meta['name']:<25} {count:>3} touches → {depth} card")
69
+ else:
70
+ print("\n No recently active systems detected (no git history or no matches).")
71
+
72
+ print(f"\n Matrx will generate cards for detected systems on first use.")
73
+ print(f" Run your agent — cards will be ready within the first few calls.\n")
74
+ print(" Done.")
75
+
76
+
77
+ def _get_hot_files(root: Path, days: int = 30) -> dict[str, int]:
78
+ """Parse git log, return file → touch count mapping."""
79
+ try:
80
+ result = subprocess.run(
81
+ ["git", "log", f"--since={days} days ago", "--name-only", "--pretty=format:"],
82
+ cwd=root, capture_output=True, text=True, timeout=10,
83
+ )
84
+ counts: dict[str, int] = {}
85
+ for line in result.stdout.splitlines():
86
+ line = line.strip()
87
+ if line and not line.startswith("commit"):
88
+ counts[line] = counts.get(line, 0) + 1
89
+ return counts
90
+ except Exception:
91
+ return {}
92
+
93
+
94
+ def _rank_systems_by_activity(
95
+ hot_files: dict[str, int], systems: list[dict]
96
+ ) -> list[tuple[str, int, str]]:
97
+ """Return [(system_id, touch_count, depth)] sorted by activity."""
98
+ ranked = []
99
+ for s in systems:
100
+ patterns = s.get("file_patterns", [])
101
+ total = sum(
102
+ count for f, count in hot_files.items()
103
+ if any(pat in f or f.endswith(pat) for pat in patterns)
104
+ )
105
+ if total > 0:
106
+ ranked.append((s["id"], total, "standard"))
107
+
108
+ ranked.sort(key=lambda x: x[1], reverse=True)
109
+ # Re-assign depths by rank
110
+ result = []
111
+ for i, (sid, count, _) in enumerate(ranked):
112
+ depth = "full" if i < 3 else ("standard" if i < 8 else "distilled")
113
+ result.append((sid, count, depth))
114
+ return result
115
+
116
+
117
+ def _seed_systems_json(root: Path, out_path: Path) -> None:
118
+ """Write default systems.json. Users can hand-edit afterward."""
119
+ out_path.write_text(json.dumps({"systems": _DEFAULT_SYSTEMS_TEMPLATE}, indent=2))
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+
3
+ function decodeBase64Env(name) {
4
+ const value = process.env[name];
5
+ if (!value) {
6
+ return undefined;
7
+ }
8
+ try {
9
+ return Buffer.from(value, "base64").toString("utf8");
10
+ } catch {
11
+ return undefined;
12
+ }
13
+ }
14
+
15
+ const envMappings = [
16
+ ["MTRX_GEMINI_CUSTOM_HEADERS_B64", "GEMINI_CLI_CUSTOM_HEADERS"],
17
+ ["MTRX_CODE_ASSIST_ENDPOINT_B64", "CODE_ASSIST_ENDPOINT"],
18
+ ["MTRX_GEMINI_API_ENDPOINT_B64", "GEMINI_API_ENDPOINT"],
19
+ ];
20
+
21
+ for (const [sourceName, targetName] of envMappings) {
22
+ const decoded = decodeBase64Env(sourceName);
23
+ if (decoded && !process.env[targetName]) {
24
+ process.env[targetName] = decoded;
25
+ }
26
+ }
27
+
28
+ if (
29
+ process.env.MTRX_GEMINI_API_KEY_AUTH_MECHANISM &&
30
+ !process.env.GEMINI_API_KEY_AUTH_MECHANISM
31
+ ) {
32
+ process.env.GEMINI_API_KEY_AUTH_MECHANISM =
33
+ process.env.MTRX_GEMINI_API_KEY_AUTH_MECHANISM;
34
+ }
@@ -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"}
@@ -91,6 +95,46 @@ def _runtime_agent_basename(tool: str) -> tuple[str, str, list[str], str]:
91
95
  return normalized, f"{tool.capitalize()} CLI", ["cli", tool], tool
92
96
 
93
97
 
98
+ def _append_sandbox_env(env: dict[str, str], key: str, value: str | None) -> None:
99
+ if not value:
100
+ return
101
+ entry = f"{key}={value}"
102
+ existing = [item.strip() for item in (env.get("SANDBOX_ENV") or "").split(",") if item.strip()]
103
+ filtered = [item for item in existing if not item.startswith(f"{key}=")]
104
+ filtered.append(entry)
105
+ env["SANDBOX_ENV"] = ",".join(filtered)
106
+
107
+
108
+ def _remove_sandbox_env_keys(env: dict[str, str], keys: tuple[str, ...]) -> None:
109
+ existing = [item.strip() for item in (env.get("SANDBOX_ENV") or "").split(",") if item.strip()]
110
+ filtered = [
111
+ item for item in existing
112
+ if not any(item.startswith(f"{key}=") for key in keys)
113
+ ]
114
+ if filtered:
115
+ env["SANDBOX_ENV"] = ",".join(filtered)
116
+ else:
117
+ env.pop("SANDBOX_ENV", None)
118
+
119
+
120
+ def _ensure_node_require(env: dict[str, str], script_path: str) -> None:
121
+ require_flag = f"--require={script_path}"
122
+ existing = (env.get("NODE_OPTIONS") or "").strip()
123
+ if require_flag in existing.split():
124
+ return
125
+ env["NODE_OPTIONS"] = f"{existing} {require_flag}".strip()
126
+
127
+
128
+ def _remove_node_require(env: dict[str, str], script_path: str) -> None:
129
+ require_flag = f"--require={script_path}"
130
+ existing = (env.get("NODE_OPTIONS") or "").split()
131
+ filtered = [part for part in existing if part != require_flag]
132
+ if filtered:
133
+ env["NODE_OPTIONS"] = " ".join(filtered)
134
+ else:
135
+ env.pop("NODE_OPTIONS", None)
136
+
137
+
94
138
  def configured_route(state: dict, tool: str) -> str | None:
95
139
  route = state.get("defaults", {}).get(tool)
96
140
  if route in VALID_ROUTES:
@@ -463,6 +507,20 @@ def _capture_git_context(cwd: str | None = None) -> tuple[str, str]:
463
507
  return branch, commit
464
508
 
465
509
 
510
+ def _capture_git_remote_url(cwd: str | None = None) -> str:
511
+ root = cwd or os.getcwd()
512
+ try:
513
+ r = subprocess.run(
514
+ ["git", "-C", root, "remote", "get-url", "origin"],
515
+ capture_output=True, text=True, timeout=2, check=False,
516
+ )
517
+ if r.returncode == 0:
518
+ return r.stdout.strip()
519
+ except (OSError, subprocess.SubprocessError):
520
+ pass
521
+ return ""
522
+
523
+
466
524
  def _resolve_matrx_context_overrides(
467
525
  state: dict,
468
526
  env: dict[str, str],
@@ -511,6 +569,8 @@ def _build_codex_env(
511
569
  if route == "matrx":
512
570
  if not mx_key:
513
571
  raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
572
+ for key in _PROXY_ENV_KEYS:
573
+ env.pop(key, None)
514
574
  provider_bearer = env_openai_key or direct_key or read_codex_access_token()
515
575
  if not provider_bearer:
516
576
  raise ValueError(
@@ -538,10 +598,13 @@ def _build_codex_env(
538
598
  if project_id:
539
599
  header_parts.append(f'"X-Matrx-Project-Id" = "{project_id}"')
540
600
  _git_branch, _git_commit = _capture_git_context(_workspace_cwd(env))
601
+ _git_repo_url = _capture_git_remote_url(_workspace_cwd(env))
541
602
  if _git_branch:
542
603
  header_parts.append(f'"X-Matrx-Branch" = "{_git_branch}"')
543
604
  if _git_commit:
544
605
  header_parts.append(f'"X-Matrx-Commit" = "{_git_commit}"')
606
+ if _git_repo_url:
607
+ header_parts.append(f'"X-Matrx-Repo-Url" = "{_git_repo_url}"')
545
608
  if env_b64:
546
609
  header_parts.append(f'"X-Matrx-Env" = "{env_b64}"')
547
610
  headers_str = ", ".join(header_parts)
@@ -583,10 +646,13 @@ def _build_gemini_env(
583
646
  proxy_base = ensure_v1_url(matrx.get("base_url"))
584
647
  mx_key, matrx_auth_source = _resolve_matrx_route_key(state, env)
585
648
  direct_key = (env.get("GEMINI_API_KEY") or env.get("GOOGLE_API_KEY") or "").strip()
649
+ bootstrap_script = str(Path(__file__).with_name("gemini_env_bootstrap.cjs").resolve())
586
650
 
587
651
  if route == "matrx":
588
652
  if not mx_key:
589
653
  raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
654
+ for key in _PROXY_ENV_KEYS:
655
+ env.pop(key, None)
590
656
  env.pop("MTRX_KEY", None)
591
657
  group_id, project_id = _resolve_matrx_context_overrides(state, env)
592
658
  session_id = str(uuid.uuid4())
@@ -602,6 +668,7 @@ def _build_gemini_env(
602
668
  if runtime_agent_id:
603
669
  ctx_params.append(f"mtrx_agent={runtime_agent_id}")
604
670
  git_branch, git_commit = _capture_git_context(_workspace_cwd(env))
671
+ git_repo_url = _capture_git_remote_url(_workspace_cwd(env))
605
672
  if git_branch:
606
673
  ctx_params.append(f"mtrx_branch={git_branch}")
607
674
  if git_commit:
@@ -624,6 +691,8 @@ def _build_gemini_env(
624
691
  custom_headers.append(f"x-matrx-branch: {git_branch}")
625
692
  if git_commit:
626
693
  custom_headers.append(f"x-matrx-commit: {git_commit}")
694
+ if git_repo_url:
695
+ custom_headers.append(f"x-matrx-repo-url: {git_repo_url}")
627
696
  if env_b64:
628
697
  custom_headers.append(f"x-matrx-env: {env_b64}")
629
698
 
@@ -633,6 +702,27 @@ def _build_gemini_env(
633
702
  env["CODE_ASSIST_ENDPOINT"] = proxy_base
634
703
  env["GEMINI_CLI_CUSTOM_HEADERS"] = ", ".join(custom_headers)
635
704
  env["GEMINI_API_KEY_AUTH_MECHANISM"] = "bearer"
705
+ _ensure_node_require(env, bootstrap_script)
706
+ _append_sandbox_env(
707
+ env,
708
+ "MTRX_GEMINI_CUSTOM_HEADERS_B64",
709
+ base64.b64encode(env["GEMINI_CLI_CUSTOM_HEADERS"].encode("utf-8")).decode("ascii"),
710
+ )
711
+ _append_sandbox_env(
712
+ env,
713
+ "MTRX_CODE_ASSIST_ENDPOINT_B64",
714
+ base64.b64encode(env["CODE_ASSIST_ENDPOINT"].encode("utf-8")).decode("ascii"),
715
+ )
716
+ _append_sandbox_env(
717
+ env,
718
+ "MTRX_GEMINI_API_ENDPOINT_B64",
719
+ base64.b64encode(env["GEMINI_API_ENDPOINT"].encode("utf-8")).decode("ascii"),
720
+ )
721
+ _append_sandbox_env(
722
+ env,
723
+ "MTRX_GEMINI_API_KEY_AUTH_MECHANISM",
724
+ env["GEMINI_API_KEY_AUTH_MECHANISM"],
725
+ )
636
726
 
637
727
  return env, matrx_auth_source
638
728
 
@@ -647,6 +737,16 @@ def _build_gemini_env(
647
737
  value = (env.get(key) or "").strip()
648
738
  if "matrx" in value.lower() or "mtrx.so" in value.lower():
649
739
  env.pop(key, None)
740
+ _remove_node_require(env, bootstrap_script)
741
+ _remove_sandbox_env_keys(
742
+ env,
743
+ (
744
+ "MTRX_GEMINI_CUSTOM_HEADERS_B64",
745
+ "MTRX_CODE_ASSIST_ENDPOINT_B64",
746
+ "MTRX_GEMINI_API_ENDPOINT_B64",
747
+ "MTRX_GEMINI_API_KEY_AUTH_MECHANISM",
748
+ ),
749
+ )
650
750
 
651
751
  custom_headers = (env.get("GEMINI_CLI_CUSTOM_HEADERS") or "").strip().lower()
652
752
  if "x-matrx-" in custom_headers:
@@ -680,6 +780,8 @@ def _build_claude_env(
680
780
  if route == "matrx":
681
781
  if not mx_key:
682
782
  raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
783
+ for key in _PROXY_ENV_KEYS:
784
+ env.pop(key, None)
683
785
  env.pop("MTRX_KEY", None)
684
786
  env.pop("MATRX_CLAUDE_MODE", None)
685
787
  env["MATRX_BASE_URL"] = proxy_root
@@ -708,10 +810,13 @@ def _build_claude_env(
708
810
  if project_id:
709
811
  custom_headers += f"\nx-matrx-project-id: {project_id}"
710
812
  _git_branch, _git_commit = _capture_git_context(_workspace_cwd(env))
813
+ _git_repo_url = _capture_git_remote_url(_workspace_cwd(env))
711
814
  if _git_branch:
712
815
  custom_headers += f"\nx-matrx-branch: {_git_branch}"
713
816
  if _git_commit:
714
817
  custom_headers += f"\nx-matrx-commit: {_git_commit}"
818
+ if _git_repo_url:
819
+ custom_headers += f"\nx-matrx-repo-url: {_git_repo_url}"
715
820
  if env_b64:
716
821
  custom_headers += f"\nx-matrx-env: {env_b64}"
717
822
  env["ANTHROPIC_CUSTOM_HEADERS"] = custom_headers
@@ -963,6 +1068,12 @@ def _validate_gemini_launch_plan(plan: LaunchPlan, state: dict) -> None:
963
1068
  if "x-matrx-agent-id:" not in custom_headers:
964
1069
  raise ValueError("Gemini Matrx route is missing GEMINI_CLI_CUSTOM_HEADERS with X-Matrx-Agent-Id")
965
1070
 
1071
+ sandbox_env = (plan.env.get("SANDBOX_ENV") or "").strip()
1072
+ if "MTRX_GEMINI_CUSTOM_HEADERS_B64=" not in sandbox_env:
1073
+ raise ValueError("Gemini Matrx route is missing sandbox bootstrap for GEMINI_CLI_CUSTOM_HEADERS")
1074
+ if "MTRX_CODE_ASSIST_ENDPOINT_B64=" not in sandbox_env:
1075
+ raise ValueError("Gemini Matrx route is missing sandbox bootstrap for CODE_ASSIST_ENDPOINT")
1076
+
966
1077
 
967
1078
  def _validate_codex_launch_plan(plan: LaunchPlan, state: dict) -> None:
968
1079
  if plan.route != "matrx":
@@ -144,7 +144,7 @@ def _build_parser() -> argparse.ArgumentParser:
144
144
  cursor.add_argument(
145
145
  "--launch",
146
146
  action="store_true",
147
- help="Launch Cursor with proxy env (required for traffic to flow)",
147
+ help="Launch Cursor after applying the current Matrx settings",
148
148
  )
149
149
 
150
150
  return parser
@@ -932,6 +932,7 @@ def _cmd_launch(tool: str, route: str | None, remainder: list[str]) -> int:
932
932
 
933
933
  def _cmd_cursor(args) -> int:
934
934
  from matrx.cli.cursor_hooks import install_mtrx_hooks, is_mtrx_hooks_installed
935
+ from matrx.cli.cursor_service import is_proxy_running
935
936
  from matrx.cli.cursor_launcher import find_cursor_executable
936
937
 
937
938
  route = args.route
@@ -942,10 +943,13 @@ def _cmd_cursor(args) -> int:
942
943
  hooks_installed = is_mtrx_hooks_installed()
943
944
  base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
944
945
  prev_path = config_dir() / "cursor-previous-settings.json"
946
+ legacy_proxy_prev_path = config_dir() / "cursor-proxy-previous-settings.json"
945
947
  configured = prev_path.exists()
948
+ legacy_proxy_active = is_proxy_running() or legacy_proxy_prev_path.exists()
946
949
  print("MTRX Cursor integration:")
947
950
  print(f" mode: {'Base URL override (all models)' if configured else 'not configured'}")
948
951
  print(f" hooks: {'active (sessionEnd, stop → telemetry)' if hooks_installed else 'not installed'}")
952
+ print(f" legacy MITM proxy: {'active' if legacy_proxy_active else 'not active'}")
949
953
  if configured:
950
954
  print(f" matrx: {base_url}")
951
955
  return 0
@@ -1001,6 +1005,11 @@ def _cmd_cursor(args) -> int:
1001
1005
  "Use `mtrx use cursor direct` to opt out.",
1002
1006
  )
1003
1007
 
1008
+ # Ensure legacy MITM routing is fully torn down before enabling the
1009
+ # current Cursor base-URL override flow. Leaving both active causes
1010
+ # Cursor traffic to keep flowing through the old telemetry proxy.
1011
+ _restore_cursor_if_needed()
1012
+
1004
1013
  # Configure Cursor's Override Base URL — sends chat to MTRX (any model: Claude, GPT-5, Gemini, etc.)
1005
1014
  prev_path = config_dir() / "cursor-previous-settings.json"
1006
1015
  previous = configure_cursor_for_proxy(matrx_proxy_url, mx_key)
@@ -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