feat: expand Reyna CLI integrations

This commit is contained in:
Adolfo Reyna
2026-06-15 18:59:43 -04:00
parent 97379a1046
commit a0a26565f0
16 changed files with 3626 additions and 19 deletions
+574 -12
View File
@@ -13,9 +13,13 @@ 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.tts import TTSError, synthesize_wav
from reyna_cli.utils import infer_capabilities, resolve_tool_name
from reyna_cli.zoom_direct import ZoomClient
@@ -23,26 +27,48 @@ from reyna_cli.zoom_direct import ZoomClient
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, iphone, arm).")
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).")
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")
def scrub_sensitive(value: Any) -> Any:
@@ -108,7 +134,7 @@ 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"} and isinstance(item, str) and len(item) > 200:
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]
@@ -179,13 +205,13 @@ def get_tools(device: Device, live_only: bool = False, cache_only: bool = False,
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]) -> Dict[str, Any]:
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)}
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}
@@ -250,6 +276,45 @@ def hermes_mcp_servers() -> Dict[str, Any]:
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)",
@@ -371,19 +436,47 @@ 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):
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])
result = call_device_tool(device, tool_name, payload)
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)
@@ -400,8 +493,16 @@ def screen_text(message: str, json_output: bool = typer.Option(False, "--json"),
@screen_app.command("speak")
def screen_speak(message: str, json_output: bool = typer.Option(False, "--json")):
wrapper_capability("esp32_screen", "speech", {"text": message}, json_output)
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")
@@ -411,7 +512,7 @@ def screen_clear(json_output: bool = typer.Option(False, "--json"), color: int =
@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)
wrapper_capability("esp32_screen", "screenshot", {"out": str(out)}, json_output, binary_out=out)
@screen_app.command("battery")
@@ -419,6 +520,178 @@ 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)
@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)
@@ -435,13 +708,21 @@ def iphone_text(message: str, json_output: bool = typer.Option(False, "--json"))
@iphone_app.command("speak")
def iphone_speak(message: str, json_output: bool = typer.Option(False, "--json")):
wrapper_capability("iphone_mcp", "speech", {"text": message}, json_output)
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)
wrapper_capability("iphone_mcp", "screenshot", {"out": str(out)}, json_output, binary_out=out)
@iphone_app.command("battery")
@@ -681,5 +962,286 @@ def zoom_recordings(user_id: str = "me", from_date: Optional[str] = typer.Option
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()