Initial reyna-cli implementation
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user