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.
|
|
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": {
|
package/src/matrx/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.1.
|
|
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":
|
package/src/matrx/cli/main.py
CHANGED
|
@@ -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
|
|
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
|