from __future__ import annotations import base64 import json import subprocess from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional import typer import yaml from rich.console import Console from rich.table import Table from reyna_cli.config import Device, cache_path_for, get_device, load_registry from reyna_cli.deco_direct import DecoDirectClient from reyna_cli.desktop_service import SERVICE_NAME, service_action, service_status, unit_content, unit_path from reyna_cli.email_local import ThunderbirdEmailClient, email_tool_specs from reyna_cli.immich import ImmichClient from reyna_cli.mcp import MCPClient from reyna_cli.mongo_direct import MongoDirectClient from reyna_cli.remarkable import LISTENER_LABEL, listen_forever, listener_service_action, listener_service_status, sync_once from reyna_cli.tts import TTSError, synthesize_wav from reyna_cli.utils import infer_capabilities, resolve_tool_name from reyna_cli.zoom_direct import ZoomClient # Backward-compatible module alias used by existing CLI tests/mocks. MongoClient = MongoDirectClient app = typer.Typer(help="Reyna family CLI for on-demand local-device and service control.") devices_app = typer.Typer(help="Manage local devices (screen, laptop, iphone, arm).") screen_app = typer.Typer(help="ESP32 screen wrapper commands.") laptop_app = typer.Typer(help="Personal laptop MCP screen wrapper commands.") computer_app = typer.Typer(help="This computer's local desktop companion client.") iphone_app = typer.Typer(help="iPhone MCP app wrapper commands.") arm_app = typer.Typer(help="Robot arm wrapper commands.") immich_app = typer.Typer(help="Immich direct REST API commands.") mongo_app = typer.Typer(help="MongoDB direct driver commands.") zoom_app = typer.Typer(help="Zoom direct REST API commands.") email_app = typer.Typer(help="Read-only local Thunderbird email commands.") deco_app = typer.Typer(help="TP-Link Deco direct router commands.") macmini_app = typer.Typer(help="Mac mini MCP tools (Calendar, Contacts, Notes, Reminders, Deco).") remarkable_app = typer.Typer(help="Paper Pro discovery, local cache, and macOS listener service.") macmini_calendar_app = typer.Typer(help="Mac mini Calendar tools.") macmini_contacts_app = typer.Typer(help="Mac mini Contacts tools.") macmini_notes_app = typer.Typer(help="Mac mini Notes tools.") macmini_reminders_app = typer.Typer(help="Mac mini Reminders tools.") macmini_deco_app = typer.Typer(help="TP-Link Deco direct router commands (backward-compatible alias).") console = Console() devices_app.add_typer(screen_app, name="screen") devices_app.add_typer(laptop_app, name="laptop") devices_app.add_typer(computer_app, name="computer") devices_app.add_typer(iphone_app, name="iphone") devices_app.add_typer(arm_app, name="arm") app.add_typer(devices_app, name="devices") app.add_typer(screen_app, name="screen", hidden=True) app.add_typer(laptop_app, name="laptop", hidden=True) app.add_typer(computer_app, name="computer") app.add_typer(iphone_app, name="iphone", hidden=True) app.add_typer(arm_app, name="arm", hidden=True) app.add_typer(immich_app, name="immich") app.add_typer(mongo_app, name="mongo") app.add_typer(zoom_app, name="zoom") app.add_typer(email_app, name="email") app.add_typer(deco_app, name="deco") macmini_app.add_typer(macmini_calendar_app, name="calendar") macmini_app.add_typer(macmini_contacts_app, name="contacts") macmini_app.add_typer(macmini_notes_app, name="notes") macmini_app.add_typer(macmini_reminders_app, name="reminders") macmini_app.add_typer(macmini_deco_app, name="deco") app.add_typer(macmini_app, name="macmini") app.add_typer(remarkable_app, name="remarkable") def scrub_sensitive(value: Any) -> Any: if isinstance(value, dict): cleaned: Dict[str, Any] = {} for key, item in value.items(): if key.lower() in {"join_url", "start_url", "password", "encrypted_password", "pwd"}: cleaned[key] = "[REDACTED]" else: cleaned[key] = scrub_sensitive(item) return cleaned if isinstance(value, list): return [scrub_sensitive(v) for v in value] return value def emit(data: Any, json_output: bool = False) -> None: data = scrub_sensitive(data) if json_output: print(json.dumps(data, indent=2, default=str)) else: console.print(data) def fail(message: str, json_output: bool = False, **extra: Any) -> None: payload = {"ok": False, "error": message, **extra} emit(payload if json_output else f"[red]{message}[/red]", json_output) raise typer.Exit(1) def get_required_device(name: str, json_output: bool = False) -> Device: device = get_device(name) if not device: fail(f"Device '{name}' not found", json_output) return device def parse_args_json(args: str, json_output: bool = False) -> Dict[str, Any]: try: parsed = json.loads(args) except json.JSONDecodeError as exc: fail(f"Invalid JSON for --args: {exc}", json_output) raise typer.Exit(1) if not isinstance(parsed, dict): fail("--args must decode to a JSON object", json_output) raise typer.Exit(1) return parsed def parse_pipeline_json(pipeline: str, json_output: bool = False) -> List[Dict[str, Any]]: try: parsed = json.loads(pipeline) except json.JSONDecodeError as exc: fail(f"Invalid JSON for --pipeline: {exc}", json_output) raise typer.Exit(1) if not isinstance(parsed, list): fail("--pipeline must decode to a JSON array", json_output) raise typer.Exit(1) return parsed def scrub_binary(value: Any, out: Optional[Path] = None) -> Any: if isinstance(value, dict): cleaned: Dict[str, Any] = {} for key, item in value.items(): if key in {"data", "audio_base64", "image_base64", "wav_base64"} and isinstance(item, str) and len(item) > 200: if out: out.parent.mkdir(parents=True, exist_ok=True) raw = item.split(",", 1)[-1] out.write_bytes(base64.b64decode(raw)) cleaned[key] = f"" cleaned["path"] = str(out) else: cleaned[key] = f"" else: cleaned[key] = scrub_binary(item, out) return cleaned if isinstance(value, list): return [scrub_binary(v, out) for v in value] return value def try_live_tools(url: str, device_id: str = "bridge") -> Dict[str, Any]: client = MCPClient(url) try: tools = client.list_tools() return {"ok": True, "device": device_id, "url": url, "source": "live", "tools": tools} except Exception as exc: return {"ok": False, "device": device_id, "error": "unreachable", "message": str(exc)} def read_cache(device: Device) -> Optional[Dict[str, Any]]: path = cache_path_for(device.id) if not path.exists(): return None return json.loads(path.read_text(encoding="utf-8")) def write_cache(device: Device, tools: List[Dict[str, Any]], url: str = "") -> None: path = cache_path_for(device.id) path.parent.mkdir(parents=True, exist_ok=True) payload = { "ok": True, "device": device.id, "url": url, "source": "cache", "cached_at": datetime.now(timezone.utc).isoformat(), "tools": tools, } path.write_text(json.dumps(payload, indent=2), encoding="utf-8") def get_tools(device: Device, live_only: bool = False, cache_only: bool = False, refresh: bool = False) -> Dict[str, Any]: if cache_only and not refresh: cached = read_cache(device) return cached or {"ok": False, "device": device.id, "error": "no_cache"} tried: List[str] = [] last_error = "" for url in device.candidate_urls(): tried.append(url) live = try_live_tools(url, device.id) if live["ok"]: write_cache(device, live["tools"], url) return live last_error = live.get("message", "") if live_only: return {"ok": False, "device": device.id, "error": "device_unreachable", "message": last_error, "tried": tried} cached = read_cache(device) if cached: cached = dict(cached) cached["warning"] = f"Device unreachable; showing cached tools from {cached.get('cached_at', 'unknown time')}." cached["live_error"] = last_error return cached return {"ok": False, "device": device.id, "error": "device_unreachable", "message": last_error, "tried": tried} def call_device_tool(device: Device, tool_name: str, arguments: Dict[str, Any], binary_out: Optional[Path] = None) -> Dict[str, Any]: errors: List[str] = [] for url in device.candidate_urls(): try: client = MCPClient(url) result = client.call_tool(tool_name, arguments) return {"ok": True, "device": device.id, "url": url, "tool_used": tool_name, "result": scrub_binary(result, binary_out)} except Exception as exc: errors.append(f"{url}: {exc}") return {"ok": False, "device": device.id, "error": "tool_call_failed", "tool": tool_name, "tried": errors} def read_meminfo() -> Dict[str, str]: info: Dict[str, str] = {} try: for line in Path("/proc/meminfo").read_text().splitlines(): key, value = line.split(":", 1) info[key.strip()] = value.strip() except Exception: pass return info def ps_mcp_processes() -> List[Dict[str, Any]]: result = subprocess.run(["ps", "-eo", "pid,ppid,rss,comm,args"], capture_output=True, text=True, check=False) rows: List[Dict[str, Any]] = [] for line in result.stdout.splitlines()[1:]: stripped = line.strip() if not stripped: continue try: pid, ppid, rss, comm, cmd = stripped.split(None, 4) except ValueError: continue # Avoid false positives from Hermes shell wrapper commands that merely # contain MCP names in an inline script. if comm not in {"python", "python3", "node", "npm", "sh"}: continue lower = cmd.lower() service = "" if "immich_mcp" in lower or "immich-mcp" in lower: service = "immich" elif "mongodb-mcp" in lower or "8630" in lower: service = "mongodb" elif "zoom-mcp" in lower or "kindflow/zoom" in lower: service = "zoom" if service: rows.append({"pid": int(pid), "ppid": int(ppid), "rss_kb": int(rss), "rss_mb": round(int(rss) / 1024, 1), "service": service, "cmd": cmd}) return rows def hermes_mcp_servers() -> Dict[str, Any]: path = Path.home() / ".hermes" / "config.yaml" if not path.exists(): return {} try: data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} except Exception: return {} servers = data.get("mcp_servers", {}) or {} redacted: Dict[str, Any] = {} for name, cfg in servers.items(): public = {k: v for k, v in (cfg or {}).items() if k in {"url", "command", "args", "timeout", "connect_timeout"}} if "headers" in (cfg or {}): public["headers"] = "[REDACTED]" if "env" in (cfg or {}): public["env"] = {key: "[SET]" for key in cfg.get("env", {})} redacted[name] = public return redacted def hermes_mcp_server_config(name: str = "macmini") -> Dict[str, Any]: path = Path.home() / ".hermes" / "config.yaml" if not path.exists(): raise RuntimeError(f"Hermes config not found at {path}") data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} cfg = (data.get("mcp_servers", {}) or {}).get(name) if not cfg: raise RuntimeError(f"MCP server '{name}' is not configured in {path}") if not cfg.get("url"): raise RuntimeError(f"MCP server '{name}' does not have an HTTP url") return cfg def macmini_client() -> MCPClient: cfg = hermes_mcp_server_config("macmini") timeout = float(cfg.get("timeout", 120)) headers = {str(k): str(v) for k, v in (cfg.get("headers") or {}).items()} return MCPClient(str(cfg["url"]), timeout=timeout, headers=headers) def normalize_mcp_result(result: Dict[str, Any]) -> Any: content = result.get("content") if isinstance(content, list) and len(content) == 1 and isinstance(content[0], dict): text = content[0].get("text") if isinstance(text, str): try: return json.loads(text) except json.JSONDecodeError: return text if "structuredContent" in result: return result["structuredContent"] return result def call_macmini_tool(tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: result = macmini_client().call_tool(tool_name, arguments) return {"ok": True, "source": "mcp", "server": "macmini", "tool": tool_name, "result": normalize_mcp_result(result)} def build_ram_plan() -> Dict[str, Any]: replacements = { "immich": "reyna-cli immich stats|albums|search (direct REST)", "mongodb": "reyna-cli mongo dbs|collections|count|find|aggregate (direct pymongo)", "zoom": "reyna-cli zoom users|meetings|recordings (direct Zoom REST)", } configured = hermes_mcp_servers() processes = ps_mcp_processes() total_rss_mb = round(sum(p["rss_kb"] for p in processes) / 1024, 1) removable = [name for name in configured if name in replacements] return { "memory": read_meminfo(), "configured_mcp_servers": configured, "running_mcp_processes": processes, "running_mcp_rss_mb": total_rss_mb, "direct_replacements": replacements, "candidate_mcp_removals_after_smoke_tests": removable, "keep_for_now": [name for name in configured if name not in replacements], "safe_next_step": "After direct commands smoke-test successfully, disable/remove candidate MCP servers and restart Hermes so their schemas leave context.", } @app.command() def doctor(json_output: bool = typer.Option(False, "--json"), ram_plan: bool = typer.Option(False, "--ram-plan")): registry = load_registry() payload: Dict[str, Any] = { "ok": True, "registry_path": registry.path, "device_count": len(registry.devices), "cache_dir": str(cache_path_for("example").parent), } if ram_plan: payload["ram_plan"] = build_ram_plan() payload["ram_diagnostics"] = payload["ram_plan"]["memory"] if json_output: emit(payload, True) else: console.print(f"Registry: {registry.path or 'not found'}") console.print(f"Devices: {len(registry.devices)}") console.print(f"Cache: {payload['cache_dir']}") if ram_plan: plan = payload["ram_plan"] console.print(f"Running MCP RSS: {plan['running_mcp_rss_mb']} MB") console.print(f"Candidate removals after tests: {', '.join(plan['candidate_mcp_removals_after_smoke_tests']) or 'none'}") console.print(f"Keep for now: {', '.join(plan['keep_for_now']) or 'none'}") @devices_app.command("list") def devices_list(json_output: bool = typer.Option(False, "--json")): registry = load_registry() payload = {"ok": True, "registry_path": registry.path, "devices": [d.public_dict() for d in registry.devices]} if json_output: emit(payload, True) else: table = Table(title="Local devices") table.add_column("ID") table.add_column("Name") table.add_column("URL") table.add_column("Fallback") for d in registry.devices: table.add_row(d.id, d.display_name, d.url, d.fallback_url) console.print(table) @devices_app.command("ping") def devices_ping(name: str, json_output: bool = typer.Option(False, "--json")): device = get_required_device(name, json_output) result = get_tools(device, live_only=True) payload = {"ok": bool(result.get("ok")), "device": device.id, "online": bool(result.get("ok")), "tried": result.get("tried", [])} emit(payload, json_output) if not payload["ok"]: raise typer.Exit(1) @devices_app.command("tools") def devices_tools_cmd(name: str, json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False): device = get_required_device(name, json_output) result = get_tools(device, live_only=live_only, cache_only=cache_only, refresh=refresh) emit(result, json_output) if not result.get("ok"): raise typer.Exit(1) @devices_app.command("call") def devices_call_cmd(name: str, tool: str, args: str = typer.Option("{}", "--args"), json_output: bool = typer.Option(False, "--json")): device = get_required_device(name, json_output) result = call_device_tool(device, tool, parse_args_json(args, json_output)) emit(result, json_output) if not result.get("ok"): raise typer.Exit(1) @devices_app.command("describe") def devices_describe_cmd(name: str, json_output: bool = typer.Option(False, "--json"), for_hermes: bool = typer.Option(False, "--for-hermes")): device = get_required_device(name, json_output) tools_result = get_tools(device) tools = tools_result.get("tools", []) if tools_result.get("ok") else [] caps = infer_capabilities(tools) payload = {"ok": tools_result.get("ok", False), "device": device.public_dict(), "tools_source": tools_result.get("source"), "capabilities": caps, "tools": tools} if for_hermes and not json_output: lines = [f"{device.display_name or device.id}: {'online' if payload['ok'] else 'offline/cache unavailable'}."] for cap, tool in caps.items(): if tool: lines.append(f"- {cap}: use wrapper command or MCP tool {tool}") lines.append(f"Use: reyna-cli devices tools {device.id} --json for the live/cached schema.") console.print("\n".join(lines)) else: emit(payload, json_output) if not payload["ok"] and json_output: raise typer.Exit(1) def wrapper_tools(default_device: str, json_output: bool, live_only: bool = False, cache_only: bool = False, refresh: bool = False): device = get_required_device(default_device, json_output) emit(get_tools(device, live_only, cache_only, refresh), json_output) def wrapper_describe(default_device: str, json_output: bool, for_hermes: bool): devices_describe_cmd(default_device, json_output=json_output, for_hermes=for_hermes) def wrapper_capability(default_device: str, capability: str, payload: Dict[str, Any], json_output: bool, binary_out: Optional[Path] = None): device = get_required_device(default_device, json_output) tools_result = get_tools(device) tools = tools_result.get("tools", []) if tools_result.get("ok") else [] tool_name = resolve_tool_name(capability, tools) if not tool_name: fail(f"Capability '{capability}' unavailable on {device.id}", json_output, available_tools=[t.get("name") for t in tools]) assert tool_name is not None if binary_out is None: result = call_device_tool(device, tool_name, payload) else: result = call_device_tool(device, tool_name, payload, binary_out=binary_out) emit(result, json_output) if not result.get("ok"): raise typer.Exit(1) def build_tts_payload(message: str, wav_path: Path, volume: int, tts_command: Optional[str], voice: Optional[str], sample_rate: int) -> Dict[str, Any]: wav_file = synthesize_wav(message, wav_path, command_template=tts_command, voice=voice, sample_rate=sample_rate) wav_base64 = base64.b64encode(wav_file.read_bytes()).decode("ascii") return {"wav_base64": wav_base64, "volume": volume} def wrapper_speak_tts( default_device: str, message: str, json_output: bool, volume: int, out: Path, tts_command: Optional[str], voice: Optional[str], sample_rate: int, ): try: payload = build_tts_payload(message, out, volume, tts_command, voice, sample_rate) except TTSError as exc: fail(str(exc), json_output) raise typer.Exit(1) wrapper_capability(default_device, "speech", payload, json_output) @screen_app.command("tools") def screen_tools(json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False): wrapper_tools("esp32_screen", json_output, live_only, cache_only, refresh) @screen_app.command("describe") def screen_describe(json_output: bool = typer.Option(False, "--json"), for_hermes: bool = typer.Option(False, "--for-hermes")): wrapper_describe("esp32_screen", json_output, for_hermes) @screen_app.command("text") def screen_text(message: str, json_output: bool = typer.Option(False, "--json"), x: int = 10, y: int = 20, size: int = 2): wrapper_capability("esp32_screen", "text", {"text": message, "x": x, "y": y, "size": size}, json_output) @screen_app.command("speak") def screen_speak( message: str, json_output: bool = typer.Option(False, "--json"), volume: int = typer.Option(50, "--volume", min=0, max=100), out: Path = typer.Option(Path("/tmp/reyna-screen-speech.wav"), "--out"), tts_command: Optional[str] = typer.Option(None, "--tts-command", help="Command template that writes audio to {out} or stdout. Supports {text}, {raw_text}, {voice}."), voice: Optional[str] = typer.Option(None, "--voice"), sample_rate: int = typer.Option(16000, "--sample-rate"), ): wrapper_speak_tts("esp32_screen", message, json_output, volume, out, tts_command, voice, sample_rate) @screen_app.command("clear") def screen_clear(json_output: bool = typer.Option(False, "--json"), color: int = 0): wrapper_capability("esp32_screen", "clear", {"color": color}, json_output) @screen_app.command("screenshot") def screen_screenshot(out: Path = typer.Option(Path("/tmp/esp32-screen.png"), "--out"), json_output: bool = typer.Option(False, "--json")): wrapper_capability("esp32_screen", "screenshot", {"out": str(out)}, json_output, binary_out=out) @screen_app.command("battery") def screen_battery(json_output: bool = typer.Option(False, "--json")): wrapper_capability("esp32_screen", "battery", {}, json_output) @laptop_app.command("tools") def laptop_tools(json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False): wrapper_tools("personal_laptop_screen", json_output, live_only, cache_only, refresh) @laptop_app.command("describe") def laptop_describe(json_output: bool = typer.Option(False, "--json"), for_hermes: bool = typer.Option(False, "--for-hermes")): wrapper_describe("personal_laptop_screen", json_output, for_hermes) @laptop_app.command("call") def laptop_call(tool: str, args: str = typer.Option("{}", "--args"), json_output: bool = typer.Option(False, "--json")): device = get_required_device("personal_laptop_screen", json_output) result = call_device_tool(device, tool, parse_args_json(args, json_output)) emit(result, json_output) if not result.get("ok"): raise typer.Exit(1) @laptop_app.command("text") def laptop_text(message: str, json_output: bool = typer.Option(False, "--json"), x: int = 10, y: int = 20, size: int = 2): wrapper_capability("personal_laptop_screen", "text", {"text": message, "x": x, "y": y, "size": size}, json_output) @laptop_app.command("speak") def laptop_speak( message: str, json_output: bool = typer.Option(False, "--json"), volume: int = typer.Option(50, "--volume", min=0, max=100), out: Path = typer.Option(Path("/tmp/reyna-laptop-speech.wav"), "--out"), tts_command: Optional[str] = typer.Option(None, "--tts-command", help="Command template that writes audio to {out} or stdout. Supports {text}, {raw_text}, {voice}."), voice: Optional[str] = typer.Option(None, "--voice"), sample_rate: int = typer.Option(16000, "--sample-rate"), ): wrapper_speak_tts("personal_laptop_screen", message, json_output, volume, out, tts_command, voice, sample_rate) @laptop_app.command("clear") def laptop_clear(json_output: bool = typer.Option(False, "--json"), color: int = 0): wrapper_capability("personal_laptop_screen", "clear", {"color": color}, json_output) @laptop_app.command("screenshot") def laptop_screenshot(out: Path = typer.Option(Path("/tmp/laptop-mcp.png"), "--out"), json_output: bool = typer.Option(False, "--json")): wrapper_capability("personal_laptop_screen", "screenshot", {"out": str(out)}, json_output, binary_out=out) @laptop_app.command("battery") def laptop_battery(json_output: bool = typer.Option(False, "--json")): wrapper_capability("personal_laptop_screen", "battery", {}, json_output) @laptop_app.command("sensors") def laptop_sensors(json_output: bool = typer.Option(False, "--json")): wrapper_capability("personal_laptop_screen", "sensors", {}, json_output) @laptop_app.command("stats") def laptop_stats(json_output: bool = typer.Option(False, "--json")): wrapper_capability("personal_laptop_screen", "stream_stats", {}, json_output) @computer_app.command("tools") def computer_tools(json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False): wrapper_tools("local_desktop_screen", json_output, live_only, cache_only, refresh) @computer_app.command("describe") def computer_describe(json_output: bool = typer.Option(False, "--json"), for_hermes: bool = typer.Option(False, "--for-hermes")): wrapper_describe("local_desktop_screen", json_output, for_hermes) @computer_app.command("call") def computer_call(tool: str, args: str = typer.Option("{}", "--args"), json_output: bool = typer.Option(False, "--json")): device = get_required_device("local_desktop_screen", json_output) result = call_device_tool(device, tool, parse_args_json(args, json_output)) emit(result, json_output) if not result.get("ok"): raise typer.Exit(1) @computer_app.command("text") def computer_text(message: str, json_output: bool = typer.Option(False, "--json"), x: int = 10, y: int = 20, size: int = 2): wrapper_capability("local_desktop_screen", "text", {"text": message, "x": x, "y": y, "size": size}, json_output) @computer_app.command("speak") def computer_speak( message: str, json_output: bool = typer.Option(False, "--json"), volume: int = typer.Option(50, "--volume", min=0, max=100), out: Path = typer.Option(Path("/tmp/reyna-computer-speech.wav"), "--out"), tts_command: Optional[str] = typer.Option(None, "--tts-command", help="Command template that writes audio to {out} or stdout. Supports {text}, {raw_text}, {voice}."), voice: Optional[str] = typer.Option(None, "--voice"), sample_rate: int = typer.Option(16000, "--sample-rate"), ): wrapper_speak_tts("local_desktop_screen", message, json_output, volume, out, tts_command, voice, sample_rate) @computer_app.command("clear") def computer_clear(json_output: bool = typer.Option(False, "--json"), color: int = 0): wrapper_capability("local_desktop_screen", "clear", {"color": color}, json_output) @computer_app.command("screenshot") def computer_screenshot(out: Path = typer.Option(Path("/tmp/reyna-computer-mcp.png"), "--out"), json_output: bool = typer.Option(False, "--json")): wrapper_capability("local_desktop_screen", "screenshot", {"out": str(out)}, json_output, binary_out=out) @computer_app.command("battery") def computer_battery(json_output: bool = typer.Option(False, "--json")): wrapper_capability("local_desktop_screen", "battery", {}, json_output) @computer_app.command("sensors") def computer_sensors(json_output: bool = typer.Option(False, "--json")): wrapper_capability("local_desktop_screen", "sensors", {}, json_output) @computer_app.command("stats") def computer_stats(json_output: bool = typer.Option(False, "--json")): wrapper_capability("local_desktop_screen", "stream_stats", {}, json_output) @computer_app.command("service-install") def computer_service_install(json_output: bool = typer.Option(False, "--json"), enable: bool = typer.Option(True, "--enable/--no-enable")): result = service_action("install", enable=enable) if json_output: emit({**result, "unit_content": unit_content()}, True) else: console.print(f"Installed {SERVICE_NAME} at {unit_path()}") for item in result["results"]: if item["stderr"]: console.print(item["stderr"]) if not result.get("ok"): raise typer.Exit(1) @computer_app.command("service-status") def computer_service_status(json_output: bool = typer.Option(False, "--json")): emit({"ok": True, "service": SERVICE_NAME, "status": service_status()}, json_output) @computer_app.command("service-start") def computer_service_start(json_output: bool = typer.Option(False, "--json")): result = service_action("start") emit(result, json_output) if not result.get("ok"): raise typer.Exit(1) @computer_app.command("service-stop") def computer_service_stop(json_output: bool = typer.Option(False, "--json")): result = service_action("stop") emit(result, json_output) if not result.get("ok"): raise typer.Exit(1) @computer_app.command("service-restart") def computer_service_restart(json_output: bool = typer.Option(False, "--json")): result = service_action("restart") emit(result, json_output) if not result.get("ok"): raise typer.Exit(1) @computer_app.command("service-logs") def computer_service_logs(lines: int = typer.Option(80, "--lines")): subprocess.run(["journalctl", "--user", "-u", SERVICE_NAME, "-n", str(lines), "--no-pager"], check=False) @remarkable_app.command("listen") def remarkable_listen(): """Run the UDP receiver in the foreground (used by the LaunchAgent).""" listen_forever() @remarkable_app.command("sync") def remarkable_sync(json_output: bool = typer.Option(False, "--json")): """Snapshot tracked notes and cache only fresh Paper Pro page versions.""" result = sync_once() emit({"ok": bool(result.get("online")), "result": result}, json_output) @remarkable_app.command("service-install") def remarkable_service_install(json_output: bool = typer.Option(False, "--json")): """Install and start the macOS UDP-listener LaunchAgent.""" result = listener_service_action("install") emit(result, json_output) if not result.get("ok"): raise typer.Exit(1) @remarkable_app.command("service-status") def remarkable_service_status(json_output: bool = typer.Option(False, "--json")): emit({"ok": True, "service": LISTENER_LABEL, "status": listener_service_status()}, json_output) @remarkable_app.command("service-start") def remarkable_service_start(json_output: bool = typer.Option(False, "--json")): result = listener_service_action("start") emit(result, json_output) if not result.get("ok"): raise typer.Exit(1) @remarkable_app.command("service-stop") def remarkable_service_stop(json_output: bool = typer.Option(False, "--json")): result = listener_service_action("stop") emit(result, json_output) if not result.get("ok"): raise typer.Exit(1) @iphone_app.command("tools") def iphone_tools(json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False): wrapper_tools("iphone_mcp", json_output, live_only, cache_only, refresh) @iphone_app.command("describe") def iphone_describe(json_output: bool = typer.Option(False, "--json"), for_hermes: bool = typer.Option(False, "--for-hermes")): wrapper_describe("iphone_mcp", json_output, for_hermes) @iphone_app.command("text") def iphone_text(message: str, json_output: bool = typer.Option(False, "--json")): wrapper_capability("iphone_mcp", "text", {"text": message}, json_output) @iphone_app.command("speak") def iphone_speak( message: str, json_output: bool = typer.Option(False, "--json"), volume: int = typer.Option(50, "--volume", min=0, max=100), out: Path = typer.Option(Path("/tmp/reyna-iphone-speech.wav"), "--out"), tts_command: Optional[str] = typer.Option(None, "--tts-command", help="Command template that writes audio to {out} or stdout. Supports {text}, {raw_text}, {voice}."), voice: Optional[str] = typer.Option(None, "--voice"), sample_rate: int = typer.Option(16000, "--sample-rate"), ): wrapper_speak_tts("iphone_mcp", message, json_output, volume, out, tts_command, voice, sample_rate) @iphone_app.command("screenshot") def iphone_screenshot(out: Path = typer.Option(Path("/tmp/iphone-mcp.png"), "--out"), json_output: bool = typer.Option(False, "--json")): wrapper_capability("iphone_mcp", "screenshot", {"out": str(out)}, json_output, binary_out=out) @iphone_app.command("battery") def iphone_battery(json_output: bool = typer.Option(False, "--json")): wrapper_capability("iphone_mcp", "battery", {}, json_output) @arm_app.command("tools") def arm_tools(json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False): wrapper_tools("robot_arm", json_output, live_only, cache_only, refresh) @arm_app.command("describe") def arm_describe(json_output: bool = typer.Option(False, "--json"), for_hermes: bool = typer.Option(False, "--for-hermes")): wrapper_describe("robot_arm", json_output, for_hermes) @arm_app.command("call") def arm_call(tool: str, args: str = typer.Option("{}", "--args"), json_output: bool = typer.Option(False, "--json")): device = get_required_device("robot_arm", json_output) result = call_device_tool(device, tool, parse_args_json(args, json_output)) emit(result, json_output) if not result.get("ok"): raise typer.Exit(1) @arm_app.command("state") def arm_state(json_output: bool = typer.Option(False, "--json")): wrapper_capability("robot_arm", "state", {}, json_output) @arm_app.command("battery") def arm_battery(json_output: bool = typer.Option(False, "--json")): wrapper_capability("robot_arm", "battery", {}, json_output) @arm_app.command("home") def arm_home(json_output: bool = typer.Option(False, "--json")): wrapper_capability("robot_arm", "home", {}, json_output) @arm_app.command("wave") def arm_wave(json_output: bool = typer.Option(False, "--json")): wrapper_capability("robot_arm", "wave", {}, json_output) def immich_tool_specs() -> List[Dict[str, Any]]: return [ {"name": "stats", "description": "Get Immich server statistics"}, {"name": "albums", "description": "List Immich albums"}, {"name": "search", "description": "Smart-search assets", "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}}}, ] @immich_app.command("tools") def immich_tools(json_output: bool = typer.Option(False, "--json")): emit({"ok": True, "source": "direct", "tools": immich_tool_specs()}, json_output) @immich_app.command("describe") def immich_describe(json_output: bool = typer.Option(False, "--json")): emit({"ok": True, "source": "direct", "capabilities": {"stats": "stats", "albums": "albums", "search": "search"}, "tools": immich_tool_specs()}, json_output) @immich_app.command("call") def immich_call(tool: str, args: str = typer.Option("{}", "--args"), json_output: bool = typer.Option(False, "--json")): client = ImmichClient() parsed_args = parse_args_json(args, json_output) try: if tool == "stats": result = client.statistics() elif tool == "albums": result = client.albums() elif tool == "search": result = client.search(str(parsed_args.get("query", "")), int(parsed_args.get("limit", parsed_args.get("size", 10)))) else: fail(f"Unknown tool: {tool}", json_output) raise typer.Exit(1) emit({"ok": True, "source": "direct", "tool": tool, "result": result}, json_output) except Exception as exc: fail(str(exc), json_output) @immich_app.command("stats") def immich_stats(json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "direct", "result": ImmichClient().statistics()}, json_output) except Exception as exc: fail(str(exc), json_output) @immich_app.command("albums") def immich_albums(json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "direct", "result": ImmichClient().albums()}, json_output) except Exception as exc: fail(str(exc), json_output) @immich_app.command("search") def immich_search(query: str, limit: int = 10, json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "direct", "result": ImmichClient().search(query, limit)}, json_output) except Exception as exc: fail(str(exc), json_output) def mongo_tool_specs() -> List[Dict[str, Any]]: return [ {"name": "dbs", "description": "List databases"}, {"name": "collections", "description": "List collections in a database"}, {"name": "count", "description": "Count documents in a collection"}, {"name": "find", "description": "Find documents in a collection"}, {"name": "aggregate", "description": "Run an aggregation pipeline"}, ] @mongo_app.command("tools") def mongo_tools(json_output: bool = typer.Option(False, "--json")): emit({"ok": True, "source": "direct", "tools": mongo_tool_specs()}, json_output) @mongo_app.command("describe") def mongo_describe(json_output: bool = typer.Option(False, "--json")): caps = {"dbs": "dbs", "collections": "collections", "count": "count", "find": "find", "aggregate": "aggregate"} emit({"ok": True, "source": "direct", "capabilities": caps, "tools": mongo_tool_specs()}, json_output) @mongo_app.command("call") def mongo_call(tool: str, args: str = typer.Option("{}", "--args"), json_output: bool = typer.Option(False, "--json")): client = MongoDirectClient() parsed = parse_args_json(args, json_output) try: if tool == "dbs": result = client.databases() elif tool == "collections": result = client.collections(str(parsed.get("database", ""))) elif tool == "count": result = client.count(str(parsed.get("database", "")), str(parsed.get("collection", "")), parsed.get("query", parsed.get("filter", {}))) elif tool == "find": result = client.find(str(parsed.get("database", "")), str(parsed.get("collection", "")), parsed.get("filter", {}), int(parsed.get("limit", 10))) elif tool == "aggregate": result = client.aggregate(str(parsed.get("database", "")), str(parsed.get("collection", "")), parsed.get("pipeline", [])) else: fail(f"Unknown tool: {tool}", json_output) raise typer.Exit(1) emit({"ok": True, "source": "direct", "tool": tool, "result": result}, json_output) except Exception as exc: fail(str(exc), json_output) @mongo_app.command("dbs") def mongo_dbs(json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "direct", "result": MongoClient().databases()}, json_output) except Exception as exc: fail(str(exc), json_output) @mongo_app.command("collections") def mongo_collections(db: str, json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "direct", "result": MongoClient().collections(db)}, json_output) except Exception as exc: fail(str(exc), json_output) @mongo_app.command("count") def mongo_count(db: str, collection: str, filter: str = typer.Option("{}", "--filter"), json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "direct", "result": MongoClient().count(db, collection, parse_args_json(filter, json_output))}, json_output) except Exception as exc: fail(str(exc), json_output) @mongo_app.command("find") def mongo_find(db: str, collection: str, filter: str = typer.Option("{}", "--filter"), limit: int = 10, json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "direct", "result": MongoClient().find(db, collection, parse_args_json(filter, json_output), limit)}, json_output) except Exception as exc: fail(str(exc), json_output) @mongo_app.command("aggregate") def mongo_aggregate(db: str, collection: str, pipeline: str = typer.Option("[]", "--pipeline"), json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "direct", "result": MongoClient().aggregate(db, collection, parse_pipeline_json(pipeline, json_output))}, json_output) except Exception as exc: fail(str(exc), json_output) def zoom_tool_specs() -> List[Dict[str, Any]]: return [ {"name": "users", "description": "List Zoom users"}, {"name": "user", "description": "Get one Zoom user"}, {"name": "meetings", "description": "List meetings for a user"}, {"name": "recordings", "description": "List cloud recordings for a user"}, ] @zoom_app.command("tools") def zoom_tools(json_output: bool = typer.Option(False, "--json")): emit({"ok": True, "source": "direct", "tools": zoom_tool_specs()}, json_output) @zoom_app.command("users") def zoom_users(page_size: int = 30, status: str = "active", json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "direct", "result": ZoomClient().users(page_size, status)}, json_output) except Exception as exc: fail(str(exc), json_output) @zoom_app.command("user") def zoom_user(user_id: str = "me", json_output: bool = typer.Option(False, "--json")): try: client = ZoomClient() result = client.users() if user_id == "me" else client.user(user_id) emit({"ok": True, "source": "direct", "result": result}, json_output) except Exception as exc: fail(str(exc), json_output) @zoom_app.command("meetings") def zoom_meetings(user_id: str = "me", meeting_type: str = "scheduled", page_size: int = 30, json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "direct", "result": ZoomClient().meetings(user_id, meeting_type, page_size)}, json_output) except Exception as exc: fail(str(exc), json_output) @zoom_app.command("recordings") def zoom_recordings(user_id: str = "me", from_date: Optional[str] = typer.Option(None, "--from"), to_date: Optional[str] = typer.Option(None, "--to"), page_size: int = 30, json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "direct", "result": ZoomClient().recordings(user_id, from_date, to_date, page_size)}, json_output) except Exception as exc: fail(str(exc), json_output) @email_app.command("tools") def email_tools(json_output: bool = typer.Option(False, "--json")): emit({"ok": True, "source": "local_thunderbird", "tools": email_tool_specs()}, json_output) @email_app.command("doctor") def email_doctor(json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "local_thunderbird", "result": ThunderbirdEmailClient().config_status()}, json_output) except Exception as exc: fail(str(exc), json_output) @email_app.command("folders") def email_folders(json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "local_thunderbird", "result": ThunderbirdEmailClient().folders()}, json_output) except Exception as exc: fail(str(exc), json_output) @email_app.command("latest") def email_latest(folder: Optional[str] = typer.Option(None, "--folder"), limit: int = 10, json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "local_thunderbird", "result": ThunderbirdEmailClient().latest(folder=folder, limit=limit)}, json_output) except Exception as exc: fail(str(exc), json_output) @email_app.command("search") def email_search(query: str, folder: Optional[str] = typer.Option(None, "--folder"), limit: int = 10, any_term: bool = typer.Option(False, "--any", help="Match any query term instead of requiring every term."), json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "local_thunderbird", "result": ThunderbirdEmailClient().search(query, folder=folder, limit=limit, require_all=not any_term)}, json_output) except Exception as exc: fail(str(exc), json_output) @email_app.command("summarize") def email_summarize(query: str, folder: Optional[str] = typer.Option(None, "--folder"), limit: int = 10, json_output: bool = typer.Option(False, "--json")): """Return concise matching email facts and snippets for a query.""" try: emit({"ok": True, "source": "local_thunderbird", "result": ThunderbirdEmailClient().search(query, folder=folder, limit=limit)}, json_output) except Exception as exc: fail(str(exc), json_output) @email_app.command("find-receipts") def email_find_receipts(query: str = typer.Argument(""), limit: int = 10, json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "local_thunderbird", "result": ThunderbirdEmailClient().receipt_search(query=query, limit=limit)}, json_output) except Exception as exc: fail(str(exc), json_output) @email_app.command("show") def email_show(message_id: str, folder: Optional[str] = typer.Option(None, "--folder"), body: bool = typer.Option(False, "--body", help="Include a redacted body preview."), json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "local_thunderbird", "result": ThunderbirdEmailClient().show(message_id, folder=folder, include_body=body)}, json_output) except Exception as exc: fail(str(exc), json_output) @macmini_app.command("tools") def macmini_tools(json_output: bool = typer.Option(False, "--json")): try: emit({"ok": True, "source": "mcp", "server": "macmini", "tools": macmini_client().list_tools()}, json_output) except Exception as exc: fail(str(exc), json_output) @macmini_app.command("call") def macmini_call(tool: str, args: str = typer.Option("{}", "--args"), json_output: bool = typer.Option(False, "--json")): try: emit(call_macmini_tool(tool, parse_args_json(args, json_output)), json_output) except Exception as exc: fail(str(exc), json_output) @macmini_app.command("ping") def macmini_ping(json_output: bool = typer.Option(False, "--json")): try: ok = macmini_client().ping() emit({"ok": ok, "server": "macmini", "online": ok}, json_output) if not ok: raise typer.Exit(1) except Exception as exc: fail(str(exc), json_output) @macmini_calendar_app.command("calendars") def macmini_calendar_calendars(json_output: bool = typer.Option(False, "--json")): try: emit(call_macmini_tool("calendar_list_calendars", {}), json_output) except Exception as exc: fail(str(exc), json_output) @macmini_calendar_app.command("events") def macmini_calendar_events(start: str, end: str, calendar: Optional[str] = None, calendar_index: Optional[int] = typer.Option(None, "--calendar-index"), limit: int = 50, json_output: bool = typer.Option(False, "--json")): args: Dict[str, Any] = {"start": start, "end": end, "limit": limit} if calendar is not None: args["calendar"] = calendar if calendar_index is not None: args["calendarIndex"] = calendar_index try: emit(call_macmini_tool("calendar_list_events", args), json_output) except Exception as exc: fail(str(exc), json_output) @macmini_calendar_app.command("create") def macmini_calendar_create(title: str, start: str, end: str, all_day: bool = typer.Option(False, "--all-day"), notes: Optional[str] = None, location: Optional[str] = None, calendar: Optional[str] = None, calendar_index: Optional[int] = typer.Option(None, "--calendar-index"), json_output: bool = typer.Option(False, "--json")): args: Dict[str, Any] = {"title": title, "start": start, "end": end, "allDay": all_day} for key, value in {"notes": notes, "location": location, "calendar": calendar, "calendarIndex": calendar_index}.items(): if value is not None: args[key] = value try: emit(call_macmini_tool("calendar_create_event", args), json_output) except Exception as exc: fail(str(exc), json_output) @macmini_contacts_app.command("search") def macmini_contacts_search(query: Optional[str] = None, limit: int = 20, json_output: bool = typer.Option(False, "--json")): try: emit(call_macmini_tool("contacts_search", {"query": query, "limit": limit}), json_output) except Exception as exc: fail(str(exc), json_output) @macmini_contacts_app.command("read") def macmini_contacts_read(contact_id: str, json_output: bool = typer.Option(False, "--json")): try: emit(call_macmini_tool("contacts_read", {"id": contact_id}), json_output) except Exception as exc: fail(str(exc), json_output) @macmini_contacts_app.command("create") def macmini_contacts_create(first_name: Optional[str] = typer.Option(None, "--first-name"), last_name: Optional[str] = typer.Option(None, "--last-name"), organization: Optional[str] = None, job_title: Optional[str] = typer.Option(None, "--job-title"), note: Optional[str] = None, email: Optional[str] = None, phone: Optional[str] = None, json_output: bool = typer.Option(False, "--json")): args: Dict[str, Any] = {} for key, value in {"firstName": first_name, "lastName": last_name, "organization": organization, "jobTitle": job_title, "note": note}.items(): if value is not None: args[key] = value if email: args["email"] = {"label": "work", "value": email} if phone: args["phone"] = {"label": "mobile", "value": phone} try: emit(call_macmini_tool("contacts_create", args), json_output) except Exception as exc: fail(str(exc), json_output) @macmini_notes_app.command("list") def macmini_notes_list(query: Optional[str] = None, folder: Optional[str] = None, include_preview: bool = typer.Option(False, "--include-preview"), limit: int = 20, json_output: bool = typer.Option(False, "--json")): args = {"query": query, "folder": folder, "includePreview": include_preview, "limit": limit} try: emit(call_macmini_tool("notes_list", args), json_output) except Exception as exc: fail(str(exc), json_output) @macmini_notes_app.command("read") def macmini_notes_read(note_id: str, json_output: bool = typer.Option(False, "--json")): try: emit(call_macmini_tool("notes_read", {"id": note_id}), json_output) except Exception as exc: fail(str(exc), json_output) @macmini_notes_app.command("create") def macmini_notes_create(title: str, body: str = "", folder: Optional[str] = None, json_output: bool = typer.Option(False, "--json")): args: Dict[str, Any] = {"title": title, "body": body} if folder is not None: args["folder"] = folder try: emit(call_macmini_tool("notes_create", args), json_output) except Exception as exc: fail(str(exc), json_output) @macmini_reminders_app.command("lists") def macmini_reminders_lists(json_output: bool = typer.Option(False, "--json")): try: emit(call_macmini_tool("reminders_list_lists", {}), json_output) except Exception as exc: fail(str(exc), json_output) @macmini_reminders_app.command("list") def macmini_reminders_list(list_name: Optional[str] = typer.Option(None, "--list"), completed: Optional[bool] = typer.Option(False, "--completed/--incomplete"), limit: int = 25, json_output: bool = typer.Option(False, "--json")): args = {"list": list_name, "completed": completed, "limit": limit} try: emit(call_macmini_tool("reminders_list", args), json_output) except Exception as exc: fail(str(exc), json_output) @macmini_reminders_app.command("create") def macmini_reminders_create(title: str, list_name: Optional[str] = typer.Option(None, "--list"), notes: Optional[str] = None, due: Optional[str] = None, json_output: bool = typer.Option(False, "--json")): args: Dict[str, Any] = {"title": title} for key, value in {"list": list_name, "notes": notes, "due": due}.items(): if value is not None: args[key] = value try: emit(call_macmini_tool("reminders_create", args), json_output) except Exception as exc: fail(str(exc), json_output) def emit_deco_result(tool: str, result: Dict[str, Any], json_output: bool = False) -> None: emit({"ok": True, "source": "direct", "tool": tool, "result": result}, json_output) @deco_app.command("config") def deco_config(json_output: bool = typer.Option(False, "--json")): try: emit_deco_result("deco_get_config_status", DecoDirectClient().config_status(), json_output) except Exception as exc: fail(str(exc), json_output) @deco_app.command("overview") def deco_overview(json_output: bool = typer.Option(False, "--json")): try: emit_deco_result("deco_get_overview", DecoDirectClient().overview(), json_output) except Exception as exc: fail(str(exc), json_output) @deco_app.command("clients") def deco_clients(json_output: bool = typer.Option(False, "--json")): try: emit_deco_result("deco_list_clients", DecoDirectClient().list_clients(), json_output) except Exception as exc: fail(str(exc), json_output) @deco_app.command("firmware") def deco_firmware(json_output: bool = typer.Option(False, "--json")): try: emit_deco_result("deco_get_firmware", DecoDirectClient().firmware(), json_output) except Exception as exc: fail(str(exc), json_output) @deco_app.command("ipv4") def deco_ipv4(json_output: bool = typer.Option(False, "--json")): try: emit_deco_result("deco_get_ipv4_status", DecoDirectClient().ipv4_status(), json_output) except Exception as exc: fail(str(exc), json_output) # Keep the old path working for scripts that still call `reyna-cli macmini deco ...`, # but route it directly to the Deco router instead of through the Mac MCP server. macmini_deco_app.command("config")(deco_config) macmini_deco_app.command("overview")(deco_overview) macmini_deco_app.command("clients")(deco_clients) macmini_deco_app.command("firmware")(deco_firmware) macmini_deco_app.command("ipv4")(deco_ipv4) @app.command("web") def web_server( host: str = typer.Option("127.0.0.1", "--host", help="Host/IP address to bind."), port: int = typer.Option(8765, "--port", help="TCP port to listen on."), reload: bool = typer.Option(False, "--reload", help="Enable uvicorn reload for development."), ): """Run the browser web interface for Reyna CLI. Auth defaults to Authentik OIDC at auth.reynafamily.com. Configure with REYNA_WEB_SESSION_SECRET, REYNA_WEB_AUTH_CLIENT_ID, REYNA_WEB_AUTH_CLIENT_SECRET, and REYNA_WEB_PUBLIC_URL. """ from reyna_cli.web import run_web_server run_web_server(host=host, port=port, reload=reload) if __name__ == "__main__": app()