mtrx-cli 0.1.1 → 0.1.2

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.1",
3
+ "version": "0.1.2",
4
4
  "description": "MATRX CLI for routing Codex and Claude through Matrx",
5
5
  "homepage": "https://mtrx.so",
6
6
  "repository": {
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import base64
4
4
  import hashlib
5
5
  import datetime as dt
6
+ import httpx
6
7
  import json
7
8
  import os
8
9
  import platform
@@ -169,6 +170,12 @@ def prepare_routed_setup(
169
170
  changed = True
170
171
  if route == "matrx" and _persist_workspace_binding_from_env(state, env):
171
172
  changed = True
173
+ if route == "matrx" and tool == "claude":
174
+ mx_key, _ = _resolve_matrx_route_key(state, env)
175
+ if _approve_claude_custom_api_key(mx_key):
176
+ changed = True
177
+ if _sync_claude_subscription_to_matrx(state, env):
178
+ changed = True
172
179
 
173
180
  if _sync_tool_route_config(state, tool=tool, route=route):
174
181
  changed = True
@@ -200,6 +207,10 @@ def claude_settings_path() -> Path:
200
207
  return Path.home() / ".claude" / "settings.json"
201
208
 
202
209
 
210
+ def claude_state_path() -> Path:
211
+ return Path.home() / ".claude.json"
212
+
213
+
203
214
  def codex_config_path() -> Path:
204
215
  return Path.home() / ".codex" / "config.toml"
205
216
 
@@ -247,6 +258,102 @@ def read_codex_access_token() -> str | None:
247
258
  return token.strip()
248
259
 
249
260
 
261
+ def _read_claude_app_state() -> dict:
262
+ path = claude_state_path()
263
+ if not path.exists():
264
+ return {}
265
+ try:
266
+ data = json.loads(path.read_text(encoding="utf-8"))
267
+ except (json.JSONDecodeError, OSError):
268
+ return {}
269
+ return data if isinstance(data, dict) else {}
270
+
271
+
272
+ def _write_claude_app_state(data: dict) -> None:
273
+ path = claude_state_path()
274
+ path.parent.mkdir(parents=True, exist_ok=True)
275
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
276
+
277
+
278
+ def _approve_claude_custom_api_key(raw_key: str) -> bool:
279
+ if not raw_key:
280
+ return False
281
+
282
+ state = _read_claude_app_state()
283
+ custom = state.setdefault("customApiKeyResponses", {})
284
+ approved = custom.get("approved")
285
+ rejected = custom.get("rejected")
286
+ if not isinstance(approved, list):
287
+ approved = []
288
+ if not isinstance(rejected, list):
289
+ rejected = []
290
+
291
+ changed = False
292
+ if raw_key not in approved:
293
+ approved.append(raw_key)
294
+ changed = True
295
+ if raw_key in rejected:
296
+ rejected = [value for value in rejected if value != raw_key]
297
+ changed = True
298
+
299
+ if not changed:
300
+ return False
301
+
302
+ custom["approved"] = approved
303
+ custom["rejected"] = rejected
304
+ _write_claude_app_state(state)
305
+ return True
306
+
307
+
308
+ def _resolve_claude_subscription_token(state: dict) -> str:
309
+ token = (read_claude_oauth_token() or "").strip()
310
+ if token:
311
+ return token
312
+ return (state.get("auth", {}).get("claude_code", {}).get("oauth_token") or "").strip()
313
+
314
+
315
+ def _sync_claude_subscription_to_matrx(state: dict, env: dict[str, str]) -> bool:
316
+ token = _resolve_claude_subscription_token(state)
317
+ if not token:
318
+ return False
319
+
320
+ mx_key, _ = _resolve_matrx_route_key(state, env)
321
+ if not mx_key:
322
+ return False
323
+
324
+ auth_cfg = state.setdefault("auth", {}).setdefault("claude_code", {})
325
+ token_fp = _fingerprint_secret(token)
326
+ key_fp = _fingerprint_secret(mx_key)
327
+ base_url = ensure_root_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
328
+
329
+ if (
330
+ auth_cfg.get("server_synced_token_fingerprint") == token_fp
331
+ and auth_cfg.get("server_synced_matrx_key_fingerprint") == key_fp
332
+ and auth_cfg.get("server_synced_base_url") == base_url
333
+ ):
334
+ return False
335
+
336
+ try:
337
+ with httpx.Client(timeout=15) as client:
338
+ response = client.post(
339
+ f"{base_url.rstrip('/')}/v1/subscriptions/claude-code",
340
+ headers={
341
+ "X-Matrx-Key": mx_key,
342
+ "Content-Type": "application/json",
343
+ },
344
+ json={"token": token},
345
+ )
346
+ response.raise_for_status()
347
+ except httpx.HTTPError:
348
+ return False
349
+
350
+ auth_cfg["server_synced_token_fingerprint"] = token_fp
351
+ auth_cfg["server_synced_matrx_key_fingerprint"] = key_fp
352
+ auth_cfg["server_synced_base_url"] = base_url
353
+ auth_cfg["server_synced_at"] = dt.datetime.now(dt.timezone.utc).isoformat()
354
+ return True
355
+
356
+
250
357
  def _resolve_matrx_route_key(
251
358
  state: dict,
252
359
  env: dict[str, str],
@@ -372,20 +479,20 @@ def _build_claude_env(
372
479
  ) -> tuple[dict[str, str], str]:
373
480
  matrx = state["auth"]["matrx"]
374
481
  anthropic = state["auth"]["anthropic"]
375
- # Claude Code gateway uses the root URL, not a /v1-suffixed base.
376
- proxy_base = ensure_root_url(matrx.get("base_url"))
482
+ proxy_root = ensure_root_url(matrx.get("base_url"))
483
+ proxy_base = ensure_v1_url(matrx.get("base_url"))
377
484
  mx_key, matrx_auth_source = _resolve_matrx_route_key(state, env)
378
485
  direct_key = (anthropic.get("key") or "").strip()
379
- oauth_mode = _claude_effective_oauth_mode(state, env)
380
486
 
381
487
  if route == "matrx":
382
488
  if not mx_key:
383
489
  raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
384
490
  env.pop("MTRX_KEY", None)
385
- env["MATRX_BASE_URL"] = proxy_base
491
+ env.pop("MATRX_CLAUDE_MODE", None)
492
+ env["MATRX_BASE_URL"] = proxy_root
386
493
  env["MATRX_API_KEY"] = mx_key
387
- # Claude Code gateway uses root URL, not /v1
388
494
  env["ANTHROPIC_BASE_URL"] = proxy_base
495
+ env["ANTHROPIC_API_KEY"] = mx_key
389
496
  group_id, project_id = _resolve_matrx_context_overrides(state, env)
390
497
  session_id = str(uuid.uuid4())
391
498
  # Evolutionary scaffolding: env snapshot for AI context injection
@@ -393,7 +500,6 @@ def _build_claude_env(
393
500
  env_b64 = base64.b64encode(json.dumps(env_snap).encode()).decode() if env_snap else ""
394
501
  custom_headers = "\n".join(
395
502
  [
396
- f"x-matrx-key: {mx_key}",
397
503
  "x-matrx-agent-id: claude-cli",
398
504
  "x-matrx-provider: claude_code",
399
505
  f"x-matrx-session-id: {session_id}",
@@ -405,33 +511,34 @@ def _build_claude_env(
405
511
  custom_headers += f"\nx-matrx-project-id: {project_id}"
406
512
  if env_b64:
407
513
  custom_headers += f"\nx-matrx-env: {env_b64}"
408
- # Always send the matrx key via ANTHROPIC_CUSTOM_HEADERS so it arrives on
409
- # the dedicated X-Matrx-Key header. This keeps the Authorization slot free
410
- # for the real Anthropic credential (OAuth token or API key) so the proxy can
411
- # forward it to Anthropic without confusing the matrx key with a provider key.
412
514
  env["ANTHROPIC_CUSTOM_HEADERS"] = custom_headers
413
515
  env.pop("ANTHROPIC_AUTH_TOKEN", None)
414
- if oauth_mode:
415
- # OAuth token flows through natively via Authorization: Bearer sk-ant-oat01-*
416
- env.pop("ANTHROPIC_API_KEY", None)
417
- else:
418
- # Non-OAuth: if a saved Anthropic API key is available, set it so the SDK
419
- # sends x-api-key to the proxy and the proxy can forward it upstream.
420
- # If no key is saved, the proxy will fall back to the org vault.
421
- if direct_key:
422
- env["ANTHROPIC_API_KEY"] = direct_key
423
- else:
424
- env.pop("ANTHROPIC_API_KEY", None)
425
516
  return env, matrx_auth_source
426
517
 
427
518
  # Direct route: clear any matrx-managed env vars
428
519
  env.pop("MTRX_KEY", None)
429
- _clear_if_matches(env, "ANTHROPIC_BASE_URL", proxy_base)
430
- _clear_if_matches(env, "ANTHROPIC_API_KEY", mx_key)
431
- _clear_if_matches(env, "ANTHROPIC_AUTH_TOKEN", mx_key)
432
- custom_headers = env.get("ANTHROPIC_CUSTOM_HEADERS", "")
433
- if mx_key and mx_key in custom_headers:
520
+ env.pop("MATRX_CLAUDE_MODE", None)
521
+ env.pop("MATRX_BASE_URL", None)
522
+ env.pop("MATRX_API_KEY", None)
523
+ for candidate in {proxy_root, proxy_base}:
524
+ _clear_if_matches(env, "ANTHROPIC_BASE_URL", candidate)
525
+
526
+ anthropic_api_key = (env.get("ANTHROPIC_API_KEY") or "").strip()
527
+ if anthropic_api_key.startswith("mx_") or anthropic_api_key == mx_key:
528
+ env.pop("ANTHROPIC_API_KEY", None)
529
+
530
+ anthropic_auth_token = (env.get("ANTHROPIC_AUTH_TOKEN") or "").strip()
531
+ if (
532
+ anthropic_auth_token == mx_key
533
+ or anthropic_auth_token.startswith("mx_")
534
+ or anthropic_auth_token.lower().startswith("bearer mx_")
535
+ ):
536
+ env.pop("ANTHROPIC_AUTH_TOKEN", None)
537
+
538
+ custom_headers = (env.get("ANTHROPIC_CUSTOM_HEADERS") or "").strip()
539
+ if "x-matrx-" in custom_headers.lower():
434
540
  env.pop("ANTHROPIC_CUSTOM_HEADERS", None)
541
+
435
542
  if claude_oauth_available():
436
543
  return env, "local_claude_oauth"
437
544
  if env.get("ANTHROPIC_API_KEY"):
@@ -451,11 +558,12 @@ def _validate_claude_launch_plan(plan: LaunchPlan, state: dict) -> None:
451
558
  return
452
559
 
453
560
  base_url = (plan.env.get("ANTHROPIC_BASE_URL") or "").strip()
561
+ expected_base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
454
562
  if not base_url:
455
563
  raise ValueError("Claude Matrx route is missing ANTHROPIC_BASE_URL")
456
- if base_url.endswith("/v1"):
564
+ if base_url != expected_base_url:
457
565
  raise ValueError(
458
- "Claude Matrx route must use the gateway root base URL, not a /v1 URL. "
566
+ "Claude Matrx route must use the Matrx /v1 base URL. "
459
567
  f"Got: {base_url}"
460
568
  )
461
569
 
@@ -463,24 +571,24 @@ def _validate_claude_launch_plan(plan: LaunchPlan, state: dict) -> None:
463
571
  if not mx_key.startswith("mx_"):
464
572
  raise ValueError("Claude Matrx route is missing a valid MATRX_API_KEY")
465
573
 
466
- # All routes use ANTHROPIC_CUSTOM_HEADERS for matrx key delivery so that
467
- # the Authorization slot remains free for the real Anthropic credential.
574
+ anthropic_key = (plan.env.get("ANTHROPIC_API_KEY") or "").strip()
575
+ if anthropic_key != mx_key:
576
+ raise ValueError("Claude Matrx route must set ANTHROPIC_API_KEY to the same mx_ key as MATRX_API_KEY")
577
+
468
578
  custom_headers = (plan.env.get("ANTHROPIC_CUSTOM_HEADERS") or "").strip()
469
- if not custom_headers or mx_key not in custom_headers:
470
- raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with the Matrx key")
579
+ if not custom_headers:
580
+ raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS")
471
581
  lowered_headers = custom_headers.lower()
472
582
  if "x-matrx-session-id:" not in lowered_headers:
473
583
  raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with X-Matrx-Session-Id")
474
584
  if "x-matrx-provider: claude_code" not in lowered_headers:
475
585
  raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with X-Matrx-Provider=claude_code")
586
+ if "x-matrx-agent-id: claude-cli" not in lowered_headers:
587
+ raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with X-Matrx-Agent-Id=claude-cli")
476
588
 
477
589
  if plan.env.get("ANTHROPIC_AUTH_TOKEN"):
478
590
  raise ValueError("Claude Matrx route should not set ANTHROPIC_AUTH_TOKEN")
479
591
 
480
- if _claude_effective_oauth_mode(state, plan.env):
481
- if plan.env.get("ANTHROPIC_API_KEY"):
482
- raise ValueError("Claude Matrx OAuth route should not set ANTHROPIC_API_KEY")
483
-
484
592
 
485
593
  def _validate_codex_launch_plan(plan: LaunchPlan, state: dict) -> None:
486
594
  if plan.route != "matrx":
@@ -529,16 +637,18 @@ def describe_launch_plan(plan: LaunchPlan, state: dict) -> list[str]:
529
637
  ]
530
638
 
531
639
  if plan.tool == "claude":
532
- base_url = ensure_root_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
640
+ base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
533
641
  custom_headers = (plan.env.get("ANTHROPIC_CUSTOM_HEADERS") or "").strip()
534
- api_key_present = bool((plan.env.get("ANTHROPIC_API_KEY") or "").strip())
642
+ anthropic_key = (plan.env.get("ANTHROPIC_API_KEY") or "").strip()
535
643
  return [
536
644
  "Launching claude via Matrx",
537
645
  f" base_url: {base_url or DEFAULT_MATRX_BASE_URL}",
538
646
  f" auth_source: {plan.auth_source}",
539
- f" oauth_mode: {_claude_effective_oauth_mode(state, plan.env)}",
540
647
  f" custom_headers_present: {bool(custom_headers)}",
541
- f" api_key_present: {api_key_present}",
648
+ f" proxy_key_present: {anthropic_key.startswith('mx_')}",
649
+ f" subscription_token_available: {bool(_resolve_claude_subscription_token(state))}",
650
+ " runtime_route: env injection",
651
+ " persistent_route: disabled",
542
652
  ]
543
653
 
544
654
  return []
@@ -592,22 +702,6 @@ def _clear_if_matches(env: dict[str, str], key: str, expected: str) -> None:
592
702
  env.pop(key, None)
593
703
 
594
704
 
595
- def _claude_uses_oauth(state: dict) -> bool:
596
- if read_claude_oauth_token():
597
- return True
598
- imported = (state.get("auth", {}).get("claude_code", {}).get("oauth_token") or "").strip()
599
- return bool(imported)
600
-
601
-
602
- def _claude_effective_oauth_mode(state: dict, env: dict[str, str] | None = None) -> bool:
603
- requested = ((env or {}).get("MATRX_CLAUDE_MODE") or "").strip().lower()
604
- if requested == "oauth":
605
- return True
606
- if requested == "api-key":
607
- return False
608
- return _claude_uses_oauth(state)
609
-
610
-
611
705
  def _sync_tool_route_config(state: dict, *, tool: str, route: str) -> bool:
612
706
  if tool == "claude":
613
707
  return _cleanup_claude_managed_config(state)