1294 lines
54 KiB
Python
1294 lines
54 KiB
Python
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"<binary saved to {out}>"
|
|
cleaned["path"] = str(out)
|
|
else:
|
|
cleaned[key] = f"<binary omitted: {len(item)} chars>"
|
|
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()
|