mtrx-cli 0.1.0 → 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.0",
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
@@ -123,7 +124,7 @@ def build_launch_plan(
123
124
  raise ValueError(f"{tool} executable not found in PATH")
124
125
 
125
126
  route = resolve_route(state, tool, route_override)
126
- env = dict(base_env or os.environ)
127
+ env = dict(os.environ if base_env is None else base_env)
127
128
  auth_source = ""
128
129
  launch_args = list(passthrough_args or [])
129
130
 
@@ -164,11 +165,17 @@ def prepare_routed_setup(
164
165
  """
165
166
  route = resolve_route(state, tool, route_override)
166
167
  changed = False
167
- env = dict(base_env or os.environ)
168
+ env = dict(os.environ if base_env is None else base_env)
168
169
  if route == "matrx" and _ensure_matrx_auth(state, env=env):
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,8 +479,8 @@ 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
486
 
@@ -381,10 +488,11 @@ def _build_claude_env(
381
488
  if not mx_key:
382
489
  raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
383
490
  env.pop("MTRX_KEY", None)
384
- env["MATRX_BASE_URL"] = proxy_base
491
+ env.pop("MATRX_CLAUDE_MODE", None)
492
+ env["MATRX_BASE_URL"] = proxy_root
385
493
  env["MATRX_API_KEY"] = mx_key
386
- # Claude Code gateway uses root URL, not /v1
387
494
  env["ANTHROPIC_BASE_URL"] = proxy_base
495
+ env["ANTHROPIC_API_KEY"] = mx_key
388
496
  group_id, project_id = _resolve_matrx_context_overrides(state, env)
389
497
  session_id = str(uuid.uuid4())
390
498
  # Evolutionary scaffolding: env snapshot for AI context injection
@@ -392,7 +500,6 @@ def _build_claude_env(
392
500
  env_b64 = base64.b64encode(json.dumps(env_snap).encode()).decode() if env_snap else ""
393
501
  custom_headers = "\n".join(
394
502
  [
395
- f"x-matrx-key: {mx_key}",
396
503
  "x-matrx-agent-id: claude-cli",
397
504
  "x-matrx-provider: claude_code",
398
505
  f"x-matrx-session-id: {session_id}",
@@ -404,33 +511,34 @@ def _build_claude_env(
404
511
  custom_headers += f"\nx-matrx-project-id: {project_id}"
405
512
  if env_b64:
406
513
  custom_headers += f"\nx-matrx-env: {env_b64}"
407
- # Always send the matrx key via ANTHROPIC_CUSTOM_HEADERS so it arrives on
408
- # the dedicated X-Matrx-Key header. This keeps the Authorization slot free
409
- # for the real Anthropic credential (OAuth token or API key) so the proxy can
410
- # forward it to Anthropic without confusing the matrx key with a provider key.
411
514
  env["ANTHROPIC_CUSTOM_HEADERS"] = custom_headers
412
515
  env.pop("ANTHROPIC_AUTH_TOKEN", None)
413
- if _claude_uses_oauth(state):
414
- # OAuth token flows through natively via Authorization: Bearer sk-ant-oat01-*
415
- env.pop("ANTHROPIC_API_KEY", None)
416
- else:
417
- # Non-OAuth: if a saved Anthropic API key is available, set it so the SDK
418
- # sends x-api-key to the proxy and the proxy can forward it upstream.
419
- # If no key is saved, the proxy will fall back to the org vault.
420
- if direct_key:
421
- env["ANTHROPIC_API_KEY"] = direct_key
422
- else:
423
- env.pop("ANTHROPIC_API_KEY", None)
424
516
  return env, matrx_auth_source
425
517
 
426
518
  # Direct route: clear any matrx-managed env vars
427
519
  env.pop("MTRX_KEY", None)
428
- _clear_if_matches(env, "ANTHROPIC_BASE_URL", proxy_base)
429
- _clear_if_matches(env, "ANTHROPIC_API_KEY", mx_key)
430
- _clear_if_matches(env, "ANTHROPIC_AUTH_TOKEN", mx_key)
431
- custom_headers = env.get("ANTHROPIC_CUSTOM_HEADERS", "")
432
- 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():
433
540
  env.pop("ANTHROPIC_CUSTOM_HEADERS", None)
541
+
434
542
  if claude_oauth_available():
435
543
  return env, "local_claude_oauth"
436
544
  if env.get("ANTHROPIC_API_KEY"):
@@ -450,11 +558,12 @@ def _validate_claude_launch_plan(plan: LaunchPlan, state: dict) -> None:
450
558
  return
451
559
 
452
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"))
453
562
  if not base_url:
454
563
  raise ValueError("Claude Matrx route is missing ANTHROPIC_BASE_URL")
455
- if base_url.endswith("/v1"):
564
+ if base_url != expected_base_url:
456
565
  raise ValueError(
457
- "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. "
458
567
  f"Got: {base_url}"
459
568
  )
460
569
 
@@ -462,27 +571,24 @@ def _validate_claude_launch_plan(plan: LaunchPlan, state: dict) -> None:
462
571
  if not mx_key.startswith("mx_"):
463
572
  raise ValueError("Claude Matrx route is missing a valid MATRX_API_KEY")
464
573
 
465
- # All routes use ANTHROPIC_CUSTOM_HEADERS for matrx key delivery so that
466
- # 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
+
467
578
  custom_headers = (plan.env.get("ANTHROPIC_CUSTOM_HEADERS") or "").strip()
468
- if not custom_headers or mx_key not in custom_headers:
469
- 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")
470
581
  lowered_headers = custom_headers.lower()
471
582
  if "x-matrx-session-id:" not in lowered_headers:
472
583
  raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with X-Matrx-Session-Id")
473
584
  if "x-matrx-provider: claude_code" not in lowered_headers:
474
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")
475
588
 
476
589
  if plan.env.get("ANTHROPIC_AUTH_TOKEN"):
477
590
  raise ValueError("Claude Matrx route should not set ANTHROPIC_AUTH_TOKEN")
478
591
 
479
- if _claude_uses_oauth(state):
480
- oauth_token = read_claude_oauth_token() or (state.get("auth", {}).get("claude_code", {}).get("oauth_token") or "").strip()
481
- if not oauth_token:
482
- raise ValueError("Claude OAuth was selected but no Claude OAuth token is available. Run: mtrx login claude-code --import")
483
- if plan.env.get("ANTHROPIC_API_KEY"):
484
- raise ValueError("Claude Matrx OAuth route should not set ANTHROPIC_API_KEY")
485
-
486
592
 
487
593
  def _validate_codex_launch_plan(plan: LaunchPlan, state: dict) -> None:
488
594
  if plan.route != "matrx":
@@ -531,16 +637,18 @@ def describe_launch_plan(plan: LaunchPlan, state: dict) -> list[str]:
531
637
  ]
532
638
 
533
639
  if plan.tool == "claude":
534
- 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"))
535
641
  custom_headers = (plan.env.get("ANTHROPIC_CUSTOM_HEADERS") or "").strip()
536
- api_key_present = bool((plan.env.get("ANTHROPIC_API_KEY") or "").strip())
642
+ anthropic_key = (plan.env.get("ANTHROPIC_API_KEY") or "").strip()
537
643
  return [
538
644
  "Launching claude via Matrx",
539
645
  f" base_url: {base_url or DEFAULT_MATRX_BASE_URL}",
540
646
  f" auth_source: {plan.auth_source}",
541
- f" oauth_mode: {_claude_uses_oauth(state)}",
542
647
  f" custom_headers_present: {bool(custom_headers)}",
543
- 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",
544
652
  ]
545
653
 
546
654
  return []
@@ -594,13 +702,6 @@ def _clear_if_matches(env: dict[str, str], key: str, expected: str) -> None:
594
702
  env.pop(key, None)
595
703
 
596
704
 
597
- def _claude_uses_oauth(state: dict) -> bool:
598
- if read_claude_oauth_token():
599
- return True
600
- imported = (state.get("auth", {}).get("claude_code", {}).get("oauth_token") or "").strip()
601
- return bool(imported)
602
-
603
-
604
705
  def _sync_tool_route_config(state: dict, *, tool: str, route: str) -> bool:
605
706
  if tool == "claude":
606
707
  return _cleanup_claude_managed_config(state)
@@ -10,6 +10,8 @@ import threading
10
10
  import urllib.parse
11
11
  import webbrowser
12
12
 
13
+ import httpx
14
+
13
15
  from matrx.cli.launcher import (
14
16
  prepare_routed_setup,
15
17
  build_launch_plan,
@@ -52,6 +54,8 @@ def main(argv: list[str] | None = None) -> int:
52
54
  return _cmd_status()
53
55
  if args.command == "doctor":
54
56
  return _cmd_doctor()
57
+ if args.command == "personal":
58
+ return _cmd_personal(args)
55
59
  if args.command in {"codex", "claude"}:
56
60
  return _cmd_launch(args.command, args.route, remainder)
57
61
 
@@ -79,6 +83,11 @@ def _build_parser() -> argparse.ArgumentParser:
79
83
  subparsers.add_parser("status")
80
84
  subparsers.add_parser("doctor")
81
85
 
86
+ personal = subparsers.add_parser("personal")
87
+ personal_subparsers = personal.add_subparsers(dest="personal_command")
88
+ optimize = personal_subparsers.add_parser("optimize")
89
+ optimize.add_argument("mode", choices=["on", "off", "status"])
90
+
82
91
  codex = subparsers.add_parser("codex")
83
92
  codex.add_argument("--route", choices=["direct", "matrx"])
84
93
 
@@ -379,6 +388,100 @@ def _cmd_status() -> int:
379
388
  return 0
380
389
 
381
390
 
391
+ def _personal_matrx_key(state: dict) -> str:
392
+ return (state.get("auth", {}).get("matrx", {}).get("key") or "").strip()
393
+
394
+
395
+ def _matrx_api_json(
396
+ state: dict,
397
+ *,
398
+ method: str,
399
+ path: str,
400
+ key: str,
401
+ json_body: dict | None = None,
402
+ ) -> dict:
403
+ base_url = ensure_root_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
404
+ url = f"{base_url.rstrip('/')}/v1{path}"
405
+ headers = {"X-Matrx-Key": key}
406
+ if json_body is not None:
407
+ headers["Content-Type"] = "application/json"
408
+
409
+ try:
410
+ with httpx.Client(timeout=15) as client:
411
+ response = client.request(method, url, headers=headers, json=json_body)
412
+ except httpx.HTTPError as exc:
413
+ raise ValueError(f"Matrx API request failed: {exc}") from exc
414
+
415
+ if response.status_code >= 400:
416
+ detail = response.text.strip() or response.reason_phrase
417
+ raise ValueError(f"Matrx API error ({response.status_code}) for {path}: {detail}")
418
+
419
+ if response.status_code == 204 or not response.content:
420
+ return {}
421
+ return response.json()
422
+
423
+
424
+ def _resolve_personal_policy_target(state: dict, *, key: str) -> tuple[dict, dict]:
425
+ context = _matrx_api_json(state, method="GET", path="/auth/context", key=key)
426
+ project_id = (context.get("project_id") or "").strip()
427
+ group_id = (context.get("group_id") or "").strip()
428
+ if not project_id:
429
+ raise ValueError("Saved Matrx key is not project-scoped; personal optimization is unavailable")
430
+ if not group_id:
431
+ raise ValueError("No default personal group found for the current Matrx project")
432
+
433
+ group = _matrx_api_json(state, method="GET", path=f"/groups/{group_id}", key=key)
434
+ policy_id = (group.get("policy_id") or "").strip()
435
+ if not policy_id:
436
+ raise ValueError("Default personal group has no optimization policy attached")
437
+
438
+ policy = _matrx_api_json(state, method="GET", path=f"/policies/{policy_id}", key=key)
439
+ return context, policy
440
+
441
+
442
+ def _cmd_personal_optimize(args) -> int:
443
+ state = load_state()
444
+ key = _personal_matrx_key(state)
445
+ if not key:
446
+ print("Personal Matrx login required. Run: mtrx login matrx", file=sys.stderr)
447
+ return 1
448
+
449
+ try:
450
+ context, policy = _resolve_personal_policy_target(state, key=key)
451
+ mode = args.mode
452
+ if mode == "status":
453
+ print(f"Personal optimization: {policy.get('mode', 'unknown')}")
454
+ print(f" project: {context.get('project_id') or '-'}")
455
+ print(f" group: {context.get('group_id') or '-'}")
456
+ print(f" policy: {policy.get('id') or '-'}")
457
+ return 0
458
+
459
+ target_mode = "optimize" if mode == "on" else "observe"
460
+ updated = _matrx_api_json(
461
+ state,
462
+ method="PATCH",
463
+ path=f"/policies/{policy['id']}",
464
+ key=key,
465
+ json_body={"mode": target_mode},
466
+ )
467
+ except ValueError as exc:
468
+ print(str(exc), file=sys.stderr)
469
+ return 1
470
+
471
+ print(f"Personal optimization: {updated.get('mode', target_mode)}")
472
+ print(f" project: {context.get('project_id') or '-'}")
473
+ print(f" group: {context.get('group_id') or '-'}")
474
+ print(f" policy: {updated.get('id') or policy.get('id') or '-'}")
475
+ return 0
476
+
477
+
478
+ def _cmd_personal(args) -> int:
479
+ if args.personal_command == "optimize":
480
+ return _cmd_personal_optimize(args)
481
+ print("Use: mtrx personal optimize <on|off|status>", file=sys.stderr)
482
+ return 1
483
+
484
+
382
485
  def _cmd_doctor() -> int:
383
486
  state = load_state()
384
487
  failures = 0