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,510 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import html
5
+ import http.server
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import urllib.parse
11
+ import webbrowser
12
+
13
+ from matrx.cli.launcher import (
14
+ prepare_routed_setup,
15
+ build_launch_plan,
16
+ claude_credentials_path,
17
+ claude_oauth_available,
18
+ configured_route,
19
+ describe_launch_plan,
20
+ find_executable,
21
+ get_tool_config_status,
22
+ initialize_first_launch_route,
23
+ launch,
24
+ read_claude_oauth_token,
25
+ read_codex_access_token,
26
+ resolve_route,
27
+ validate_launch_plan,
28
+ )
29
+ from matrx.cli.state import (
30
+ ensure_app_url,
31
+ ensure_root_url,
32
+ ensure_v1_url,
33
+ get_workspace_binding,
34
+ load_state,
35
+ mask_secret,
36
+ save_state,
37
+ )
38
+
39
+
40
+ def main(argv: list[str] | None = None) -> int:
41
+ parser = _build_parser()
42
+ args, remainder = parser.parse_known_args(argv)
43
+
44
+ if args.command == "help":
45
+ parser.print_help()
46
+ return 0
47
+ if args.command == "login":
48
+ return _cmd_login(args)
49
+ if args.command == "use":
50
+ return _cmd_use(args)
51
+ if args.command == "status":
52
+ return _cmd_status()
53
+ if args.command == "doctor":
54
+ return _cmd_doctor()
55
+ if args.command in {"codex", "claude"}:
56
+ return _cmd_launch(args.command, args.route, remainder)
57
+
58
+ parser.print_help()
59
+ return 1
60
+
61
+
62
+ def _build_parser() -> argparse.ArgumentParser:
63
+ parser = argparse.ArgumentParser(prog="mtrx")
64
+ subparsers = parser.add_subparsers(dest="command")
65
+
66
+ login = subparsers.add_parser("login")
67
+ login.add_argument("provider", choices=["matrx", "openai", "anthropic", "claude-code"])
68
+ login.add_argument("--key")
69
+ login.add_argument("--base-url")
70
+ login.add_argument("--app-url")
71
+ login.add_argument("--web", action="store_true")
72
+ login.add_argument("--import", dest="do_import", action="store_true")
73
+
74
+ use = subparsers.add_parser("use")
75
+ use.add_argument("tool", choices=["codex", "claude"])
76
+ use.add_argument("route", choices=["direct", "matrx"])
77
+
78
+ subparsers.add_parser("help")
79
+ subparsers.add_parser("status")
80
+ subparsers.add_parser("doctor")
81
+
82
+ codex = subparsers.add_parser("codex")
83
+ codex.add_argument("--route", choices=["direct", "matrx"])
84
+
85
+ claude = subparsers.add_parser("claude")
86
+ claude.add_argument("--route", choices=["direct", "matrx"])
87
+
88
+ return parser
89
+
90
+
91
+ def _is_interactive_terminal() -> bool:
92
+ return sys.stdin.isatty() and sys.stdout.isatty()
93
+
94
+
95
+ def _prompt_yes_no(prompt: str, *, default: bool = True) -> bool:
96
+ if not _is_interactive_terminal():
97
+ return default
98
+ suffix = "[Y/n]" if default else "[y/N]"
99
+ answer = input(f"{prompt} {suffix} ").strip().lower()
100
+ if not answer:
101
+ return default
102
+ return answer in {"y", "yes"}
103
+
104
+
105
+ def _prompt_for_secret(prompt: str) -> str:
106
+ if not _is_interactive_terminal():
107
+ return ""
108
+ return input(f"{prompt}: ").strip()
109
+
110
+
111
+ def _derive_callback_payload(params: dict[str, list[str]]) -> dict[str, str | None]:
112
+ def _one(name: str) -> str | None:
113
+ values = params.get(name) or []
114
+ if not values:
115
+ return None
116
+ value = values[0].strip()
117
+ return value or None
118
+
119
+ return {
120
+ "state": _one("state"),
121
+ "matrx_key": _one("matrx_key"),
122
+ "org_id": _one("org_id"),
123
+ "project_id": _one("project_id"),
124
+ "error": _one("error"),
125
+ }
126
+
127
+
128
+ def _matrx_browser_callback_html(payload: dict[str, str | None]) -> bytes:
129
+ error = (payload.get("error") or "").strip()
130
+ if error:
131
+ escaped_error = html.escape(error)
132
+ body = (
133
+ "<html><body><h1>Matrx login failed.</h1>"
134
+ "<p>The browser-based handoff returned an error.</p>"
135
+ f"<pre>{escaped_error}</pre>"
136
+ "<p>You can close this window and return to the terminal.</p>"
137
+ "</body></html>"
138
+ )
139
+ else:
140
+ body = (
141
+ "<html><body><h1>Matrx login complete.</h1>"
142
+ "<p>You can close this window and return to the terminal.</p>"
143
+ "</body></html>"
144
+ )
145
+ return body.encode("utf-8")
146
+
147
+
148
+ def _run_matrx_browser_login(
149
+ state: dict,
150
+ *,
151
+ requested_org_id: str | None = None,
152
+ requested_project_id: str | None = None,
153
+ ) -> dict[str, str | None]:
154
+ auth_cfg = state.setdefault("auth", {}).setdefault("matrx", {})
155
+ api_root = ensure_root_url(auth_cfg.get("base_url"))
156
+ app_url = ensure_app_url(auth_cfg.get("app_url"), base_url=auth_cfg.get("base_url"))
157
+ nonce = os.urandom(12).hex()
158
+ result: dict[str, str | None] = {}
159
+ event = threading.Event()
160
+
161
+ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
162
+ def do_GET(self): # noqa: N802
163
+ parsed = urllib.parse.urlparse(self.path)
164
+ if parsed.path != "/callback":
165
+ self.send_response(404)
166
+ self.end_headers()
167
+ return
168
+
169
+ payload = _derive_callback_payload(urllib.parse.parse_qs(parsed.query))
170
+ result.update(payload)
171
+ event.set()
172
+ body = _matrx_browser_callback_html(payload)
173
+ self.send_response(200)
174
+ self.send_header("Content-Type", "text/html; charset=utf-8")
175
+ self.send_header("Content-Length", str(len(body)))
176
+ self.end_headers()
177
+ self.wfile.write(body)
178
+
179
+ def log_message(self, format, *args): # noqa: A003
180
+ return
181
+
182
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _CallbackHandler)
183
+ try:
184
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
185
+ thread.start()
186
+ callback_url = f"http://127.0.0.1:{server.server_port}/callback"
187
+ params = {
188
+ "callback": callback_url,
189
+ "state": nonce,
190
+ "api_base": api_root,
191
+ }
192
+ if requested_org_id:
193
+ params["org_id"] = requested_org_id
194
+ if requested_project_id:
195
+ params["project_id"] = requested_project_id
196
+ login_url = f"{app_url}/cli-auth?{urllib.parse.urlencode(params)}"
197
+ print(f"Open this URL to complete Matrx login:\n {login_url}")
198
+ webbrowser.open(login_url)
199
+ if not event.wait(timeout=300):
200
+ raise ValueError("Matrx web login timed out")
201
+ finally:
202
+ server.shutdown()
203
+ server.server_close()
204
+
205
+ if result.get("state") != nonce:
206
+ raise ValueError("Matrx web login returned an invalid state token")
207
+ if result.get("error"):
208
+ raise ValueError(f"Matrx web login failed: {result['error']}")
209
+ if not (result.get("matrx_key") or "").startswith("mx_"):
210
+ raise ValueError("Matrx web login did not return a valid Matrx key")
211
+ return result
212
+
213
+
214
+ def _complete_matrx_login(state: dict) -> tuple[dict, bool]:
215
+ auth_cfg = state.setdefault("auth", {}).setdefault("matrx", {})
216
+ if _has_matrx_login(state, env=os.environ):
217
+ return state, False
218
+ if not _is_interactive_terminal():
219
+ raise ValueError("Login required. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
220
+
221
+ print("Matrx login required.")
222
+ if _prompt_yes_no("Open the browser-based Matrx sign-in flow now?", default=True):
223
+ session = _run_matrx_browser_login(state)
224
+ auth_cfg["key"] = session["matrx_key"]
225
+ return state, True
226
+
227
+ manual_key = _prompt_for_secret("Paste your Matrx key")
228
+ if not manual_key:
229
+ raise ValueError("Matrx login cancelled")
230
+ if not manual_key.startswith("mx_"):
231
+ raise ValueError("Matrx key must start with mx_")
232
+ auth_cfg["key"] = manual_key
233
+ return state, True
234
+
235
+
236
+ def _complete_codex_login() -> None:
237
+ if read_codex_access_token():
238
+ return
239
+ if not _is_interactive_terminal():
240
+ raise ValueError("Codex login required. Run: codex login")
241
+ print("Codex login required.")
242
+ if not _prompt_yes_no("Run `codex login` now?", default=True):
243
+ raise ValueError("Codex login cancelled")
244
+ executable = find_executable("codex") or "codex"
245
+ result = subprocess.run([executable, "login"], check=False)
246
+ if result.returncode != 0 or not read_codex_access_token():
247
+ raise ValueError("Codex login did not complete successfully")
248
+
249
+
250
+ def _maybe_promote_direct_route(state: dict, tool: str, route: str | None) -> tuple[str | None, bool]:
251
+ if route is not None:
252
+ return route, False
253
+ if configured_route(state, tool) != "direct":
254
+ return route, False
255
+ if _has_matrx_login(state, env=os.environ):
256
+ return route, False
257
+ if not _is_interactive_terminal():
258
+ return route, False
259
+
260
+ print(f"`mtrx {tool}` is currently configured to use the direct route.")
261
+ if _prompt_yes_no(f"Switch {tool} to the Matrx route and continue with Matrx sign-in?", default=True):
262
+ state.setdefault("defaults", {})[tool] = "matrx"
263
+ return "matrx", True
264
+ return route, False
265
+
266
+
267
+ def _cmd_login(args) -> int:
268
+ state = load_state()
269
+ provider = args.provider.replace("-", "_")
270
+
271
+ if provider == "claude_code":
272
+ if not args.do_import:
273
+ print("Use: mtrx login claude-code --import", file=sys.stderr)
274
+ return 1
275
+ token = read_claude_oauth_token()
276
+ if not token:
277
+ print(
278
+ f"No Claude OAuth token found at {claude_credentials_path()}",
279
+ file=sys.stderr,
280
+ )
281
+ return 1
282
+ state["auth"]["claude_code"]["oauth_token"] = token
283
+ path = save_state(state)
284
+ print(f"Saved Claude Code OAuth token metadata to {path}")
285
+ return 0
286
+
287
+ key = (args.key or "").strip()
288
+ if provider == "matrx":
289
+ if args.base_url:
290
+ state["auth"]["matrx"]["base_url"] = args.base_url.strip()
291
+ if args.app_url:
292
+ state["auth"]["matrx"]["app_url"] = args.app_url.strip()
293
+ elif not state["auth"]["matrx"].get("app_url"):
294
+ state["auth"]["matrx"]["app_url"] = ensure_app_url(
295
+ None,
296
+ base_url=state["auth"]["matrx"].get("base_url"),
297
+ )
298
+ if not key:
299
+ if not args.web and not _is_interactive_terminal():
300
+ print("--key is required for non-interactive Matrx login", file=sys.stderr)
301
+ return 1
302
+ try:
303
+ state, changed = _complete_matrx_login(state)
304
+ except ValueError as exc:
305
+ print(str(exc), file=sys.stderr)
306
+ return 1
307
+ if changed:
308
+ path = save_state(state)
309
+ print(f"Saved {args.provider} credentials to {path}")
310
+ print(f"Matrx base URL: {ensure_v1_url(state['auth']['matrx']['base_url'])}")
311
+ return 0
312
+ print("Matrx login did not change the current state", file=sys.stderr)
313
+ return 1
314
+
315
+ if not key:
316
+ print("--key is required for this login command", file=sys.stderr)
317
+ return 1
318
+
319
+ if provider == "matrx" and not key.startswith("mx_"):
320
+ print("Matrx key must start with mx_", file=sys.stderr)
321
+ return 1
322
+
323
+ state["auth"][provider]["key"] = key
324
+ path = save_state(state)
325
+ print(f"Saved {args.provider} credentials to {path}")
326
+ if provider == "matrx":
327
+ print(f"Matrx base URL: {ensure_v1_url(state['auth']['matrx']['base_url'])}")
328
+ return 0
329
+
330
+
331
+ def _cmd_use(args) -> int:
332
+ state = load_state()
333
+ if not _has_matrx_login(state, env=os.environ):
334
+ print("Login required. Run: mtrx login matrx --key mx_...", file=sys.stderr)
335
+ return 1
336
+ state["defaults"][args.tool] = args.route
337
+ path = save_state(state)
338
+ print(f"Default route for {args.tool}: {args.route}")
339
+ print(f"Saved to {path}")
340
+ return 0
341
+
342
+
343
+ def _cmd_status() -> int:
344
+ state = load_state()
345
+ auth = state["auth"]
346
+ print("Defaults:")
347
+ print(f" codex: {_default_route_label(configured_route(state, 'codex'))}")
348
+ print(f" claude: {_default_route_label(configured_route(state, 'claude'))}")
349
+ print("Auth:")
350
+ print(
351
+ " matrx: "
352
+ f"{mask_secret(auth['matrx'].get('key'))} "
353
+ f"({ensure_v1_url(auth['matrx'].get('base_url'))})"
354
+ )
355
+ print(f" matrx app: {ensure_app_url(auth['matrx'].get('app_url'), base_url=auth['matrx'].get('base_url'))}")
356
+ print(f" openai: {mask_secret(auth['openai'].get('key'))}")
357
+ print(f" anthropic: {mask_secret(auth['anthropic'].get('key'))}")
358
+ imported = "imported" if auth["claude_code"].get("oauth_token") else "not imported"
359
+ local = "present" if claude_oauth_available() else "missing"
360
+ print(f" claude-code oauth: {imported}, local credentials: {local}")
361
+ print("Tool config:")
362
+ for tool in ("codex", "claude"):
363
+ config_status = get_tool_config_status(state, tool)
364
+ route = configured_route(state, tool)
365
+ if tool == "codex" and config_status["verified"] and not config_status["configured"]:
366
+ status_label = "runtime-only" if route == "matrx" else "clean"
367
+ elif config_status["configured"] and config_status["verified"]:
368
+ status_label = "configured+verified"
369
+ elif config_status["configured"]:
370
+ status_label = "configured"
371
+ else:
372
+ status_label = "not configured"
373
+ location = config_status["config_path"] or "unknown"
374
+ verified_at = config_status["last_verified_at"] or "-"
375
+ print(f" {tool}: {status_label} ({location}, verified={verified_at})")
376
+ print("Executables:")
377
+ print(f" codex: {find_executable('codex') or 'not found'}")
378
+ print(f" claude: {find_executable('claude') or 'not found'}")
379
+ return 0
380
+
381
+
382
+ def _cmd_doctor() -> int:
383
+ state = load_state()
384
+ failures = 0
385
+ env_matrx_key = (os.environ.get("MTRX_KEY") or "").strip()
386
+ workspace_binding = get_workspace_binding(state, cwd=os.environ.get("PWD") or os.getcwd()) or {}
387
+ workspace_matrx_key = (workspace_binding.get("matrx_key") or "").strip()
388
+
389
+ for tool in ("codex", "claude"):
390
+ found = find_executable(tool)
391
+ if found:
392
+ print(f"[ok] {tool} executable: {found}")
393
+ else:
394
+ print(f"[fail] {tool} executable not found")
395
+ failures += 1
396
+
397
+ matrx_key = (state["auth"]["matrx"].get("key") or "").strip()
398
+ if matrx_key:
399
+ print("[ok] Matrx key saved")
400
+ elif workspace_matrx_key:
401
+ print("[warn] No Matrx key saved (current workspace has a stored MATRX binding)")
402
+ elif env_matrx_key:
403
+ print("[warn] No Matrx key saved (current shell has MTRX_KEY override)")
404
+ else:
405
+ print("[warn] No Matrx key saved")
406
+ if env_matrx_key:
407
+ print("[ok] MTRX_KEY override present in current shell")
408
+ if workspace_matrx_key:
409
+ print("[ok] Workspace MATRX binding present in current repo")
410
+
411
+ openai_key = (state["auth"]["openai"].get("key") or "").strip()
412
+ if openai_key:
413
+ print("[ok] OpenAI fallback key saved")
414
+ else:
415
+ print("[warn] No OpenAI fallback key saved")
416
+
417
+ anthropic_key = (state["auth"]["anthropic"].get("key") or "").strip()
418
+ if anthropic_key:
419
+ print("[ok] Anthropic fallback key saved")
420
+ else:
421
+ print("[warn] No Anthropic fallback key saved")
422
+
423
+ if claude_oauth_available():
424
+ print(f"[ok] Local Claude OAuth found at {claude_credentials_path()}")
425
+ else:
426
+ print(f"[warn] Local Claude OAuth not found at {claude_credentials_path()}")
427
+
428
+ for tool in ("codex", "claude"):
429
+ route = configured_route(state, tool)
430
+ if route == "matrx" and not _has_matrx_login(state, env=os.environ):
431
+ print(f"[fail] Default {tool} route is matrx but no Matrx key is saved")
432
+ failures += 1
433
+ elif route is None:
434
+ print(f"[warn] Default {tool} route not chosen yet (first `mtrx {tool}` launch will initialize it to matrx)")
435
+ config_status = get_tool_config_status(state, tool)
436
+ if tool == "codex" and config_status["verified"] and not config_status["configured"]:
437
+ if route == "matrx":
438
+ print(f"[ok] {tool} uses runtime launch overrides; native config is clean at {config_status['config_path']}")
439
+ else:
440
+ print(f"[ok] {tool} native config is clean at {config_status['config_path']}")
441
+ elif config_status["configured"] and config_status["verified"]:
442
+ print(f"[ok] {tool} native config verified at {config_status['config_path']}")
443
+ elif config_status["configured"]:
444
+ print(f"[warn] {tool} native config written but not verified")
445
+ else:
446
+ print(f"[warn] {tool} native config not configured")
447
+
448
+ return 1 if failures else 0
449
+
450
+
451
+ def _cmd_launch(tool: str, route: str | None, remainder: list[str]) -> int:
452
+ state = load_state()
453
+ route, promoted = _maybe_promote_direct_route(state, tool, route)
454
+ first_launch = route is None and configured_route(state, tool) is None
455
+ effective_route = resolve_route(state, tool, route)
456
+ try:
457
+ auth_changed = False
458
+ if effective_route == "matrx":
459
+ state, login_changed = _complete_matrx_login(state)
460
+ auth_changed = auth_changed or login_changed
461
+ if tool == "codex":
462
+ _complete_codex_login()
463
+ initialized = initialize_first_launch_route(state, tool, route)
464
+ state, changed = prepare_routed_setup(
465
+ state,
466
+ tool=tool,
467
+ route_override=route,
468
+ base_env=os.environ,
469
+ )
470
+ if initialized or changed or auth_changed or promoted:
471
+ save_state(state)
472
+ if initialized:
473
+ print(
474
+ f"First-time setup: default route for {tool} set to matrx. "
475
+ f"Use `mtrx use {tool} direct` to opt out.",
476
+ )
477
+ plan = build_launch_plan(
478
+ state,
479
+ tool=tool,
480
+ route_override=route,
481
+ passthrough_args=remainder,
482
+ base_env=os.environ,
483
+ )
484
+ validate_launch_plan(plan, state)
485
+ for line in describe_launch_plan(plan, state):
486
+ print(line)
487
+ except ValueError as exc:
488
+ print(str(exc), file=sys.stderr)
489
+ return 1
490
+ return launch(plan)
491
+
492
+
493
+ def _has_matrx_login(state: dict, env: dict[str, str] | None = None) -> bool:
494
+ env = env or {}
495
+ env_key = (env.get("MTRX_KEY") or "").strip()
496
+ if env_key:
497
+ return True
498
+ workspace_binding = get_workspace_binding(state, cwd=env.get("PWD") or os.getcwd()) or {}
499
+ workspace_key = (workspace_binding.get("matrx_key") or "").strip()
500
+ if workspace_key:
501
+ return True
502
+ return bool((state.get("auth", {}).get("matrx", {}).get("key") or "").strip())
503
+
504
+
505
+ def _default_route_label(route: str | None) -> str:
506
+ return route or "unset"
507
+
508
+
509
+ if __name__ == "__main__":
510
+ raise SystemExit(main())