mtrx-cli 0.1.0

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.
@@ -0,0 +1,796 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import datetime as dt
6
+ import json
7
+ import os
8
+ import platform
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import uuid
13
+ try:
14
+ import tomllib
15
+ except ModuleNotFoundError: # pragma: no cover - Python 3.10 compatibility
16
+ tomllib = None
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ from matrx.cli.state import (
21
+ DEFAULT_MATRX_BASE_URL,
22
+ ensure_root_url,
23
+ ensure_v1_url,
24
+ get_workspace_binding,
25
+ set_workspace_binding,
26
+ )
27
+
28
+
29
+ def _capture_env_snapshot() -> dict:
30
+ """Capture OS, shell, cwd, venv, node for evolutionary scaffolding injection."""
31
+ import subprocess as _sp
32
+ out: dict = {
33
+ "os": platform.system(),
34
+ "shell": os.environ.get("SHELL", os.environ.get("COMSPEC", "")),
35
+ "cwd": os.getcwd(),
36
+ }
37
+ out["venv"] = os.environ.get("VIRTUAL_ENV", "") or None
38
+ if not out["venv"]:
39
+ out["venv_active"] = False
40
+ node = shutil.which("node")
41
+ if node:
42
+ try:
43
+ r = _sp.run([node, "-v"], capture_output=True, text=True, timeout=2)
44
+ out["node"] = r.stdout.strip() if r.returncode == 0 else None
45
+ except Exception:
46
+ out["node"] = None
47
+ else:
48
+ out["node"] = None
49
+ return out
50
+
51
+
52
+ MATRX_ENV_KEYS = {
53
+ "MTRX_KEY",
54
+ "OPENAI_BASE_URL",
55
+ "OPENAI_API_BASE",
56
+ "OPENAI_API_KEY",
57
+ "ANTHROPIC_BASE_URL",
58
+ "ANTHROPIC_API_KEY",
59
+ "ANTHROPIC_AUTH_TOKEN",
60
+ "ANTHROPIC_CUSTOM_HEADERS",
61
+ }
62
+
63
+ MTRX_CODEX_BLOCK_START = "# >>> mtrx managed codex route >>>"
64
+ MTRX_CODEX_BLOCK_END = "# <<< mtrx managed codex route <<<"
65
+ VALID_ROUTES = {"direct", "matrx"}
66
+
67
+
68
+ @dataclass
69
+ class LaunchPlan:
70
+ tool: str
71
+ route: str
72
+ executable: str
73
+ args: list[str]
74
+ env: dict[str, str]
75
+ auth_source: str
76
+
77
+
78
+ def configured_route(state: dict, tool: str) -> str | None:
79
+ route = state.get("defaults", {}).get(tool)
80
+ if route in VALID_ROUTES:
81
+ return str(route)
82
+ return None
83
+
84
+
85
+ def resolve_route(state: dict, tool: str, route_override: str | None) -> str:
86
+ if route_override:
87
+ return route_override
88
+ return configured_route(state, tool) or "direct"
89
+
90
+
91
+ def initialize_first_launch_route(state: dict, tool: str, route_override: str | None) -> bool:
92
+ if route_override:
93
+ return False
94
+ if configured_route(state, tool) is not None:
95
+ return False
96
+ state.setdefault("defaults", {})[tool] = "matrx"
97
+ return True
98
+
99
+
100
+ def find_executable(tool: str) -> str | None:
101
+ candidates = [tool]
102
+ if tool == "claude":
103
+ candidates.extend(["claude.exe", "claude.cmd"])
104
+ if tool == "codex":
105
+ candidates.extend(["codex.exe", "codex.cmd"])
106
+ for candidate in candidates:
107
+ found = shutil.which(candidate)
108
+ if found:
109
+ return found
110
+ return None
111
+
112
+
113
+ def build_launch_plan(
114
+ state: dict,
115
+ *,
116
+ tool: str,
117
+ route_override: str | None = None,
118
+ passthrough_args: list[str] | None = None,
119
+ base_env: dict[str, str] | None = None,
120
+ ) -> LaunchPlan:
121
+ executable = find_executable(tool)
122
+ if not executable:
123
+ raise ValueError(f"{tool} executable not found in PATH")
124
+
125
+ route = resolve_route(state, tool, route_override)
126
+ env = dict(base_env or os.environ)
127
+ auth_source = ""
128
+ launch_args = list(passthrough_args or [])
129
+
130
+ if tool == "codex":
131
+ env, auth_source, launch_args = _build_codex_env(
132
+ state,
133
+ route,
134
+ env,
135
+ launch_args,
136
+ )
137
+ elif tool == "claude":
138
+ env, auth_source = _build_claude_env(state, route, env)
139
+ else:
140
+ raise ValueError(f"Unsupported tool: {tool}")
141
+
142
+ return LaunchPlan(
143
+ tool=tool,
144
+ route=route,
145
+ executable=executable,
146
+ args=launch_args,
147
+ env=env,
148
+ auth_source=auth_source,
149
+ )
150
+
151
+
152
+ def prepare_routed_setup(
153
+ state: dict,
154
+ *,
155
+ tool: str,
156
+ route_override: str | None = None,
157
+ base_env: dict[str, str] | None = None,
158
+ ) -> tuple[dict, bool]:
159
+ """
160
+ Inline setup for routed launches.
161
+
162
+ Returns (mutated_state, changed).
163
+ Raises ValueError when the launch should abort.
164
+ """
165
+ route = resolve_route(state, tool, route_override)
166
+ changed = False
167
+ env = dict(base_env or os.environ)
168
+ if route == "matrx" and _ensure_matrx_auth(state, env=env):
169
+ changed = True
170
+ if route == "matrx" and _persist_workspace_binding_from_env(state, env):
171
+ changed = True
172
+
173
+ if _sync_tool_route_config(state, tool=tool, route=route):
174
+ changed = True
175
+
176
+ if route != "matrx":
177
+ return state, changed
178
+
179
+ return state, changed
180
+
181
+
182
+ def launch(plan: LaunchPlan) -> int:
183
+ result = subprocess.run([plan.executable, *plan.args], env=plan.env, check=False)
184
+ return int(result.returncode)
185
+
186
+
187
+ def validate_launch_plan(plan: LaunchPlan, state: dict) -> None:
188
+ """Validate launch plan before spawning the process."""
189
+ if plan.tool == "claude":
190
+ _validate_claude_launch_plan(plan, state)
191
+ if plan.tool == "codex":
192
+ _validate_codex_launch_plan(plan, state)
193
+
194
+
195
+ def claude_credentials_path() -> Path:
196
+ return Path.home() / ".claude" / ".credentials.json"
197
+
198
+
199
+ def claude_settings_path() -> Path:
200
+ return Path.home() / ".claude" / "settings.json"
201
+
202
+
203
+ def codex_config_path() -> Path:
204
+ return Path.home() / ".codex" / "config.toml"
205
+
206
+
207
+ def codex_auth_path() -> Path:
208
+ return Path.home() / ".codex" / "auth.json"
209
+
210
+
211
+ def claude_oauth_available() -> bool:
212
+ token = read_claude_oauth_token()
213
+ return bool(token)
214
+
215
+
216
+ def read_claude_oauth_token() -> str | None:
217
+ path = claude_credentials_path()
218
+ if not path.exists():
219
+ return None
220
+ try:
221
+ data = json.loads(path.read_text(encoding="utf-8"))
222
+ except (json.JSONDecodeError, OSError):
223
+ return None
224
+ oauth = data.get("claudeAiOauth")
225
+ if not isinstance(oauth, dict):
226
+ return None
227
+ token = oauth.get("accessToken")
228
+ if not isinstance(token, str) or not token.strip():
229
+ return None
230
+ return token.strip()
231
+
232
+
233
+ def read_codex_access_token() -> str | None:
234
+ path = codex_auth_path()
235
+ if not path.exists():
236
+ return None
237
+ try:
238
+ data = json.loads(path.read_text(encoding="utf-8"))
239
+ except (json.JSONDecodeError, OSError):
240
+ return None
241
+ tokens = data.get("tokens")
242
+ if not isinstance(tokens, dict):
243
+ return None
244
+ token = tokens.get("access_token")
245
+ if not isinstance(token, str) or not token.strip():
246
+ return None
247
+ return token.strip()
248
+
249
+
250
+ def _resolve_matrx_route_key(
251
+ state: dict,
252
+ env: dict[str, str],
253
+ ) -> tuple[str, str]:
254
+ env_key = (env.get("MTRX_KEY") or "").strip()
255
+ if env_key:
256
+ return env_key, "env_matrx_key"
257
+
258
+ binding = get_workspace_binding(state, cwd=_workspace_cwd(env))
259
+ binding_key = ((binding or {}).get("matrx_key") or "").strip()
260
+ if binding_key:
261
+ return binding_key, "workspace_matrx_key"
262
+
263
+ saved_key = (state.get("auth", {}).get("matrx", {}).get("key") or "").strip()
264
+ if saved_key:
265
+ return saved_key, "saved_matrx_key"
266
+
267
+ return "", "missing_matrx_key"
268
+
269
+
270
+ def _workspace_cwd(env: dict[str, str]) -> str:
271
+ return (env.get("PWD") or os.getcwd()).strip()
272
+
273
+
274
+ def _resolve_matrx_context_overrides(
275
+ state: dict,
276
+ env: dict[str, str],
277
+ ) -> tuple[str, str]:
278
+ binding = get_workspace_binding(state, cwd=_workspace_cwd(env)) or {}
279
+ group_id = (env.get("MTRX_GROUP_ID") or binding.get("group_id") or "").strip()
280
+ project_id = (env.get("MTRX_PROJECT_ID") or binding.get("project_id") or "").strip()
281
+ return group_id, project_id
282
+
283
+
284
+ def _persist_workspace_binding_from_env(state: dict, env: dict[str, str]) -> bool:
285
+ env_key = (env.get("MTRX_KEY") or "").strip()
286
+ env_project = (env.get("MTRX_PROJECT_ID") or "").strip()
287
+ env_group = (env.get("MTRX_GROUP_ID") or "").strip()
288
+ saved_personal_key = (state.get("auth", {}).get("matrx", {}).get("key") or "").strip()
289
+
290
+ if not any((env_key, env_project, env_group)):
291
+ return False
292
+
293
+ if env_key and env_key == saved_personal_key and not env_project and not env_group:
294
+ return False
295
+
296
+ return set_workspace_binding(
297
+ state,
298
+ cwd=_workspace_cwd(env),
299
+ matrx_key=env_key if "MTRX_KEY" in env else None,
300
+ project_id=env_project if "MTRX_PROJECT_ID" in env else None,
301
+ group_id=env_group if "MTRX_GROUP_ID" in env else None,
302
+ )
303
+
304
+ def _build_codex_env(
305
+ state: dict,
306
+ route: str,
307
+ env: dict[str, str],
308
+ passthrough_args: list[str],
309
+ ) -> tuple[dict[str, str], str, list[str]]:
310
+ matrx = state["auth"]["matrx"]
311
+ openai = state["auth"]["openai"]
312
+ proxy_base = ensure_v1_url(matrx.get("base_url"))
313
+ mx_key, matrx_auth_source = _resolve_matrx_route_key(state, env)
314
+ direct_key = (openai.get("key") or "").strip()
315
+
316
+ if route == "matrx":
317
+ if not mx_key:
318
+ raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
319
+ access_token = read_codex_access_token()
320
+ if not access_token:
321
+ raise ValueError("Codex login required. Run: codex login")
322
+ for key in MATRX_ENV_KEYS:
323
+ env.pop(key, None)
324
+ env_snap = _capture_env_snapshot()
325
+ env_b64 = base64.b64encode(json.dumps(env_snap).encode()).decode() if env_snap else ""
326
+ session_id = str(uuid.uuid4())
327
+ group_id, project_id = _resolve_matrx_context_overrides(state, env)
328
+ header_parts = [
329
+ f'"Authorization" = "Bearer {access_token}"',
330
+ f'"X-Matrx-Key" = "{mx_key}"',
331
+ '"X-Matrx-Agent-Id" = "codex-cli"',
332
+ '"X-Matrx-Provider" = "codex"',
333
+ f'"X-Matrx-Session-Id" = "{session_id}"',
334
+ ]
335
+ if group_id:
336
+ header_parts.append(f'"X-Matrx-Group" = "{group_id}"')
337
+ if project_id:
338
+ header_parts.append(f'"X-Matrx-Project-Id" = "{project_id}"')
339
+ if env_b64:
340
+ header_parts.append(f'"X-Matrx-Env" = "{env_b64}"')
341
+ headers_str = ", ".join(header_parts)
342
+ return env, matrx_auth_source, [
343
+ "-c",
344
+ "model_provider=matrx",
345
+ "-c",
346
+ 'model_providers.matrx.name="Matrx Proxy"',
347
+ "-c",
348
+ f'model_providers.matrx.base_url="{proxy_base}"',
349
+ "-c",
350
+ 'model_providers.matrx.wire_api="responses"',
351
+ "-c",
352
+ f'model_providers.matrx.http_headers={{ {headers_str} }}',
353
+ *passthrough_args,
354
+ ]
355
+
356
+ _clear_if_matches(env, "OPENAI_BASE_URL", proxy_base)
357
+ _clear_if_matches(env, "OPENAI_API_BASE", proxy_base)
358
+ _clear_if_matches(env, "OPENAI_API_KEY", mx_key)
359
+ env.pop("MTRX_KEY", None)
360
+ if env.get("OPENAI_API_KEY"):
361
+ return env, "existing_openai_env", passthrough_args
362
+ if direct_key:
363
+ env["OPENAI_API_KEY"] = direct_key
364
+ return env, "saved_openai_key", passthrough_args
365
+ return env, "existing_codex_auth", passthrough_args
366
+
367
+
368
+ def _build_claude_env(
369
+ state: dict,
370
+ route: str,
371
+ env: dict[str, str],
372
+ ) -> tuple[dict[str, str], str]:
373
+ matrx = state["auth"]["matrx"]
374
+ 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"))
377
+ mx_key, matrx_auth_source = _resolve_matrx_route_key(state, env)
378
+ direct_key = (anthropic.get("key") or "").strip()
379
+
380
+ if route == "matrx":
381
+ if not mx_key:
382
+ raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
383
+ env.pop("MTRX_KEY", None)
384
+ env["MATRX_BASE_URL"] = proxy_base
385
+ env["MATRX_API_KEY"] = mx_key
386
+ # Claude Code gateway uses root URL, not /v1
387
+ env["ANTHROPIC_BASE_URL"] = proxy_base
388
+ group_id, project_id = _resolve_matrx_context_overrides(state, env)
389
+ session_id = str(uuid.uuid4())
390
+ # Evolutionary scaffolding: env snapshot for AI context injection
391
+ env_snap = _capture_env_snapshot()
392
+ env_b64 = base64.b64encode(json.dumps(env_snap).encode()).decode() if env_snap else ""
393
+ custom_headers = "\n".join(
394
+ [
395
+ f"x-matrx-key: {mx_key}",
396
+ "x-matrx-agent-id: claude-cli",
397
+ "x-matrx-provider: claude_code",
398
+ f"x-matrx-session-id: {session_id}",
399
+ ]
400
+ )
401
+ if group_id:
402
+ custom_headers += f"\nx-matrx-group: {group_id}"
403
+ if project_id:
404
+ custom_headers += f"\nx-matrx-project-id: {project_id}"
405
+ if env_b64:
406
+ 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
+ env["ANTHROPIC_CUSTOM_HEADERS"] = custom_headers
412
+ 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
+ return env, matrx_auth_source
425
+
426
+ # Direct route: clear any matrx-managed env vars
427
+ 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:
433
+ env.pop("ANTHROPIC_CUSTOM_HEADERS", None)
434
+ if claude_oauth_available():
435
+ return env, "local_claude_oauth"
436
+ if env.get("ANTHROPIC_API_KEY"):
437
+ return env, "existing_anthropic_env"
438
+ if direct_key:
439
+ env["ANTHROPIC_API_KEY"] = direct_key
440
+ return env, "saved_anthropic_key"
441
+ imported = (state["auth"]["claude_code"].get("oauth_token") or "").strip()
442
+ if imported:
443
+ return env, "imported_claude_oauth"
444
+ return env, "existing_claude_auth"
445
+
446
+
447
+ def _validate_claude_launch_plan(plan: LaunchPlan, state: dict) -> None:
448
+ """Validate Claude launch plan before spawning."""
449
+ if plan.route != "matrx":
450
+ return
451
+
452
+ base_url = (plan.env.get("ANTHROPIC_BASE_URL") or "").strip()
453
+ if not base_url:
454
+ raise ValueError("Claude Matrx route is missing ANTHROPIC_BASE_URL")
455
+ if base_url.endswith("/v1"):
456
+ raise ValueError(
457
+ "Claude Matrx route must use the gateway root base URL, not a /v1 URL. "
458
+ f"Got: {base_url}"
459
+ )
460
+
461
+ mx_key = (plan.env.get("MATRX_API_KEY") or "").strip()
462
+ if not mx_key.startswith("mx_"):
463
+ raise ValueError("Claude Matrx route is missing a valid MATRX_API_KEY")
464
+
465
+ # All routes use ANTHROPIC_CUSTOM_HEADERS for matrx key delivery so that
466
+ # the Authorization slot remains free for the real Anthropic credential.
467
+ 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")
470
+ lowered_headers = custom_headers.lower()
471
+ if "x-matrx-session-id:" not in lowered_headers:
472
+ raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with X-Matrx-Session-Id")
473
+ if "x-matrx-provider: claude_code" not in lowered_headers:
474
+ raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with X-Matrx-Provider=claude_code")
475
+
476
+ if plan.env.get("ANTHROPIC_AUTH_TOKEN"):
477
+ raise ValueError("Claude Matrx route should not set ANTHROPIC_AUTH_TOKEN")
478
+
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
+
487
+ def _validate_codex_launch_plan(plan: LaunchPlan, state: dict) -> None:
488
+ if plan.route != "matrx":
489
+ return
490
+
491
+ expected_base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
492
+ required_args = {
493
+ "model_provider=matrx",
494
+ 'model_providers.matrx.name="Matrx Proxy"',
495
+ f'model_providers.matrx.base_url="{expected_base_url}"',
496
+ 'model_providers.matrx.wire_api="responses"',
497
+ }
498
+ actual_args = set(plan.args)
499
+ if any(arg not in actual_args for arg in required_args):
500
+ raise ValueError("Codex Matrx route is missing required launch overrides")
501
+ http_headers_arg = next(
502
+ (arg for arg in plan.args if arg.startswith('model_providers.matrx.http_headers=')),
503
+ None,
504
+ )
505
+ if not http_headers_arg or '"Authorization" = "Bearer ' not in http_headers_arg:
506
+ raise ValueError("Codex Matrx route is missing launch-time Authorization bearer forwarding")
507
+ key_match = re.search(r'"X-Matrx-Key"\s*=\s*"([^"]+)"', http_headers_arg)
508
+ if key_match is None or not key_match.group(1).startswith("mx_"):
509
+ raise ValueError("Codex Matrx route is missing launch-time X-Matrx-Key forwarding")
510
+ if '"X-Matrx-Provider" = "codex"' not in http_headers_arg:
511
+ raise ValueError("Codex Matrx route is missing launch-time X-Matrx-Provider=codex")
512
+ if '"X-Matrx-Session-Id"' not in http_headers_arg:
513
+ raise ValueError("Codex Matrx route is missing launch-time X-Matrx-Session-Id")
514
+
515
+
516
+ def describe_launch_plan(plan: LaunchPlan, state: dict) -> list[str]:
517
+ if plan.route != "matrx":
518
+ return []
519
+
520
+ if plan.tool == "codex":
521
+ base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
522
+ return [
523
+ "Launching codex via Matrx",
524
+ f" config: {codex_config_path()}",
525
+ f" base_url: {base_url}",
526
+ f" auth_source: {plan.auth_source}",
527
+ f" codex_auth_path: {codex_auth_path()}",
528
+ f" codex_access_token_present: {bool(read_codex_access_token())}",
529
+ " runtime_route: forced launch overrides",
530
+ " persistent_route: disabled",
531
+ ]
532
+
533
+ if plan.tool == "claude":
534
+ base_url = ensure_root_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
535
+ custom_headers = (plan.env.get("ANTHROPIC_CUSTOM_HEADERS") or "").strip()
536
+ api_key_present = bool((plan.env.get("ANTHROPIC_API_KEY") or "").strip())
537
+ return [
538
+ "Launching claude via Matrx",
539
+ f" base_url: {base_url or DEFAULT_MATRX_BASE_URL}",
540
+ f" auth_source: {plan.auth_source}",
541
+ f" oauth_mode: {_claude_uses_oauth(state)}",
542
+ f" custom_headers_present: {bool(custom_headers)}",
543
+ f" api_key_present: {api_key_present}",
544
+ ]
545
+
546
+ return []
547
+
548
+
549
+ def get_tool_config_status(state: dict, tool: str) -> dict[str, str | bool | None]:
550
+ status = (
551
+ state.setdefault("setup", {})
552
+ .setdefault("tool_config", {})
553
+ .setdefault(
554
+ tool,
555
+ {
556
+ "configured": False,
557
+ "verified": False,
558
+ "config_path": None,
559
+ "backup_path": None,
560
+ "original_backup_path": None,
561
+ "config_fingerprint": None,
562
+ "matrx_key_fingerprint": None,
563
+ "last_verified_at": None,
564
+ "previous_model_provider": None,
565
+ "previous_matrx_block": None,
566
+ "previous_values": {},
567
+ },
568
+ )
569
+ )
570
+ return {
571
+ "configured": bool(status.get("configured")),
572
+ "verified": bool(status.get("verified")),
573
+ "config_path": status.get("config_path"),
574
+ "backup_path": status.get("backup_path"),
575
+ "last_verified_at": status.get("last_verified_at"),
576
+ }
577
+
578
+
579
+ def _ensure_matrx_auth(state: dict, *, env: dict[str, str] | None = None) -> bool:
580
+ key, _ = _resolve_matrx_route_key(state, dict(env or os.environ))
581
+ if not key:
582
+ raise ValueError("Login required. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
583
+ if not key.startswith("mx_"):
584
+ raise ValueError("Matrx key is invalid. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
585
+ return False
586
+
587
+
588
+ def _fingerprint_secret(secret: str) -> str:
589
+ return hashlib.sha256(secret.encode()).hexdigest()
590
+
591
+
592
+ def _clear_if_matches(env: dict[str, str], key: str, expected: str) -> None:
593
+ if expected and env.get(key) == expected:
594
+ env.pop(key, None)
595
+
596
+
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
+ def _sync_tool_route_config(state: dict, *, tool: str, route: str) -> bool:
605
+ if tool == "claude":
606
+ return _cleanup_claude_managed_config(state)
607
+ if tool == "codex":
608
+ return _sync_codex_route_config(state, route=route)
609
+ return False
610
+
611
+
612
+ def _tool_config_entry(state: dict, tool: str) -> dict:
613
+ return (
614
+ state.setdefault("setup", {})
615
+ .setdefault("tool_config", {})
616
+ .setdefault(
617
+ tool,
618
+ {
619
+ "configured": False,
620
+ "verified": False,
621
+ "config_path": None,
622
+ "backup_path": None,
623
+ "original_backup_path": None,
624
+ "config_fingerprint": None,
625
+ "matrx_key_fingerprint": None,
626
+ "last_verified_at": None,
627
+ "previous_model_provider": None,
628
+ "previous_matrx_block": None,
629
+ "previous_values": {},
630
+ },
631
+ )
632
+ )
633
+
634
+
635
+ def _read_json_file(path: Path) -> dict:
636
+ if not path.exists():
637
+ return {}
638
+ try:
639
+ data = json.loads(path.read_text(encoding="utf-8"))
640
+ except json.JSONDecodeError as exc:
641
+ raise ValueError(f"Failed to parse {path}") from exc
642
+ if not isinstance(data, dict):
643
+ raise ValueError(f"Expected {path} to contain a JSON object")
644
+ return data
645
+
646
+
647
+ def _write_json_file(path: Path, data: dict) -> None:
648
+ path.parent.mkdir(parents=True, exist_ok=True)
649
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
650
+
651
+
652
+ def _cleanup_claude_managed_config(state: dict) -> bool:
653
+ path = claude_settings_path()
654
+ entry = _tool_config_entry(state, "claude")
655
+ entry["config_path"] = str(path)
656
+
657
+ if not path.exists():
658
+ entry["configured"] = False
659
+ entry["verified"] = True
660
+ entry["config_fingerprint"] = None
661
+ entry["matrx_key_fingerprint"] = None
662
+ entry["last_verified_at"] = dt.datetime.now(dt.timezone.utc).isoformat()
663
+ return False
664
+
665
+ data = _read_json_file(path)
666
+ env = data.get("env")
667
+ if env is None:
668
+ entry["configured"] = False
669
+ entry["verified"] = True
670
+ entry["config_fingerprint"] = _fingerprint_secret(json.dumps(data, sort_keys=True))
671
+ entry["matrx_key_fingerprint"] = None
672
+ entry["last_verified_at"] = dt.datetime.now(dt.timezone.utc).isoformat()
673
+ return False
674
+ if not isinstance(env, dict):
675
+ raise ValueError(f"Expected {path} env to be a JSON object")
676
+
677
+ previous = entry.setdefault("previous_values", {})
678
+ changed = False
679
+
680
+ for key in ("ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"):
681
+ prior = previous.get(key, None)
682
+ if prior is None:
683
+ if key in env:
684
+ env.pop(key, None)
685
+ changed = True
686
+ elif env.get(key) != prior:
687
+ env[key] = prior
688
+ changed = True
689
+
690
+ if not env:
691
+ data.pop("env", None)
692
+
693
+ entry["configured"] = False
694
+ entry["verified"] = True
695
+ entry["config_fingerprint"] = _fingerprint_secret(json.dumps(data, sort_keys=True))
696
+ entry["matrx_key_fingerprint"] = None
697
+ entry["last_verified_at"] = dt.datetime.now(dt.timezone.utc).isoformat()
698
+
699
+ if changed:
700
+ _write_json_file(path, data)
701
+ return changed
702
+
703
+
704
+ def _sync_codex_route_config(state: dict, *, route: str) -> bool:
705
+ path = codex_config_path()
706
+ entry = _tool_config_entry(state, "codex")
707
+ prior_entry = dict(entry)
708
+ entry["config_path"] = str(path)
709
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
710
+ managed_block, content = _strip_codex_managed_block(text)
711
+ new_text = text
712
+
713
+ if managed_block is not None:
714
+ new_text = _restore_codex_config_text(entry, content)
715
+
716
+ changed = _normalize_toml_text(new_text) != _normalize_toml_text(text)
717
+
718
+ entry["configured"] = False
719
+ entry["verified"] = True
720
+ normalized_text = _normalize_toml_text(new_text)
721
+ entry["config_fingerprint"] = _fingerprint_secret(normalized_text) if normalized_text else None
722
+ entry["matrx_key_fingerprint"] = None
723
+ entry["last_verified_at"] = dt.datetime.now(dt.timezone.utc).isoformat()
724
+ entry["previous_model_provider"] = None
725
+ entry["previous_matrx_block"] = None
726
+
727
+ if changed:
728
+ _write_or_remove_codex_config(path, new_text)
729
+
730
+ return changed or entry != prior_entry
731
+
732
+
733
+ def _strip_codex_managed_block(text: str) -> tuple[str | None, str]:
734
+ pattern = rf"(?ms)^\s*{re.escape(MTRX_CODEX_BLOCK_START)}\r?\n.*?^\s*{re.escape(MTRX_CODEX_BLOCK_END)}\s*$\r?\n?"
735
+ match = re.search(pattern, text)
736
+ if not match:
737
+ return None, text.strip()
738
+ without_managed = text[:match.start()] + text[match.end():]
739
+ return match.group(0).strip(), without_managed.strip()
740
+
741
+
742
+ def _restore_codex_config_text(entry: dict, content: str) -> str:
743
+ original_backup_path = (entry.get("original_backup_path") or "").strip()
744
+ if original_backup_path:
745
+ backup_path = Path(original_backup_path)
746
+ if backup_path.exists():
747
+ return backup_path.read_text(encoding="utf-8")
748
+
749
+ restored = content.rstrip()
750
+ prior_model_provider = (entry.get("previous_model_provider") or "").strip()
751
+ prior_matrx_block = (entry.get("previous_matrx_block") or "").strip()
752
+
753
+ if prior_model_provider and not re.search(r"(?m)^model_provider\s*=", restored):
754
+ restored = _append_toml_block(restored, prior_model_provider)
755
+ if prior_matrx_block and "[model_providers.matrx]" not in restored:
756
+ restored = _append_toml_block(restored, prior_matrx_block)
757
+
758
+ return restored
759
+
760
+
761
+ def _write_or_remove_codex_config(path: Path, text: str) -> None:
762
+ normalized = _normalize_toml_text(text)
763
+ if normalized:
764
+ path.parent.mkdir(parents=True, exist_ok=True)
765
+ path.write_text(normalized, encoding="utf-8")
766
+ _verify_codex_config_file(path)
767
+ return
768
+ if path.exists():
769
+ path.unlink()
770
+
771
+
772
+ def _append_toml_block(content: str, block: str) -> str:
773
+ base = content.rstrip()
774
+ addition = block.strip()
775
+ if not addition:
776
+ return base
777
+ if not base:
778
+ return addition
779
+ return f"{base}\n\n{addition}"
780
+
781
+
782
+ def _normalize_toml_text(text: str) -> str:
783
+ cleaned = text.strip()
784
+ if not cleaned:
785
+ return ""
786
+ return cleaned + "\n"
787
+
788
+
789
+ def _verify_codex_config_file(path: Path) -> None:
790
+ if tomllib is None:
791
+ return
792
+ text = path.read_text(encoding="utf-8")
793
+ try:
794
+ tomllib.loads(text)
795
+ except tomllib.TOMLDecodeError as exc:
796
+ raise ValueError(f"Failed to parse Codex config at {path}") from exc