Files
reyna-cli/src/reyna_cli/cli.py
T
2026-06-10 22:29:02 -04:00

686 lines
28 KiB
Python

from __future__ import annotations
import base64
import json
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
import typer
import yaml
from rich.console import Console
from rich.table import Table
from reyna_cli.config import Device, cache_path_for, get_device, load_registry
from reyna_cli.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()