mtrx-cli 0.1.3 → 0.1.5

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",
3
+ "version": "0.1.5",
4
4
  "description": "MATRX CLI for routing Codex and Claude through Matrx",
5
5
  "homepage": "https://mtrx.so",
6
6
  "repository": {
@@ -1 +1 @@
1
- __version__ = "0.1.3"
1
+ __version__ = "0.1.5"
@@ -7,6 +7,7 @@ import os
7
7
  import subprocess
8
8
  import sys
9
9
  import threading
10
+ import time
10
11
  import urllib.parse
11
12
  import webbrowser
12
13
 
@@ -262,6 +263,107 @@ def _complete_codex_login() -> None:
262
263
  raise ValueError("Codex login did not complete successfully")
263
264
 
264
265
 
266
+ def _resolve_matrx_launch_key(state: dict, env: dict[str, str] | None = None) -> str:
267
+ env = env or os.environ
268
+ env_key = (env.get("MTRX_KEY") or "").strip()
269
+ if env_key:
270
+ return env_key
271
+ workspace_binding = get_workspace_binding(state, cwd=env.get("PWD") or os.getcwd()) or {}
272
+ workspace_key = (workspace_binding.get("matrx_key") or "").strip()
273
+ if workspace_key:
274
+ return workspace_key
275
+ return (state.get("auth", {}).get("matrx", {}).get("key") or "").strip()
276
+
277
+
278
+ def _matrx_request_context(state: dict) -> tuple[str, dict[str, str]]:
279
+ mx_key = _resolve_matrx_launch_key(state)
280
+ if not mx_key.startswith("mx_"):
281
+ raise ValueError("Matrx login required before connecting Claude Code")
282
+ base_url = ensure_root_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
283
+ return base_url, {"X-Matrx-Key": mx_key}
284
+
285
+
286
+ def _list_matrx_subscriptions(state: dict) -> list[dict]:
287
+ base_url, headers = _matrx_request_context(state)
288
+ try:
289
+ with httpx.Client(timeout=15) as client:
290
+ response = client.get(f"{base_url.rstrip('/')}/v1/subscriptions", headers=headers)
291
+ response.raise_for_status()
292
+ except httpx.HTTPStatusError as exc:
293
+ detail = exc.response.text.strip() or f"HTTP {exc.response.status_code}"
294
+ raise ValueError(f"Could not query Matrx subscriptions: {detail}") from exc
295
+ except httpx.HTTPError as exc:
296
+ raise ValueError(f"Could not query Matrx subscriptions: {exc}") from exc
297
+
298
+ payload = response.json()
299
+ subscriptions = payload.get("subscriptions")
300
+ if not isinstance(subscriptions, list):
301
+ return []
302
+ return [entry for entry in subscriptions if isinstance(entry, dict)]
303
+
304
+
305
+ def _has_server_claude_subscription(state: dict) -> bool:
306
+ try:
307
+ subscriptions = _list_matrx_subscriptions(state)
308
+ except ValueError:
309
+ return False
310
+ return any((entry.get("provider") or "").strip() == "claude_code" for entry in subscriptions)
311
+
312
+
313
+ def _run_claude_subscription_browser_login(state: dict) -> None:
314
+ base_url, headers = _matrx_request_context(state)
315
+ try:
316
+ with httpx.Client(timeout=15) as client:
317
+ response = client.get(
318
+ f"{base_url.rstrip('/')}/v1/subscriptions/claude-code/authorize",
319
+ headers=headers,
320
+ )
321
+ response.raise_for_status()
322
+ except httpx.HTTPStatusError as exc:
323
+ detail = exc.response.text.strip() or f"HTTP {exc.response.status_code}"
324
+ raise ValueError(f"Could not start Claude Code connection: {detail}") from exc
325
+ except httpx.HTTPError as exc:
326
+ raise ValueError(f"Could not start Claude Code connection: {exc}") from exc
327
+
328
+ payload = response.json()
329
+ auth_url = (payload.get("auth_url") or "").strip()
330
+ if not auth_url:
331
+ raise ValueError("Matrx did not return a Claude Code authorization URL")
332
+
333
+ print(f"Open this URL to connect Claude Code to Matrx:\n {auth_url}")
334
+ webbrowser.open(auth_url)
335
+
336
+ deadline = time.monotonic() + 300
337
+ while time.monotonic() < deadline:
338
+ if _has_server_claude_subscription(state):
339
+ return
340
+ time.sleep(2)
341
+
342
+ raise ValueError("Claude Code connection timed out")
343
+
344
+
345
+ def _complete_claude_login(state: dict) -> tuple[dict, bool]:
346
+ token = (read_claude_oauth_token() or "").strip()
347
+ imported = (state.get("auth", {}).get("claude_code", {}).get("oauth_token") or "").strip()
348
+ if token or imported:
349
+ return state, False
350
+ if _has_server_claude_subscription(state):
351
+ return state, False
352
+ if not _is_interactive_terminal():
353
+ raise ValueError(
354
+ "Claude provider connection required. "
355
+ "Run `mtrx login claude-code --import` or rerun interactively to connect in the browser"
356
+ )
357
+
358
+ print("Claude provider connection required.")
359
+ if not _prompt_yes_no("Open the browser-based Claude Code connection flow now?", default=True):
360
+ raise ValueError("Claude connection cancelled")
361
+
362
+ _run_claude_subscription_browser_login(state)
363
+
364
+ return state, False
365
+
366
+
265
367
  def _maybe_promote_direct_route(state: dict, tool: str, route: str | None) -> tuple[str | None, bool]:
266
368
  if route is not None:
267
369
  return route, False
@@ -290,7 +392,11 @@ def _cmd_login(args) -> int:
290
392
  token = read_claude_oauth_token()
291
393
  if not token:
292
394
  print(
293
- f"No Claude OAuth token found at {claude_credentials_path()}",
395
+ (
396
+ f"No Claude OAuth token found at {claude_credentials_path()}. "
397
+ "If your Claude install no longer writes that file, use the browser-based "
398
+ "`mtrx claude` flow instead, or run `claude setup-token` and import that token."
399
+ ),
294
400
  file=sys.stderr,
295
401
  )
296
402
  return 1
@@ -569,6 +675,9 @@ def _cmd_launch(tool: str, route: str | None, remainder: list[str]) -> int:
569
675
  auth_changed = auth_changed or login_changed
570
676
  if tool == "codex":
571
677
  _complete_codex_login()
678
+ if tool == "claude":
679
+ state, login_changed = _complete_claude_login(state)
680
+ auth_changed = auth_changed or login_changed
572
681
  initialized = initialize_first_launch_route(state, tool, route)
573
682
  state, changed = prepare_routed_setup(
574
683
  state,