Initial reyna-cli implementation

This commit is contained in:
Adolfo Reyna
2026-06-10 22:29:02 -04:00
commit 97379a1046
20 changed files with 2261 additions and 0 deletions
View File
+685
View File
@@ -0,0 +1,685 @@
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.immich import ImmichClient
from reyna_cli.mcp import MCPClient
from reyna_cli.mongo_direct import MongoDirectClient
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, iphone, arm).")
screen_app = typer.Typer(help="ESP32 screen wrapper commands.")
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.")
console = Console()
devices_app.add_typer(screen_app, name="screen")
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(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")
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"} 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]) -> 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)}
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 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):
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)
emit(result, json_output)
if not result.get("ok"):
raise typer.Exit(1)
@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")):
wrapper_capability("esp32_screen", "speech", {"text": message}, json_output)
@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)
@screen_app.command("battery")
def screen_battery(json_output: bool = typer.Option(False, "--json")):
wrapper_capability("esp32_screen", "battery", {}, json_output)
@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")):
wrapper_capability("iphone_mcp", "speech", {"text": message}, json_output)
@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)
@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)
if __name__ == "__main__":
app()
View File
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional
import httpx
class ImmichClient:
def __init__(self, base_url: Optional[str] = None, api_key: Optional[str] = None):
self.base_url = (base_url or os.environ.get("IMMICH_URL") or "http://localhost:2283").rstrip("/")
self.api_key = api_key or os.environ.get("IMMICH_API_KEY")
self.client = httpx.Client(
base_url=self.base_url,
headers={"x-api-key": self.api_key} if self.api_key else {},
timeout=10.0,
)
def get_stats(self) -> Dict[str, Any]:
response = self.client.get("/api/server-info/statistics")
response.raise_for_status()
return response.json()
def get_albums(self) -> List[Dict[str, Any]]:
response = self.client.get("/api/album")
response.raise_for_status()
return response.json()
def search(self, query: str, limit: int = 10) -> Dict[str, Any]:
params = {"q": query, "size": limit}
response = self.client.get("/api/search/search", params=params)
response.raise_for_status()
return response.json()
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional
from pymongo import MongoClient as PyMongoClient
class MongoClient:
def __init__(self, uri: Optional[str] = None):
self.uri = uri or os.environ.get("MONGO_URI") or "mongodb://localhost:27017"
self.client = PyMongoClient(self.uri, serverSelectionTimeoutMS=5000)
def list_databases(self) -> List[str]:
return self.client.list_database_names()
def list_collections(self, db_name: str) -> List[str]:
return self.client[db_name].list_collection_names()
def count_documents(self, db_name: str, collection_name: str, query: Dict[str, Any]) -> int:
return self.client[db_name][collection_name].count_documents(query)
def find(self, db_name: str, collection_name: str, filter: Dict[str, Any], limit: int = 10) -> List[Dict[str, Any]]:
cursor = self.client[db_name][collection_name].find(filter).limit(limit)
return list(cursor)
def aggregate(self, db_name: str, collection_name: str, pipeline: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
cursor = self.client[db_name][collection_name].aggregate(pipeline)
return list(cursor)
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import base64
import os
from typing import Any, Dict, Optional
import httpx
class ZoomClient:
def __init__(
self,
account_id: Optional[str] = None,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
):
self.account_id = account_id or os.environ.get("ZOOM_ACCOUNT_ID")
self.client_id = client_id or os.environ.get("ZOOM_CLIENT_ID")
self.client_secret = client_secret or os.environ.get("ZOOM_CLIENT_SECRET")
self.token: Optional[str] = None
self.client = httpx.Client(base_url="https://api.zoom.us/v2", timeout=10.0)
def _get_token(self) -> str:
if self.token:
return self.token
if not all([self.account_id, self.client_id, self.client_secret]):
raise ValueError("Zoom credentials missing (account_id, client_id, client_secret)")
url = f"https://zoom.us/oauth/token?grant_type=account_credentials&account_id={self.account_id}"
auth_header = base64.b64encode(f"{self.client_id}:{self.client_secret}".encode()).decode()
response = httpx.post(
url,
headers={"Authorization": f"Basic {auth_header}"},
)
response.raise_for_status()
data = response.json()
self.token = data["access_token"]
return self.token
def _request(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
token = self._get_token()
headers = kwargs.get("headers", {})
headers["Authorization"] = f"Bearer {token}"
kwargs["headers"] = headers
response = self.client.request(method, path, **kwargs)
response.raise_for_status()
if response.status_code == 204:
return {}
return response.json()
def list_meetings(self, user_id: str = "me") -> Dict[str, Any]:
return self._request("GET", f"/users/{user_id}/meetings")
def get_user(self, user_id: str = "me") -> Dict[str, Any]:
return self._request("GET", f"/users/{user_id}")
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
from pydantic import BaseModel, Field
DEFAULT_REGISTRY_PATH = Path.home() / ".hermes" / "local_devices.yaml"
CACHE_DIR = Path(os.environ.get("REYNA_CLI_CACHE_DIR", Path.home() / ".cache" / "reyna-cli"))
class Device(BaseModel):
id: str
display_name: str = ""
type: str = ""
status: str = ""
host: str = ""
url: str = ""
fallback_url: str = ""
reserved_ip: str = ""
ports: Dict[str, Any] = Field(default_factory=dict)
capabilities: List[str] = Field(default_factory=list)
preferred_actions: Dict[str, str] = Field(default_factory=dict)
notes: List[str] = Field(default_factory=list)
@property
def name(self) -> str:
return self.id
def candidate_urls(self) -> List[str]:
urls: List[str] = []
for url in (self.url, self.fallback_url):
if url and url not in urls:
urls.append(url)
return urls
def public_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"display_name": self.display_name,
"type": self.type,
"status": self.status,
"host": self.host,
"url": self.url,
"fallback_url": self.fallback_url,
"reserved_ip": self.reserved_ip,
"ports": self.ports,
"capabilities": self.capabilities,
"preferred_actions": self.preferred_actions,
"notes": self.notes,
}
class Registry(BaseModel):
path: Optional[str] = None
version: int = 1
updated: str = ""
aliases: Dict[str, str] = Field(default_factory=dict)
devices: List[Device] = Field(default_factory=list)
def _device_from_mapping(device_id: str, raw: Dict[str, Any]) -> Device:
data = dict(raw or {})
data["id"] = device_id
return Device(**data)
def load_registry(path: Optional[Path] = None) -> Registry:
paths_to_try: List[Path] = []
if path:
paths_to_try.append(Path(path).expanduser())
env_path = os.environ.get("REYNA_DEVICES_REGISTRY")
if env_path:
paths_to_try.append(Path(env_path).expanduser())
paths_to_try.extend([DEFAULT_REGISTRY_PATH, Path.cwd() / "local_devices.yaml"])
seen: set[Path] = set()
for p in paths_to_try:
p = p.expanduser()
if p in seen:
continue
seen.add(p)
if not p.exists():
continue
with p.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
devices_raw = data.get("devices", {}) or {}
devices: List[Device] = []
if isinstance(devices_raw, dict):
devices = [_device_from_mapping(k, v) for k, v in devices_raw.items()]
elif isinstance(devices_raw, list):
for item in devices_raw:
if isinstance(item, dict):
device_id = item.get("id") or item.get("name") or item.get("display_name")
if device_id:
devices.append(_device_from_mapping(str(device_id), item))
return Registry(
path=str(p),
version=data.get("version", 1),
updated=str(data.get("updated", "")),
aliases=data.get("aliases", {}) or {},
devices=devices,
)
return Registry(path=None)
def get_device(name: str, registry: Optional[Registry] = None) -> Optional[Device]:
registry = registry or load_registry()
normalized = name.strip()
shorthand = {
"screen": "esp32_screen",
"esp32": "esp32_screen",
"iphone": "iphone_mcp",
"phone": "iphone_mcp",
"arm": "robot_arm",
}
normalized = shorthand.get(normalized, normalized)
for device in registry.devices:
if normalized in {device.id, device.host, device.display_name}:
return device
return None
def cache_path_for(device_id: str) -> Path:
return CACHE_DIR / "devices" / f"{device_id}-tools.json"
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
import os
from pathlib import Path
def load_hermes_env() -> None:
"""Load ~/.hermes/.env into this process without overriding existing vars.
This lets reyna-cli run from cron/gateway shells that do not already export
service credentials. Values are never printed by this helper.
"""
env_path = Path(os.environ.get("HERMES_ENV_PATH", Path.home() / ".hermes" / ".env"))
if not env_path.exists():
return
try:
for raw_line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if not key or key in os.environ:
continue
value = value.strip()
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
value = value[1:-1]
os.environ[key] = value
except OSError:
return
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import os
from typing import Any, Dict, List
import httpx
from reyna_cli.env import load_hermes_env
class ImmichClient:
def __init__(self, base_url: str | None = None, api_key: str | None = None, timeout: float = 20.0):
load_hermes_env()
self.base_url = (base_url or os.getenv("IMMICH_BASE_URL") or os.getenv("IMMICH_URL") or "").rstrip("/")
self.api_key = api_key or os.getenv("IMMICH_API_KEY") or os.getenv("IMMICH_KEY") or ""
self.timeout = timeout
if not self.base_url:
raise RuntimeError("IMMICH_BASE_URL is not configured")
if not self.api_key:
raise RuntimeError("IMMICH_API_KEY is not configured")
self.client = httpx.Client(timeout=timeout, headers={"x-api-key": self.api_key, "Accept": "application/json"})
def _url(self, path: str) -> str:
if not path.startswith("/"):
path = "/" + path
return self.base_url + path
def _request(self, method: str, candidates: List[str], **kwargs: Any) -> Any:
errors: list[str] = []
for path in candidates:
try:
response = self.client.request(method, self._url(path), **kwargs)
if response.status_code == 404:
errors.append(f"{path}: 404")
continue
response.raise_for_status()
if not response.content:
return {}
return response.json()
except Exception as exc:
errors.append(f"{path}: {exc}")
raise RuntimeError("; ".join(errors))
def server_version(self) -> Dict[str, Any]:
return self._request("GET", ["/api/server/version", "/server/version"])
def statistics(self) -> Dict[str, Any]:
return self._request("GET", ["/api/server/statistics", "/server/statistics", "/api/server/stats", "/server/stats"])
def albums(self) -> Any:
return self._request("GET", ["/api/albums", "/albums"])
def search(self, query: str, limit: int = 10) -> Any:
payloads = [
{"query": query, "size": limit},
{"query": query, "page": 1, "size": limit},
{"smartSearch": query, "size": limit},
]
errors: list[str] = []
for path in ["/api/search/smart", "/search/smart", "/api/search/metadata", "/search/metadata"]:
for payload in payloads:
try:
response = self.client.post(self._url(path), json=payload)
if response.status_code == 404:
break
response.raise_for_status()
return response.json()
except Exception as exc:
errors.append(f"{path}: {exc}")
raise RuntimeError("; ".join(errors))
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import uuid
from typing import Any, Dict, List, Optional
import httpx
class MCPClient:
def __init__(self, base_url: str, timeout: float = 2.0):
self.base_url = base_url.rstrip("/")
self.session_id: Optional[str] = None
self.client = httpx.Client(timeout=timeout)
def _headers(self) -> Dict[str, str]:
headers = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
}
if self.session_id:
headers["mcp-session-id"] = self.session_id
return headers
def _parse_response(self, response: httpx.Response) -> Dict[str, Any]:
if response.headers.get("mcp-session-id"):
self.session_id = response.headers["mcp-session-id"]
text = response.text.strip()
if text.startswith("event:") or "\ndata:" in text:
for line in text.splitlines():
if line.startswith("data:"):
text = line.removeprefix("data:").strip()
break
return response.json() if text == response.text.strip() else response.json() if not text else __import__("json").loads(text)
def _post_once(self, url: str, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
payload = {"jsonrpc": "2.0", "id": str(uuid.uuid4()), "method": method, "params": params or {}}
response = self.client.post(url, json=payload, headers=self._headers())
response.raise_for_status()
return self._parse_response(response)
def call_rpc(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
try:
return self._post_once(self.base_url, method, params)
except Exception as first_exc:
# Some lightweight MCP prototypes route only with a trailing slash.
slash_url = self.base_url + "/"
if slash_url == self.base_url:
raise
try:
return self._post_once(slash_url, method, params)
except Exception as second_exc:
raise RuntimeError(f"{self.base_url}: {first_exc}; {slash_url}: {second_exc}")
def initialize(self) -> Dict[str, Any]:
try:
return self.call_rpc(
"initialize",
{
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "reyna-cli", "version": "0.1.0"},
},
)
except Exception as exc:
return {"ok": False, "warning": f"initialize failed: {exc}"}
def _call_with_optional_initialize(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
try:
return self.call_rpc(method, params)
except Exception as exc:
msg = str(exc)
if "Missing session ID" not in msg and "400 Bad Request" not in msg:
raise
init = self.initialize()
if init.get("error") and not self.session_id:
raise
return self.call_rpc(method, params)
def list_tools(self) -> List[Dict[str, Any]]:
result = self._call_with_optional_initialize("tools/list")
if "error" in result:
raise RuntimeError(str(result["error"]))
return result.get("result", {}).get("tools", [])
def call_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
result = self._call_with_optional_initialize("tools/call", {"name": name, "arguments": arguments})
if "error" in result:
raise RuntimeError(str(result["error"]))
return result.get("result", result)
def ping(self) -> bool:
try:
self.list_tools()
return True
except Exception:
try:
response = self.client.get(self.base_url)
return response.status_code < 500
except Exception:
return False
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import os
from typing import Any, Dict, List
from reyna_cli.env import load_hermes_env
class MongoDirectClient:
def __init__(self, uri: str | None = None, timeout_ms: int = 5000):
load_hermes_env()
try:
from pymongo import MongoClient
except ImportError as exc:
raise RuntimeError("pymongo is required for direct MongoDB commands") from exc
self.uri = uri or os.getenv("MONGODB_URI") or os.getenv("MONGO_URI") or os.getenv("MDB_MCP_CONNECTION_STRING") or "mongodb://127.0.0.1:27017"
self.client = MongoClient(self.uri, serverSelectionTimeoutMS=timeout_ms)
def ping(self) -> bool:
self.client.admin.command("ping")
return True
def databases(self) -> List[str]:
return self.client.list_database_names()
def collections(self, database: str) -> List[str]:
return self.client[database].list_collection_names()
def count(self, database: str, collection: str, query: Dict[str, Any] | None = None) -> int:
return int(self.client[database][collection].count_documents(query or {}))
def find(self, database: str, collection: str, filter: Dict[str, Any] | None = None, limit: int = 10) -> List[Dict[str, Any]]:
docs = list(self.client[database][collection].find(filter or {}).limit(limit))
return [self._jsonable(doc) for doc in docs]
def aggregate(self, database: str, collection: str, pipeline: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
docs = list(self.client[database][collection].aggregate(pipeline))
return [self._jsonable(doc) for doc in docs]
def _jsonable(self, value: Any) -> Any:
if isinstance(value, dict):
return {str(k): self._jsonable(v) for k, v in value.items()}
if isinstance(value, list):
return [self._jsonable(v) for v in value]
if hasattr(value, "isoformat"):
return value.isoformat()
if value.__class__.__name__ == "ObjectId":
return str(value)
return value
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
CAPABILITY_MAPPINGS: Dict[str, List[str]] = {
"speech": ["speak", "text_to_speech", "play_audio_base64", "play_wav", "audio_play"],
"speak": ["speak", "text_to_speech", "play_audio_base64", "play_wav", "audio_play"],
"text": ["draw_text", "display_message", "show_text", "text"],
"clear": ["clear_screen", "clear", "fill_screen"],
"screenshot": ["screenshot", "capture_screen", "get_screenshot"],
"battery": ["get_battery", "battery", "battery_status"],
"state": ["get_arm_state", "get_state", "status"],
"home": ["home_arm", "home", "reset_position"],
"wave": ["approved_double_speed_wave", "wave", "wave_hand"],
"stats": ["get_stats", "stats", "statistics"],
"albums": ["list_albums", "albums", "get_albums"],
"search": ["search_assets", "search", "find_photos"],
"dbs": ["list_databases", "databases", "list_dbs", "get_databases"],
"collections": ["list_collections", "collections", "get_collections"],
"count": ["count_documents", "count", "get_count"],
"find": ["find_documents", "find", "query"],
"aggregate": ["aggregate", "pipeline"],
}
def normalize_tools(raw: Any) -> List[Dict[str, Any]]:
if isinstance(raw, dict):
if "tools" in raw:
raw = raw["tools"]
elif "result" in raw and isinstance(raw["result"], dict):
raw = raw["result"].get("tools", [])
if not isinstance(raw, list):
return []
tools: List[Dict[str, Any]] = []
for tool in raw:
if isinstance(tool, str):
tools.append({"name": tool})
elif isinstance(tool, dict) and tool.get("name"):
tools.append(tool)
return tools
def resolve_tool_name(capability: str, available_tools: List[Dict[str, Any]]) -> Optional[str]:
capability = "speech" if capability == "speak" else capability
preferred_names = CAPABILITY_MAPPINGS.get(capability, [])
tool_names = [str(t.get("name", "")) for t in normalize_tools(available_tools)]
for preferred in preferred_names:
if preferred in tool_names:
return preferred
for preferred in preferred_names:
for name in tool_names:
if preferred in name:
return name
for name in tool_names:
if capability in name:
return name
return None
def infer_capabilities(tools: List[Dict[str, Any]]) -> Dict[str, Optional[str]]:
caps = ["text", "speech", "clear", "screenshot", "battery", "state", "home", "wave"]
return {cap: resolve_tool_name(cap, tools) for cap in caps}
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import base64
import os
import time
from typing import Any, Dict
import httpx
from reyna_cli.env import load_hermes_env
class ZoomClient:
def __init__(self, timeout: float = 20.0):
load_hermes_env()
self.account_id = os.getenv("ZOOM_ACCOUNT_ID", "")
self.client_id = os.getenv("ZOOM_CLIENT_ID", "")
self.client_secret = os.getenv("ZOOM_CLIENT_SECRET", "")
if not self.account_id or not self.client_id or not self.client_secret:
raise RuntimeError("ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, and ZOOM_CLIENT_SECRET are required")
self.timeout = timeout
self.client = httpx.Client(timeout=timeout)
self._token = ""
self._token_expires_at = 0.0
def token(self) -> str:
if self._token and time.time() < self._token_expires_at - 60:
return self._token
basic = base64.b64encode(f"{self.client_id}:{self.client_secret}".encode()).decode()
response = self.client.post(
"https://zoom.us/oauth/token",
params={"grant_type": "account_credentials", "account_id": self.account_id},
headers={"Authorization": f"Basic {basic}"},
)
response.raise_for_status()
payload = response.json()
self._token = payload["access_token"]
self._token_expires_at = time.time() + int(payload.get("expires_in", 3600))
return self._token
def _request(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
response = self.client.request(
method,
"https://api.zoom.us/v2" + path,
headers={"Authorization": f"Bearer {self.token()}", "Accept": "application/json"},
**kwargs,
)
response.raise_for_status()
if not response.content:
return {}
return response.json()
def users(self, page_size: int = 30, status: str = "active") -> Dict[str, Any]:
return self._request("GET", "/users", params={"page_size": page_size, "status": status})
def user(self, user_id: str = "me") -> Dict[str, Any]:
return self._request("GET", f"/users/{user_id}")
def meetings(self, user_id: str = "me", meeting_type: str = "scheduled", page_size: int = 30) -> Dict[str, Any]:
return self._request("GET", f"/users/{user_id}/meetings", params={"type": meeting_type, "page_size": page_size})
def recordings(self, user_id: str = "me", from_date: str | None = None, to_date: str | None = None, page_size: int = 30) -> Dict[str, Any]:
params: Dict[str, Any] = {"page_size": page_size}
if from_date:
params["from"] = from_date
if to_date:
params["to"] = to_date
return self._request("GET", f"/users/{user_id}/recordings", params=params)