feat: expand Reyna CLI integrations

This commit is contained in:
Adolfo Reyna
2026-06-15 18:59:43 -04:00
parent 97379a1046
commit a0a26565f0
16 changed files with 3626 additions and 19 deletions
+100 -2
View File
@@ -6,12 +6,14 @@ On-demand CLI façade for Reyna family MCP services and LAN devices. The goal is
- **Devices parent command**: All local devices are now under `reyna-cli devices`.
- `reyna-cli devices screen ...` (ESP32 screen)
- `reyna-cli devices laptop ...` (personal laptop MCP screen)
- `reyna-cli devices computer ...` (this computer's desktop companion client)
- `reyna-cli devices iphone ...` (iPhone app)
- `reyna-cli devices arm ...` (Robot arm)
- **Robot Arm**: Dynamic tool resolution for `state`, `home`, `wave`, `battery`.
- **Immich**: On-demand MCP bridge at `http://127.0.0.1:8626/mcp`.
- **MongoDB**: On-demand MCP bridge at `http://127.0.0.1:8630/mcp`.
- **Top-level aliases**: `reyna-cli screen`, `reyna-cli iphone`, `reyna-cli arm` still work for compatibility.
- **Top-level aliases**: `reyna-cli screen`, `reyna-cli laptop`, `reyna-cli iphone`, `reyna-cli arm` still work for compatibility.
## Install / run
@@ -44,13 +46,30 @@ uv run reyna-cli devices arm wave
uv run reyna-cli devices arm battery --json
```
## ESP32 screen & iPhone
## ESP32 screen, laptop screen, this computer & iPhone
```bash
uv run reyna-cli devices screen text "Dinner is ready"
uv run reyna-cli devices laptop text "Hermes is online" --json
uv run reyna-cli devices laptop battery --json
uv run reyna-cli devices laptop sensors --json
uv run reyna-cli devices computer service-status --json
uv run reyna-cli devices computer text "Hermes is online here" --json
uv run reyna-cli devices iphone battery --json
```
## This computer desktop client service
The local desktop companion client runs `/home/adolforeyna/Projects/Screen/desktop_client/client.py` in the GUI session with `WAYLAND_DISPLAY=wayland-0` and `XDG_RUNTIME_DIR=/run/user/1000`.
```bash
uv run reyna-cli computer service-install --json
uv run reyna-cli computer service-start --json
uv run reyna-cli computer service-status --json
uv run reyna-cli computer service-restart --json
uv run reyna-cli computer service-logs --lines 80
```
## Immich
```bash
@@ -70,6 +89,85 @@ uv run reyna-cli mongo find my_db my_coll --limit 5
uv run reyna-cli mongo aggregate my_db my_coll --pipeline '[{"$match":{}}]'
```
## Local Thunderbird email (read-only)
The `email` command searches the locally synced Thunderbird MBOX files directly. It is read-only: it does not send, delete, move, label, or modify mail.
```bash
uv run reyna-cli email doctor --json
uv run reyna-cli email search "whirlpool fridge warranty" --limit 5 --json
uv run reyna-cli email find-receipts "Whirlpool" --json
uv run reyna-cli email latest --limit 10 --json
uv run reyna-cli email show <message-id> --body --json
```
By default it uses the local Thunderbird profile at `~/snap/thunderbird/common/.thunderbird/tjug9cp5.default` and the Gmail `INBOX` MBOX. Override with `REYNA_THUNDERBIRD_PROFILE` or `REYNA_THUNDERBIRD_INBOX` when testing or using a different profile.
## Mac mini
The `macmini` command reads the existing Hermes `mcp_servers.macmini` HTTP URL and Authorization header from `~/.hermes/config.yaml`, so credentials stay in the Hermes profile config instead of being duplicated here.
```bash
uv run reyna-cli macmini ping --json
uv run reyna-cli macmini tools --json
uv run reyna-cli macmini call notes_list --args '{"limit":5}' --json
uv run reyna-cli macmini calendar calendars --json
uv run reyna-cli macmini calendar events 2026-06-11T00:00:00 2026-06-12T00:00:00 --json
uv run reyna-cli macmini calendar create "Dentist" 2026-06-15T09:00:00 2026-06-15T10:00:00 --notes "Bring insurance card"
uv run reyna-cli macmini contacts search Adolfo --json
uv run reyna-cli macmini notes list --query family --json
uv run reyna-cli macmini reminders lists --json
```
## TP-Link Deco
The `deco` command talks directly to the router with `tplinkrouterc6u`; it does not use the Mac mini MCP server. Configure `DECO_HOST`, `DECO_USERNAME`, `DECO_PASSWORD`, `DECO_VERIFY_SSL`, and `DECO_TIMEOUT` in `~/.hermes/.env` or the process environment. `DECO_HOST` defaults to the Linux default gateway when omitted.
```bash
uv run reyna-cli deco config --json
uv run reyna-cli deco overview --json
uv run reyna-cli deco clients --json
uv run reyna-cli deco firmware --json
uv run reyna-cli deco ipv4 --json
# Backward-compatible alias; also direct, not MCP:
uv run reyna-cli macmini deco clients --json
```
## Browser web interface
`reyna-cli web` runs a FastAPI/uvicorn browser UI that can execute Reyna CLI commands from a browser. The web app uses Authentik OIDC by default against `auth.reynafamily.com`.
Create an Authentik OAuth2/OpenID provider/application for the web app and add a redirect URI that matches where you will browse to the server, for example:
```text
https://reyna-cli.reynafamily.com/auth/callback
```
Configure the server with environment variables (for example in `~/.hermes/.env` or your service manager):
```bash
REYNA_WEB_SESSION_SECRET='generate-a-long-random-string'
REYNA_WEB_AUTH_CLIENT_ID='authentik-client-id'
REYNA_WEB_AUTH_CLIENT_SECRET='authentik-client-secret'
REYNA_WEB_AUTH_ISSUER='https://auth.reynafamily.com/application/o/reyna-cli-web/'
REYNA_WEB_PUBLIC_URL='https://reyna-cli.reynafamily.com'
```
Run locally:
```bash
uv run reyna-cli web --host 127.0.0.1 --port 8765
```
For a trusted LAN-only development session, bypass OIDC with:
```bash
REYNA_WEB_DEV_AUTH=1 uv run reyna-cli web --host 127.0.0.1 --port 8765
```
## Tests
```bash
+8
View File
@@ -11,6 +11,14 @@ dependencies = [
"rich",
"pytest-mock",
"pymongo",
"tplinkrouterc6u==5.21.0",
"edge-tts",
"fastapi>=0.136.3",
"uvicorn>=0.49.0",
"authlib>=1.7.2",
"itsdangerous>=2.2.0",
"jinja2>=3.1.6",
"python-multipart>=0.0.32",
]
readme = "README.md"
requires-python = ">=3.10"
+574 -12
View File
@@ -13,9 +13,13 @@ from rich.console import Console
from rich.table import Table
from reyna_cli.config import Device, cache_path_for, get_device, load_registry
from reyna_cli.deco_direct import DecoDirectClient
from reyna_cli.desktop_service import SERVICE_NAME, service_action, service_status, unit_content, unit_path
from reyna_cli.email_local import ThunderbirdEmailClient, email_tool_specs
from reyna_cli.immich import ImmichClient
from reyna_cli.mcp import MCPClient
from reyna_cli.mongo_direct import MongoDirectClient
from reyna_cli.tts import TTSError, synthesize_wav
from reyna_cli.utils import infer_capabilities, resolve_tool_name
from reyna_cli.zoom_direct import ZoomClient
@@ -23,26 +27,48 @@ from reyna_cli.zoom_direct import ZoomClient
MongoClient = MongoDirectClient
app = typer.Typer(help="Reyna family CLI for on-demand local-device and service control.")
devices_app = typer.Typer(help="Manage local devices (screen, iphone, arm).")
devices_app = typer.Typer(help="Manage local devices (screen, laptop, iphone, arm).")
screen_app = typer.Typer(help="ESP32 screen wrapper commands.")
laptop_app = typer.Typer(help="Personal laptop MCP screen wrapper commands.")
computer_app = typer.Typer(help="This computer's local desktop companion client.")
iphone_app = typer.Typer(help="iPhone MCP app wrapper commands.")
arm_app = typer.Typer(help="Robot arm wrapper commands.")
immich_app = typer.Typer(help="Immich direct REST API commands.")
mongo_app = typer.Typer(help="MongoDB direct driver commands.")
zoom_app = typer.Typer(help="Zoom direct REST API commands.")
email_app = typer.Typer(help="Read-only local Thunderbird email commands.")
deco_app = typer.Typer(help="TP-Link Deco direct router commands.")
macmini_app = typer.Typer(help="Mac mini MCP tools (Calendar, Contacts, Notes, Reminders, Deco).")
macmini_calendar_app = typer.Typer(help="Mac mini Calendar tools.")
macmini_contacts_app = typer.Typer(help="Mac mini Contacts tools.")
macmini_notes_app = typer.Typer(help="Mac mini Notes tools.")
macmini_reminders_app = typer.Typer(help="Mac mini Reminders tools.")
macmini_deco_app = typer.Typer(help="TP-Link Deco direct router commands (backward-compatible alias).")
console = Console()
devices_app.add_typer(screen_app, name="screen")
devices_app.add_typer(laptop_app, name="laptop")
devices_app.add_typer(computer_app, name="computer")
devices_app.add_typer(iphone_app, name="iphone")
devices_app.add_typer(arm_app, name="arm")
app.add_typer(devices_app, name="devices")
app.add_typer(screen_app, name="screen", hidden=True)
app.add_typer(laptop_app, name="laptop", hidden=True)
app.add_typer(computer_app, name="computer")
app.add_typer(iphone_app, name="iphone", hidden=True)
app.add_typer(arm_app, name="arm", hidden=True)
app.add_typer(immich_app, name="immich")
app.add_typer(mongo_app, name="mongo")
app.add_typer(zoom_app, name="zoom")
app.add_typer(email_app, name="email")
app.add_typer(deco_app, name="deco")
macmini_app.add_typer(macmini_calendar_app, name="calendar")
macmini_app.add_typer(macmini_contacts_app, name="contacts")
macmini_app.add_typer(macmini_notes_app, name="notes")
macmini_app.add_typer(macmini_reminders_app, name="reminders")
macmini_app.add_typer(macmini_deco_app, name="deco")
app.add_typer(macmini_app, name="macmini")
def scrub_sensitive(value: Any) -> Any:
@@ -108,7 +134,7 @@ def scrub_binary(value: Any, out: Optional[Path] = None) -> Any:
if isinstance(value, dict):
cleaned: Dict[str, Any] = {}
for key, item in value.items():
if key in {"data", "audio_base64", "image_base64"} and isinstance(item, str) and len(item) > 200:
if key in {"data", "audio_base64", "image_base64", "wav_base64"} and isinstance(item, str) and len(item) > 200:
if out:
out.parent.mkdir(parents=True, exist_ok=True)
raw = item.split(",", 1)[-1]
@@ -179,13 +205,13 @@ def get_tools(device: Device, live_only: bool = False, cache_only: bool = False,
return {"ok": False, "device": device.id, "error": "device_unreachable", "message": last_error, "tried": tried}
def call_device_tool(device: Device, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
def call_device_tool(device: Device, tool_name: str, arguments: Dict[str, Any], binary_out: Optional[Path] = None) -> Dict[str, Any]:
errors: List[str] = []
for url in device.candidate_urls():
try:
client = MCPClient(url)
result = client.call_tool(tool_name, arguments)
return {"ok": True, "device": device.id, "url": url, "tool_used": tool_name, "result": scrub_binary(result)}
return {"ok": True, "device": device.id, "url": url, "tool_used": tool_name, "result": scrub_binary(result, binary_out)}
except Exception as exc:
errors.append(f"{url}: {exc}")
return {"ok": False, "device": device.id, "error": "tool_call_failed", "tool": tool_name, "tried": errors}
@@ -250,6 +276,45 @@ def hermes_mcp_servers() -> Dict[str, Any]:
return redacted
def hermes_mcp_server_config(name: str = "macmini") -> Dict[str, Any]:
path = Path.home() / ".hermes" / "config.yaml"
if not path.exists():
raise RuntimeError(f"Hermes config not found at {path}")
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
cfg = (data.get("mcp_servers", {}) or {}).get(name)
if not cfg:
raise RuntimeError(f"MCP server '{name}' is not configured in {path}")
if not cfg.get("url"):
raise RuntimeError(f"MCP server '{name}' does not have an HTTP url")
return cfg
def macmini_client() -> MCPClient:
cfg = hermes_mcp_server_config("macmini")
timeout = float(cfg.get("timeout", 120))
headers = {str(k): str(v) for k, v in (cfg.get("headers") or {}).items()}
return MCPClient(str(cfg["url"]), timeout=timeout, headers=headers)
def normalize_mcp_result(result: Dict[str, Any]) -> Any:
content = result.get("content")
if isinstance(content, list) and len(content) == 1 and isinstance(content[0], dict):
text = content[0].get("text")
if isinstance(text, str):
try:
return json.loads(text)
except json.JSONDecodeError:
return text
if "structuredContent" in result:
return result["structuredContent"]
return result
def call_macmini_tool(tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
result = macmini_client().call_tool(tool_name, arguments)
return {"ok": True, "source": "mcp", "server": "macmini", "tool": tool_name, "result": normalize_mcp_result(result)}
def build_ram_plan() -> Dict[str, Any]:
replacements = {
"immich": "reyna-cli immich stats|albums|search (direct REST)",
@@ -371,19 +436,47 @@ def wrapper_describe(default_device: str, json_output: bool, for_hermes: bool):
devices_describe_cmd(default_device, json_output=json_output, for_hermes=for_hermes)
def wrapper_capability(default_device: str, capability: str, payload: Dict[str, Any], json_output: bool):
def wrapper_capability(default_device: str, capability: str, payload: Dict[str, Any], json_output: bool, binary_out: Optional[Path] = None):
device = get_required_device(default_device, json_output)
tools_result = get_tools(device)
tools = tools_result.get("tools", []) if tools_result.get("ok") else []
tool_name = resolve_tool_name(capability, tools)
if not tool_name:
fail(f"Capability '{capability}' unavailable on {device.id}", json_output, available_tools=[t.get("name") for t in tools])
result = call_device_tool(device, tool_name, payload)
assert tool_name is not None
if binary_out is None:
result = call_device_tool(device, tool_name, payload)
else:
result = call_device_tool(device, tool_name, payload, binary_out=binary_out)
emit(result, json_output)
if not result.get("ok"):
raise typer.Exit(1)
def build_tts_payload(message: str, wav_path: Path, volume: int, tts_command: Optional[str], voice: Optional[str], sample_rate: int) -> Dict[str, Any]:
wav_file = synthesize_wav(message, wav_path, command_template=tts_command, voice=voice, sample_rate=sample_rate)
wav_base64 = base64.b64encode(wav_file.read_bytes()).decode("ascii")
return {"wav_base64": wav_base64, "volume": volume}
def wrapper_speak_tts(
default_device: str,
message: str,
json_output: bool,
volume: int,
out: Path,
tts_command: Optional[str],
voice: Optional[str],
sample_rate: int,
):
try:
payload = build_tts_payload(message, out, volume, tts_command, voice, sample_rate)
except TTSError as exc:
fail(str(exc), json_output)
raise typer.Exit(1)
wrapper_capability(default_device, "speech", payload, json_output)
@screen_app.command("tools")
def screen_tools(json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False):
wrapper_tools("esp32_screen", json_output, live_only, cache_only, refresh)
@@ -400,8 +493,16 @@ def screen_text(message: str, json_output: bool = typer.Option(False, "--json"),
@screen_app.command("speak")
def screen_speak(message: str, json_output: bool = typer.Option(False, "--json")):
wrapper_capability("esp32_screen", "speech", {"text": message}, json_output)
def screen_speak(
message: str,
json_output: bool = typer.Option(False, "--json"),
volume: int = typer.Option(50, "--volume", min=0, max=100),
out: Path = typer.Option(Path("/tmp/reyna-screen-speech.wav"), "--out"),
tts_command: Optional[str] = typer.Option(None, "--tts-command", help="Command template that writes audio to {out} or stdout. Supports {text}, {raw_text}, {voice}."),
voice: Optional[str] = typer.Option(None, "--voice"),
sample_rate: int = typer.Option(16000, "--sample-rate"),
):
wrapper_speak_tts("esp32_screen", message, json_output, volume, out, tts_command, voice, sample_rate)
@screen_app.command("clear")
@@ -411,7 +512,7 @@ def screen_clear(json_output: bool = typer.Option(False, "--json"), color: int =
@screen_app.command("screenshot")
def screen_screenshot(out: Path = typer.Option(Path("/tmp/esp32-screen.png"), "--out"), json_output: bool = typer.Option(False, "--json")):
wrapper_capability("esp32_screen", "screenshot", {"out": str(out)}, json_output)
wrapper_capability("esp32_screen", "screenshot", {"out": str(out)}, json_output, binary_out=out)
@screen_app.command("battery")
@@ -419,6 +520,178 @@ def screen_battery(json_output: bool = typer.Option(False, "--json")):
wrapper_capability("esp32_screen", "battery", {}, json_output)
@laptop_app.command("tools")
def laptop_tools(json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False):
wrapper_tools("personal_laptop_screen", json_output, live_only, cache_only, refresh)
@laptop_app.command("describe")
def laptop_describe(json_output: bool = typer.Option(False, "--json"), for_hermes: bool = typer.Option(False, "--for-hermes")):
wrapper_describe("personal_laptop_screen", json_output, for_hermes)
@laptop_app.command("call")
def laptop_call(tool: str, args: str = typer.Option("{}", "--args"), json_output: bool = typer.Option(False, "--json")):
device = get_required_device("personal_laptop_screen", json_output)
result = call_device_tool(device, tool, parse_args_json(args, json_output))
emit(result, json_output)
if not result.get("ok"):
raise typer.Exit(1)
@laptop_app.command("text")
def laptop_text(message: str, json_output: bool = typer.Option(False, "--json"), x: int = 10, y: int = 20, size: int = 2):
wrapper_capability("personal_laptop_screen", "text", {"text": message, "x": x, "y": y, "size": size}, json_output)
@laptop_app.command("speak")
def laptop_speak(
message: str,
json_output: bool = typer.Option(False, "--json"),
volume: int = typer.Option(50, "--volume", min=0, max=100),
out: Path = typer.Option(Path("/tmp/reyna-laptop-speech.wav"), "--out"),
tts_command: Optional[str] = typer.Option(None, "--tts-command", help="Command template that writes audio to {out} or stdout. Supports {text}, {raw_text}, {voice}."),
voice: Optional[str] = typer.Option(None, "--voice"),
sample_rate: int = typer.Option(16000, "--sample-rate"),
):
wrapper_speak_tts("personal_laptop_screen", message, json_output, volume, out, tts_command, voice, sample_rate)
@laptop_app.command("clear")
def laptop_clear(json_output: bool = typer.Option(False, "--json"), color: int = 0):
wrapper_capability("personal_laptop_screen", "clear", {"color": color}, json_output)
@laptop_app.command("screenshot")
def laptop_screenshot(out: Path = typer.Option(Path("/tmp/laptop-mcp.png"), "--out"), json_output: bool = typer.Option(False, "--json")):
wrapper_capability("personal_laptop_screen", "screenshot", {"out": str(out)}, json_output, binary_out=out)
@laptop_app.command("battery")
def laptop_battery(json_output: bool = typer.Option(False, "--json")):
wrapper_capability("personal_laptop_screen", "battery", {}, json_output)
@laptop_app.command("sensors")
def laptop_sensors(json_output: bool = typer.Option(False, "--json")):
wrapper_capability("personal_laptop_screen", "sensors", {}, json_output)
@laptop_app.command("stats")
def laptop_stats(json_output: bool = typer.Option(False, "--json")):
wrapper_capability("personal_laptop_screen", "stream_stats", {}, json_output)
@computer_app.command("tools")
def computer_tools(json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False):
wrapper_tools("local_desktop_screen", json_output, live_only, cache_only, refresh)
@computer_app.command("describe")
def computer_describe(json_output: bool = typer.Option(False, "--json"), for_hermes: bool = typer.Option(False, "--for-hermes")):
wrapper_describe("local_desktop_screen", json_output, for_hermes)
@computer_app.command("call")
def computer_call(tool: str, args: str = typer.Option("{}", "--args"), json_output: bool = typer.Option(False, "--json")):
device = get_required_device("local_desktop_screen", json_output)
result = call_device_tool(device, tool, parse_args_json(args, json_output))
emit(result, json_output)
if not result.get("ok"):
raise typer.Exit(1)
@computer_app.command("text")
def computer_text(message: str, json_output: bool = typer.Option(False, "--json"), x: int = 10, y: int = 20, size: int = 2):
wrapper_capability("local_desktop_screen", "text", {"text": message, "x": x, "y": y, "size": size}, json_output)
@computer_app.command("speak")
def computer_speak(
message: str,
json_output: bool = typer.Option(False, "--json"),
volume: int = typer.Option(50, "--volume", min=0, max=100),
out: Path = typer.Option(Path("/tmp/reyna-computer-speech.wav"), "--out"),
tts_command: Optional[str] = typer.Option(None, "--tts-command", help="Command template that writes audio to {out} or stdout. Supports {text}, {raw_text}, {voice}."),
voice: Optional[str] = typer.Option(None, "--voice"),
sample_rate: int = typer.Option(16000, "--sample-rate"),
):
wrapper_speak_tts("local_desktop_screen", message, json_output, volume, out, tts_command, voice, sample_rate)
@computer_app.command("clear")
def computer_clear(json_output: bool = typer.Option(False, "--json"), color: int = 0):
wrapper_capability("local_desktop_screen", "clear", {"color": color}, json_output)
@computer_app.command("screenshot")
def computer_screenshot(out: Path = typer.Option(Path("/tmp/reyna-computer-mcp.png"), "--out"), json_output: bool = typer.Option(False, "--json")):
wrapper_capability("local_desktop_screen", "screenshot", {"out": str(out)}, json_output, binary_out=out)
@computer_app.command("battery")
def computer_battery(json_output: bool = typer.Option(False, "--json")):
wrapper_capability("local_desktop_screen", "battery", {}, json_output)
@computer_app.command("sensors")
def computer_sensors(json_output: bool = typer.Option(False, "--json")):
wrapper_capability("local_desktop_screen", "sensors", {}, json_output)
@computer_app.command("stats")
def computer_stats(json_output: bool = typer.Option(False, "--json")):
wrapper_capability("local_desktop_screen", "stream_stats", {}, json_output)
@computer_app.command("service-install")
def computer_service_install(json_output: bool = typer.Option(False, "--json"), enable: bool = typer.Option(True, "--enable/--no-enable")):
result = service_action("install", enable=enable)
if json_output:
emit({**result, "unit_content": unit_content()}, True)
else:
console.print(f"Installed {SERVICE_NAME} at {unit_path()}")
for item in result["results"]:
if item["stderr"]:
console.print(item["stderr"])
if not result.get("ok"):
raise typer.Exit(1)
@computer_app.command("service-status")
def computer_service_status(json_output: bool = typer.Option(False, "--json")):
emit({"ok": True, "service": SERVICE_NAME, "status": service_status()}, json_output)
@computer_app.command("service-start")
def computer_service_start(json_output: bool = typer.Option(False, "--json")):
result = service_action("start")
emit(result, json_output)
if not result.get("ok"):
raise typer.Exit(1)
@computer_app.command("service-stop")
def computer_service_stop(json_output: bool = typer.Option(False, "--json")):
result = service_action("stop")
emit(result, json_output)
if not result.get("ok"):
raise typer.Exit(1)
@computer_app.command("service-restart")
def computer_service_restart(json_output: bool = typer.Option(False, "--json")):
result = service_action("restart")
emit(result, json_output)
if not result.get("ok"):
raise typer.Exit(1)
@computer_app.command("service-logs")
def computer_service_logs(lines: int = typer.Option(80, "--lines")):
subprocess.run(["journalctl", "--user", "-u", SERVICE_NAME, "-n", str(lines), "--no-pager"], check=False)
@iphone_app.command("tools")
def iphone_tools(json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False):
wrapper_tools("iphone_mcp", json_output, live_only, cache_only, refresh)
@@ -435,13 +708,21 @@ def iphone_text(message: str, json_output: bool = typer.Option(False, "--json"))
@iphone_app.command("speak")
def iphone_speak(message: str, json_output: bool = typer.Option(False, "--json")):
wrapper_capability("iphone_mcp", "speech", {"text": message}, json_output)
def iphone_speak(
message: str,
json_output: bool = typer.Option(False, "--json"),
volume: int = typer.Option(50, "--volume", min=0, max=100),
out: Path = typer.Option(Path("/tmp/reyna-iphone-speech.wav"), "--out"),
tts_command: Optional[str] = typer.Option(None, "--tts-command", help="Command template that writes audio to {out} or stdout. Supports {text}, {raw_text}, {voice}."),
voice: Optional[str] = typer.Option(None, "--voice"),
sample_rate: int = typer.Option(16000, "--sample-rate"),
):
wrapper_speak_tts("iphone_mcp", message, json_output, volume, out, tts_command, voice, sample_rate)
@iphone_app.command("screenshot")
def iphone_screenshot(out: Path = typer.Option(Path("/tmp/iphone-mcp.png"), "--out"), json_output: bool = typer.Option(False, "--json")):
wrapper_capability("iphone_mcp", "screenshot", {"out": str(out)}, json_output)
wrapper_capability("iphone_mcp", "screenshot", {"out": str(out)}, json_output, binary_out=out)
@iphone_app.command("battery")
@@ -681,5 +962,286 @@ def zoom_recordings(user_id: str = "me", from_date: Optional[str] = typer.Option
fail(str(exc), json_output)
@email_app.command("tools")
def email_tools(json_output: bool = typer.Option(False, "--json")):
emit({"ok": True, "source": "local_thunderbird", "tools": email_tool_specs()}, json_output)
@email_app.command("doctor")
def email_doctor(json_output: bool = typer.Option(False, "--json")):
try:
emit({"ok": True, "source": "local_thunderbird", "result": ThunderbirdEmailClient().config_status()}, json_output)
except Exception as exc:
fail(str(exc), json_output)
@email_app.command("folders")
def email_folders(json_output: bool = typer.Option(False, "--json")):
try:
emit({"ok": True, "source": "local_thunderbird", "result": ThunderbirdEmailClient().folders()}, json_output)
except Exception as exc:
fail(str(exc), json_output)
@email_app.command("latest")
def email_latest(folder: Optional[str] = typer.Option(None, "--folder"), limit: int = 10, json_output: bool = typer.Option(False, "--json")):
try:
emit({"ok": True, "source": "local_thunderbird", "result": ThunderbirdEmailClient().latest(folder=folder, limit=limit)}, json_output)
except Exception as exc:
fail(str(exc), json_output)
@email_app.command("search")
def email_search(query: str, folder: Optional[str] = typer.Option(None, "--folder"), limit: int = 10, any_term: bool = typer.Option(False, "--any", help="Match any query term instead of requiring every term."), json_output: bool = typer.Option(False, "--json")):
try:
emit({"ok": True, "source": "local_thunderbird", "result": ThunderbirdEmailClient().search(query, folder=folder, limit=limit, require_all=not any_term)}, json_output)
except Exception as exc:
fail(str(exc), json_output)
@email_app.command("summarize")
def email_summarize(query: str, folder: Optional[str] = typer.Option(None, "--folder"), limit: int = 10, json_output: bool = typer.Option(False, "--json")):
"""Return concise matching email facts and snippets for a query."""
try:
emit({"ok": True, "source": "local_thunderbird", "result": ThunderbirdEmailClient().search(query, folder=folder, limit=limit)}, json_output)
except Exception as exc:
fail(str(exc), json_output)
@email_app.command("find-receipts")
def email_find_receipts(query: str = typer.Argument(""), limit: int = 10, json_output: bool = typer.Option(False, "--json")):
try:
emit({"ok": True, "source": "local_thunderbird", "result": ThunderbirdEmailClient().receipt_search(query=query, limit=limit)}, json_output)
except Exception as exc:
fail(str(exc), json_output)
@email_app.command("show")
def email_show(message_id: str, folder: Optional[str] = typer.Option(None, "--folder"), body: bool = typer.Option(False, "--body", help="Include a redacted body preview."), json_output: bool = typer.Option(False, "--json")):
try:
emit({"ok": True, "source": "local_thunderbird", "result": ThunderbirdEmailClient().show(message_id, folder=folder, include_body=body)}, json_output)
except Exception as exc:
fail(str(exc), json_output)
@macmini_app.command("tools")
def macmini_tools(json_output: bool = typer.Option(False, "--json")):
try:
emit({"ok": True, "source": "mcp", "server": "macmini", "tools": macmini_client().list_tools()}, json_output)
except Exception as exc:
fail(str(exc), json_output)
@macmini_app.command("call")
def macmini_call(tool: str, args: str = typer.Option("{}", "--args"), json_output: bool = typer.Option(False, "--json")):
try:
emit(call_macmini_tool(tool, parse_args_json(args, json_output)), json_output)
except Exception as exc:
fail(str(exc), json_output)
@macmini_app.command("ping")
def macmini_ping(json_output: bool = typer.Option(False, "--json")):
try:
ok = macmini_client().ping()
emit({"ok": ok, "server": "macmini", "online": ok}, json_output)
if not ok:
raise typer.Exit(1)
except Exception as exc:
fail(str(exc), json_output)
@macmini_calendar_app.command("calendars")
def macmini_calendar_calendars(json_output: bool = typer.Option(False, "--json")):
try:
emit(call_macmini_tool("calendar_list_calendars", {}), json_output)
except Exception as exc:
fail(str(exc), json_output)
@macmini_calendar_app.command("events")
def macmini_calendar_events(start: str, end: str, calendar: Optional[str] = None, calendar_index: Optional[int] = typer.Option(None, "--calendar-index"), limit: int = 50, json_output: bool = typer.Option(False, "--json")):
args: Dict[str, Any] = {"start": start, "end": end, "limit": limit}
if calendar is not None:
args["calendar"] = calendar
if calendar_index is not None:
args["calendarIndex"] = calendar_index
try:
emit(call_macmini_tool("calendar_list_events", args), json_output)
except Exception as exc:
fail(str(exc), json_output)
@macmini_calendar_app.command("create")
def macmini_calendar_create(title: str, start: str, end: str, all_day: bool = typer.Option(False, "--all-day"), notes: Optional[str] = None, location: Optional[str] = None, calendar: Optional[str] = None, calendar_index: Optional[int] = typer.Option(None, "--calendar-index"), json_output: bool = typer.Option(False, "--json")):
args: Dict[str, Any] = {"title": title, "start": start, "end": end, "allDay": all_day}
for key, value in {"notes": notes, "location": location, "calendar": calendar, "calendarIndex": calendar_index}.items():
if value is not None:
args[key] = value
try:
emit(call_macmini_tool("calendar_create_event", args), json_output)
except Exception as exc:
fail(str(exc), json_output)
@macmini_contacts_app.command("search")
def macmini_contacts_search(query: Optional[str] = None, limit: int = 20, json_output: bool = typer.Option(False, "--json")):
try:
emit(call_macmini_tool("contacts_search", {"query": query, "limit": limit}), json_output)
except Exception as exc:
fail(str(exc), json_output)
@macmini_contacts_app.command("read")
def macmini_contacts_read(contact_id: str, json_output: bool = typer.Option(False, "--json")):
try:
emit(call_macmini_tool("contacts_read", {"id": contact_id}), json_output)
except Exception as exc:
fail(str(exc), json_output)
@macmini_contacts_app.command("create")
def macmini_contacts_create(first_name: Optional[str] = typer.Option(None, "--first-name"), last_name: Optional[str] = typer.Option(None, "--last-name"), organization: Optional[str] = None, job_title: Optional[str] = typer.Option(None, "--job-title"), note: Optional[str] = None, email: Optional[str] = None, phone: Optional[str] = None, json_output: bool = typer.Option(False, "--json")):
args: Dict[str, Any] = {}
for key, value in {"firstName": first_name, "lastName": last_name, "organization": organization, "jobTitle": job_title, "note": note}.items():
if value is not None:
args[key] = value
if email:
args["email"] = {"label": "work", "value": email}
if phone:
args["phone"] = {"label": "mobile", "value": phone}
try:
emit(call_macmini_tool("contacts_create", args), json_output)
except Exception as exc:
fail(str(exc), json_output)
@macmini_notes_app.command("list")
def macmini_notes_list(query: Optional[str] = None, folder: Optional[str] = None, include_preview: bool = typer.Option(False, "--include-preview"), limit: int = 20, json_output: bool = typer.Option(False, "--json")):
args = {"query": query, "folder": folder, "includePreview": include_preview, "limit": limit}
try:
emit(call_macmini_tool("notes_list", args), json_output)
except Exception as exc:
fail(str(exc), json_output)
@macmini_notes_app.command("read")
def macmini_notes_read(note_id: str, json_output: bool = typer.Option(False, "--json")):
try:
emit(call_macmini_tool("notes_read", {"id": note_id}), json_output)
except Exception as exc:
fail(str(exc), json_output)
@macmini_notes_app.command("create")
def macmini_notes_create(title: str, body: str = "", folder: Optional[str] = None, json_output: bool = typer.Option(False, "--json")):
args: Dict[str, Any] = {"title": title, "body": body}
if folder is not None:
args["folder"] = folder
try:
emit(call_macmini_tool("notes_create", args), json_output)
except Exception as exc:
fail(str(exc), json_output)
@macmini_reminders_app.command("lists")
def macmini_reminders_lists(json_output: bool = typer.Option(False, "--json")):
try:
emit(call_macmini_tool("reminders_list_lists", {}), json_output)
except Exception as exc:
fail(str(exc), json_output)
@macmini_reminders_app.command("list")
def macmini_reminders_list(list_name: Optional[str] = typer.Option(None, "--list"), completed: Optional[bool] = typer.Option(False, "--completed/--incomplete"), limit: int = 25, json_output: bool = typer.Option(False, "--json")):
args = {"list": list_name, "completed": completed, "limit": limit}
try:
emit(call_macmini_tool("reminders_list", args), json_output)
except Exception as exc:
fail(str(exc), json_output)
@macmini_reminders_app.command("create")
def macmini_reminders_create(title: str, list_name: Optional[str] = typer.Option(None, "--list"), notes: Optional[str] = None, due: Optional[str] = None, json_output: bool = typer.Option(False, "--json")):
args: Dict[str, Any] = {"title": title}
for key, value in {"list": list_name, "notes": notes, "due": due}.items():
if value is not None:
args[key] = value
try:
emit(call_macmini_tool("reminders_create", args), json_output)
except Exception as exc:
fail(str(exc), json_output)
def emit_deco_result(tool: str, result: Dict[str, Any], json_output: bool = False) -> None:
emit({"ok": True, "source": "direct", "tool": tool, "result": result}, json_output)
@deco_app.command("config")
def deco_config(json_output: bool = typer.Option(False, "--json")):
try:
emit_deco_result("deco_get_config_status", DecoDirectClient().config_status(), json_output)
except Exception as exc:
fail(str(exc), json_output)
@deco_app.command("overview")
def deco_overview(json_output: bool = typer.Option(False, "--json")):
try:
emit_deco_result("deco_get_overview", DecoDirectClient().overview(), json_output)
except Exception as exc:
fail(str(exc), json_output)
@deco_app.command("clients")
def deco_clients(json_output: bool = typer.Option(False, "--json")):
try:
emit_deco_result("deco_list_clients", DecoDirectClient().list_clients(), json_output)
except Exception as exc:
fail(str(exc), json_output)
@deco_app.command("firmware")
def deco_firmware(json_output: bool = typer.Option(False, "--json")):
try:
emit_deco_result("deco_get_firmware", DecoDirectClient().firmware(), json_output)
except Exception as exc:
fail(str(exc), json_output)
@deco_app.command("ipv4")
def deco_ipv4(json_output: bool = typer.Option(False, "--json")):
try:
emit_deco_result("deco_get_ipv4_status", DecoDirectClient().ipv4_status(), json_output)
except Exception as exc:
fail(str(exc), json_output)
# Keep the old path working for scripts that still call `reyna-cli macmini deco ...`,
# but route it directly to the Deco router instead of through the Mac MCP server.
macmini_deco_app.command("config")(deco_config)
macmini_deco_app.command("overview")(deco_overview)
macmini_deco_app.command("clients")(deco_clients)
macmini_deco_app.command("firmware")(deco_firmware)
macmini_deco_app.command("ipv4")(deco_ipv4)
@app.command("web")
def web_server(
host: str = typer.Option("127.0.0.1", "--host", help="Host/IP address to bind."),
port: int = typer.Option(8765, "--port", help="TCP port to listen on."),
reload: bool = typer.Option(False, "--reload", help="Enable uvicorn reload for development."),
):
"""Run the browser web interface for Reyna CLI.
Auth defaults to Authentik OIDC at auth.reynafamily.com. Configure with
REYNA_WEB_SESSION_SECRET, REYNA_WEB_AUTH_CLIENT_ID,
REYNA_WEB_AUTH_CLIENT_SECRET, and REYNA_WEB_PUBLIC_URL.
"""
from reyna_cli.web import run_web_server
run_web_server(host=host, port=port, reload=reload)
if __name__ == "__main__":
app()
+6
View File
@@ -115,6 +115,12 @@ def get_device(name: str, registry: Optional[Registry] = None) -> Optional[Devic
"iphone": "iphone_mcp",
"phone": "iphone_mcp",
"arm": "robot_arm",
"laptop": "personal_laptop_screen",
"laptop_screen": "personal_laptop_screen",
"personal_laptop": "personal_laptop_screen",
"computer": "local_desktop_screen",
"desktop": "local_desktop_screen",
"local": "local_desktop_screen",
}
normalized = shorthand.get(normalized, normalized)
for device in registry.devices:
+265
View File
@@ -0,0 +1,265 @@
from __future__ import annotations
import base64
import os
import subprocess
from json import dumps
from typing import Any, Callable, Dict, List
from reyna_cli.env import load_hermes_env
def _enum_value(value: Any) -> Any:
return getattr(value, "value", str(value) if value is not None else None)
def _getattr_any(obj: Any, names: list[str], default: Any = None) -> Any:
for name in names:
if hasattr(obj, name):
value = getattr(obj, name)
if value is not None:
return value
return default
def _decode_name(value: Any) -> Any:
if not isinstance(value, str):
return value
try:
return base64.b64decode(value).decode()
except Exception:
return value
def default_gateway() -> str | None:
"""Return the Linux default gateway IP when DECO_HOST is not configured."""
commands = [
["ip", "route", "show", "default"],
["/sbin/ip", "route", "show", "default"],
]
for command in commands:
try:
result = subprocess.run(command, check=True, capture_output=True, text=True, timeout=5)
except Exception:
continue
parts = result.stdout.split()
if "via" in parts:
idx = parts.index("via")
if idx + 1 < len(parts):
return parts[idx + 1]
return None
class DecoDirectClient:
"""Direct TP-Link Deco reader using the same library mac_mcp used behind MCP."""
def __init__(
self,
host: str | None = None,
password: str | None = None,
username: str | None = None,
verify_ssl: bool | None = None,
timeout: int | None = None,
) -> None:
load_hermes_env()
self.host = host or os.environ.get("DECO_HOST") or default_gateway()
self.password = password or os.environ.get("DECO_PASSWORD")
self.username = username or os.environ.get("DECO_USERNAME", "admin")
if verify_ssl is None:
verify_ssl = os.environ.get("DECO_VERIFY_SSL", "true").lower() not in {"0", "false", "no"}
self.verify_ssl = verify_ssl
self.timeout = timeout if timeout is not None else int(os.environ.get("DECO_TIMEOUT", "10"))
def config_status(self) -> Dict[str, Any]:
return {
"host": self.host or "(default gateway unavailable)",
"username": self.username,
"passwordConfigured": bool(self.password),
"passwordLength": len(self.password or ""),
"verifySsl": self.verify_ssl,
"timeout": self.timeout,
"source": "direct",
}
def _client(self) -> Any:
if not self.password:
raise RuntimeError("DECO_PASSWORD must be set in ~/.hermes/.env or the process environment")
if not self.host:
raise RuntimeError("DECO_HOST must be set; default gateway detection failed")
try:
from tplinkrouterc6u import TPLinkDecoClient
except ImportError as exc:
raise RuntimeError("tplinkrouterc6u is required for direct Deco commands") from exc
return TPLinkDecoClient(
host=self.host,
password=self.password,
username=self.username,
verify_ssl=self.verify_ssl,
timeout=self.timeout,
)
def _with_client(self, callback: Callable[[Any], Any]) -> Any:
deco = self._client()
try:
return callback(deco)
finally:
try:
deco.logout()
except Exception:
pass
def overview(self) -> Dict[str, Any]:
def run(deco: Any) -> Dict[str, Any]:
status = deco.get_status()
firmware = deco.get_firmware()
return {
"status": self.status_to_dict(status, include_clients=False),
"firmware": self.firmware_to_dict(firmware),
"decos": self.decos_to_dict(getattr(deco, "devices", [])),
}
return self._with_client(run)
def list_clients(self) -> Dict[str, Any]:
def run(deco: Any) -> Dict[str, Any]:
# get_status() computes reliable aggregate counts; the raw client
# list preserves Deco-specific fields when firmware returns them
# (for example linked mesh-node metadata).
deco.get_status()
decos = self._ensure_decos(deco)
return {
"clients": self._raw_client_list(deco),
"decos": self.decos_to_dict(decos),
}
return self._with_client(run)
def ipv4_status(self) -> Dict[str, Any]:
return self._with_client(lambda deco: self.ipv4_to_dict(deco.get_ipv4_status()))
def firmware(self) -> Dict[str, Any]:
return self._with_client(lambda deco: self.firmware_to_dict(deco.get_firmware()))
def _ensure_decos(self, deco: Any) -> list[dict[str, Any]]:
devices = getattr(deco, "devices", []) or []
if not devices:
try:
deco.get_firmware()
devices = getattr(deco, "devices", []) or []
except Exception:
devices = []
return devices
def _raw_client_list(self, deco: Any) -> list[dict[str, Any]]:
data = deco.request(
"admin/client?form=client_list",
dumps({"operation": "read", "params": {"device_mac": "default"}}),
)
clients = data.get("client_list", []) if isinstance(data, dict) else []
return [self.raw_client_to_dict(item) for item in clients if item.get("online", True)]
@staticmethod
def raw_client_to_dict(item: dict[str, Any]) -> Dict[str, Any]:
wire_type = item.get("wire_type")
connection = "wired" if wire_type == "wired" else item.get("connection_type") or wire_type
return {
"hostname": _decode_name(item.get("name") or item.get("hostname")),
"mac": item.get("mac"),
"ip": item.get("ip"),
"connection": connection,
"interface": item.get("interface"),
"upSpeed": item.get("up_speed", item.get("upSpeed", 0)),
"downSpeed": item.get("down_speed", item.get("downSpeed", 0)),
"active": item.get("online", item.get("active", True)),
"linkedDecoMac": item.get("device_mac") or item.get("linked_deco_mac") or item.get("linkedDecoMac") or item.get("parent_mac"),
"linkedDecoName": _decode_name(item.get("device_name") or item.get("linked_deco_name") or item.get("linkedDecoName") or item.get("node_name")),
"linkedDecoRole": item.get("device_role") or item.get("linked_deco_role") or item.get("linkedDecoRole"),
}
@staticmethod
def device_to_dict(device: Any) -> Dict[str, Any]:
return {
"hostname": _getattr_any(device, ["hostname", "name"]),
"mac": _getattr_any(device, ["macaddr", "mac"]),
"ip": _getattr_any(device, ["ipaddr", "ip"]),
"connection": _enum_value(_getattr_any(device, ["type", "connection"])),
"interface": _getattr_any(device, ["interface"]),
"upSpeed": _getattr_any(device, ["up_speed", "upSpeed"], 0),
"downSpeed": _getattr_any(device, ["down_speed", "downSpeed"], 0),
"active": _getattr_any(device, ["active"], True),
"linkedDecoMac": _getattr_any(device, ["linked_deco_mac", "linkedDecoMac"]),
"linkedDecoName": _getattr_any(device, ["linked_deco_name", "linkedDecoName"]),
"linkedDecoRole": _getattr_any(device, ["linked_deco_role", "linkedDecoRole"]),
}
@staticmethod
def decos_to_dict(devices: Any) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for item in devices or []:
if not isinstance(item, dict):
continue
out.append(
{
"name": _decode_name(item.get("name")) or item.get("device_name"),
"mac": item.get("mac"),
"ip": item.get("ip"),
"model": item.get("device_model"),
"hardwareVersion": item.get("hardware_ver"),
"firmwareVersion": item.get("software_ver"),
"role": item.get("role"),
"online": item.get("online"),
"connectionType": item.get("connection_type"),
}
)
return out
@staticmethod
def firmware_to_dict(firmware: Any) -> Dict[str, Any]:
return {
"model": firmware.model,
"hardwareVersion": firmware.hardware_version,
"firmwareVersion": firmware.firmware_version,
}
@staticmethod
def ipv4_to_dict(status: Any) -> Dict[str, Any]:
return {
"wanMac": status.wan_macaddr,
"wanIp": status.wan_ipv4_ipaddr,
"wanGateway": status.wan_ipv4_gateway,
"wanConnectionType": status.wan_ipv4_conntype,
"wanNetmask": status.wan_ipv4_netmask,
"wanPrimaryDns": status.wan_ipv4_pridns,
"wanSecondaryDns": status.wan_ipv4_snddns,
"lanMac": status.lan_macaddr,
"lanIp": status.lan_ipv4_ipaddr,
"lanNetmask": status.lan_ipv4_netmask,
}
def status_to_dict(self, status: Any, include_clients: bool = True) -> Dict[str, Any]:
data = {
"wanMac": status.wan_macaddr,
"lanMac": status.lan_macaddr,
"wanIp": status.wan_ipv4_addr,
"lanIp": status.lan_ipv4_addr,
"wanGateway": status.wan_ipv4_gateway,
"connectionType": status.conn_type,
"cpuUsage": status.cpu_usage,
"memoryUsage": status.mem_usage,
"clientsTotal": status.clients_total,
"wiredClientsTotal": status.wired_total,
"wifiClientsTotal": status.wifi_clients_total,
"guestClientsTotal": status.guest_clients_total,
"iotClientsTotal": status.iot_clients_total,
"wifi": {
"host2g": status.wifi_2g_enable,
"host5g": status.wifi_5g_enable,
"host6g": status.wifi_6g_enable,
"guest2g": status.guest_2g_enable,
"guest5g": status.guest_5g_enable,
"guest6g": status.guest_6g_enable,
},
}
if include_clients:
data["clients"] = [self.device_to_dict(device) for device in status.devices]
return data
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional
SERVICE_NAME = "reyna-desktop-client.service"
SERVICE_DESCRIPTION = "Reyna Desktop Companion Client"
CLIENT_PATH = Path.home() / "Projects" / "Screen" / "desktop_client" / "client.py"
WORKING_DIRECTORY = Path.home()
PYTHON_BIN = Path("/usr/bin/python3")
WAYLAND_DISPLAY = "wayland-0"
XDG_RUNTIME_DIR = "/run/user/1000"
def user_unit_dir() -> Path:
return Path.home() / ".config" / "systemd" / "user"
def unit_path() -> Path:
return user_unit_dir() / SERVICE_NAME
def unit_content() -> str:
return f"""[Unit]
Description={SERVICE_DESCRIPTION}
After=graphical-session.target network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory={WORKING_DIRECTORY}
Environment=WAYLAND_DISPLAY={WAYLAND_DISPLAY}
Environment=XDG_RUNTIME_DIR={XDG_RUNTIME_DIR}
ExecStart={PYTHON_BIN} {CLIENT_PATH}
Restart=always
RestartSec=5
KillSignal=SIGINT
[Install]
WantedBy=default.target
"""
def install_unit() -> Path:
path = unit_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(unit_content(), encoding="utf-8")
return path
def run_command(args: List[str], check: bool = False) -> subprocess.CompletedProcess[str]:
return subprocess.run(args, capture_output=True, text=True, check=check)
def systemctl(action: str, *extra: str, check: bool = False) -> subprocess.CompletedProcess[str]:
return run_command(["systemctl", "--user", action, *extra], check=check)
def service_status() -> Dict[str, Any]:
active = systemctl("is-active", SERVICE_NAME)
enabled = systemctl("is-enabled", SERVICE_NAME)
return {
"unit_path": str(unit_path()),
"installed": unit_path().exists(),
"active": active.stdout.strip() or active.stderr.strip(),
"active_ok": active.returncode == 0,
"enabled": enabled.stdout.strip() or enabled.stderr.strip(),
"enabled_ok": enabled.returncode == 0,
}
def service_action(action: str, enable: Optional[bool] = None) -> Dict[str, Any]:
results: List[Dict[str, Any]] = []
if action == "install":
path = install_unit()
results.append({"command": "write_unit", "returncode": 0, "stdout": str(path), "stderr": ""})
commands: List[List[str]] = [["systemctl", "--user", "daemon-reload"]]
if enable is True:
commands.append(["systemctl", "--user", "enable", SERVICE_NAME])
for command in commands:
proc = run_command(command)
results.append({"command": " ".join(command), "returncode": proc.returncode, "stdout": proc.stdout.strip(), "stderr": proc.stderr.strip()})
else:
proc = systemctl(action, SERVICE_NAME)
results.append({"command": f"systemctl --user {action} {SERVICE_NAME}", "returncode": proc.returncode, "stdout": proc.stdout.strip(), "stderr": proc.stderr.strip()})
return {"ok": all(item["returncode"] == 0 for item in results), "service": SERVICE_NAME, "results": results, "status": service_status()}
+296
View File
@@ -0,0 +1,296 @@
from __future__ import annotations
import hashlib
import html
import mailbox
import os
import re
from dataclasses import dataclass
from email.header import decode_header, make_header
from email.message import Message
from pathlib import Path
from typing import Any, Iterable
DEFAULT_PROFILE = Path.home() / "snap/thunderbird/common/.thunderbird/tjug9cp5.default"
DEFAULT_INBOX = DEFAULT_PROFILE / "ImapMail/imap.gmail.com/INBOX"
SECRET_PATTERNS = [
re.compile(r"https?://\S*(?:token|code|auth|login|reset|verify)\S*", re.I),
re.compile(r"\b\d{12,}\b"),
]
@dataclass(frozen=True)
class LocalEmail:
id: str
index: int
folder: str
subject: str
sender: str
date: str
message_id: str
snippets: list[str]
def as_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"index": self.index,
"folder": self.folder,
"date": self.date,
"from": self.sender,
"subject": self.subject,
"message_id": self.message_id,
"snippets": self.snippets,
}
def _decode_header_value(raw: str | None) -> str:
if not raw:
return ""
try:
return str(make_header(decode_header(raw)))
except Exception:
return str(raw)
def _decode_payload(payload: bytes, charset: str | None) -> str:
candidates = []
if charset:
candidates.append(charset.split(";", 1)[0].strip().strip('"'))
candidates.extend(["utf-8", "cp1252", "latin-1"])
for candidate in candidates:
if not candidate:
continue
try:
return payload.decode(candidate, errors="replace")
except LookupError:
continue
return payload.decode("utf-8", errors="replace")
def message_body(msg: Message, max_chars: int = 250_000) -> str:
chunks: list[str] = []
parts: Iterable[Message] = msg.walk() if msg.is_multipart() else [msg]
for part in parts:
disposition = (part.get("Content-Disposition") or "").lower()
if "attachment" in disposition:
continue
if part.get_content_type() not in {"text/plain", "text/html"}:
continue
payload = part.get_payload(decode=True)
if isinstance(payload, bytes):
chunks.append(_decode_payload(payload, part.get_content_charset()))
if sum(len(chunk) for chunk in chunks) >= max_chars:
break
text = "\n".join(chunks)[:max_chars]
text = html.unescape(re.sub(r"<(br|/p|/div|/tr|/li|/td|/th)[^>]*>", "\n", text, flags=re.I))
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n\s+", "\n", text)
return text.strip()
def _clean_snippet(text: str) -> str:
text = re.sub(r"[\u034f\u200b-\u200f\u00ad]", "", text)
text = re.sub(r"\s+", " ", text).strip()
for pattern in SECRET_PATTERNS:
text = pattern.sub("[REDACTED]", text)
return text[:500]
def _message_id(folder: Path, index: int, msg: Message) -> str:
basis = "|".join(
[
str(folder),
str(index),
_decode_header_value(msg.get("Message-ID")),
_decode_header_value(msg.get("Date")),
_decode_header_value(msg.get("Subject")),
]
)
return hashlib.sha1(basis.encode("utf-8", errors="replace")).hexdigest()[:16]
class ThunderbirdEmailClient:
"""Read-only local Thunderbird MBOX search client."""
def __init__(self, profile: str | Path | None = None):
self.profile = Path(profile or os.getenv("REYNA_THUNDERBIRD_PROFILE") or DEFAULT_PROFILE).expanduser()
def config_status(self) -> dict[str, Any]:
inbox = self.default_inbox()
return {
"profile": str(self.profile),
"profile_exists": self.profile.exists(),
"default_inbox": str(inbox),
"default_inbox_exists": inbox.exists(),
"folders": len(self.folders()) if self.profile.exists() else 0,
"read_only": True,
}
def default_inbox(self) -> Path:
env_inbox = os.getenv("REYNA_THUNDERBIRD_INBOX")
if env_inbox:
return Path(env_inbox).expanduser()
if self.profile == DEFAULT_PROFILE:
return DEFAULT_INBOX
candidates = sorted((self.profile / "ImapMail").glob("*/INBOX")) if (self.profile / "ImapMail").exists() else []
return candidates[0] if candidates else self.profile / "ImapMail/imap.gmail.com/INBOX"
def folders(self) -> list[dict[str, Any]]:
roots = [self.profile / "ImapMail", self.profile / "Mail"]
folders: list[dict[str, Any]] = []
for root in roots:
if not root.exists():
continue
for path in root.rglob("*"):
if not path.is_file():
continue
if path.suffix in {".msf", ".dat"} or ".sbd" in path.name:
continue
try:
if path.stat().st_size == 0:
continue
except OSError:
continue
rel = str(path.relative_to(self.profile))
folders.append({"name": path.name, "path": str(path), "relative_path": rel, "size_bytes": path.stat().st_size})
return sorted(folders, key=lambda item: (item["relative_path"].lower()))
def _resolve_folder(self, folder: str | None = None) -> Path:
if not folder or folder.upper() == "INBOX":
return self.default_inbox()
path = Path(folder).expanduser()
if path.is_absolute():
return path
matches = [Path(item["path"]) for item in self.folders() if item["relative_path"] == folder or item["name"].lower() == folder.lower()]
if not matches:
raise FileNotFoundError(f"Thunderbird folder not found: {folder}")
return matches[0]
def _iter_messages(self, folder_path: Path, newest_first: bool = True) -> Iterable[tuple[int, Message]]:
if not folder_path.exists():
raise FileNotFoundError(f"Thunderbird MBOX not found: {folder_path}")
mbox = mailbox.mbox(str(folder_path), create=False)
try:
keys = list(mbox.keys())
if newest_first:
keys.reverse()
for key in keys:
try:
yield int(key), mbox[key]
except Exception:
continue
finally:
try:
mbox.close()
except Exception:
pass
def _email_from_message(self, folder_path: Path, index: int, msg: Message, terms: list[str] | None = None, include_body: bool = False) -> dict[str, Any]:
subject = _decode_header_value(msg.get("Subject"))
sender = _decode_header_value(msg.get("From"))
date = _decode_header_value(msg.get("Date"))
msgid = _decode_header_value(msg.get("Message-ID"))
body = message_body(msg)
snippets = self._snippets(subject, sender, date, body, terms or [])
data = LocalEmail(
id=_message_id(folder_path, index, msg),
index=index,
folder=str(folder_path),
subject=subject,
sender=sender,
date=date,
message_id=msgid,
snippets=snippets,
).as_dict()
if include_body:
data["body"] = _clean_snippet(body) if len(body) <= 500 else _clean_snippet(body[:500]) + " ...[truncated]"
return data
def _snippets(self, subject: str, sender: str, date: str, body: str, terms: list[str], max_snippets: int = 5) -> list[str]:
if not terms:
return [_clean_snippet(line) for line in [subject, sender, date] if line][:max_snippets]
snippets: list[str] = []
for line in [subject, sender, date, *body.splitlines()]:
low = line.lower()
if any(term in low for term in terms):
cleaned = _clean_snippet(line)
if not cleaned:
continue
if (":" in cleaned and cleaned.endswith(";")) or "{" in cleaned or "}" in cleaned:
continue
if cleaned not in snippets:
snippets.append(cleaned)
if len(snippets) >= max_snippets:
break
return snippets
def search(self, query: str, folder: str | None = None, limit: int = 10, require_all: bool = True) -> dict[str, Any]:
terms = [term.lower() for term in re.findall(r'"([^"]+)"|(\S+)', query) for term in term if term]
if not terms:
raise ValueError("Search query cannot be empty")
folder_path = self._resolve_folder(folder)
results: list[dict[str, Any]] = []
scanned = 0
for index, msg in self._iter_messages(folder_path, newest_first=True):
scanned += 1
subject = _decode_header_value(msg.get("Subject"))
sender = _decode_header_value(msg.get("From"))
date = _decode_header_value(msg.get("Date"))
body = message_body(msg)
haystack = f"{subject}\n{sender}\n{date}\n{body}".lower()
matched = all(term in haystack for term in terms) if require_all else any(term in haystack for term in terms)
if matched:
results.append(self._email_from_message(folder_path, index, msg, terms))
if len(results) >= limit:
break
return {"folder": str(folder_path), "query": query, "limit": limit, "scanned": scanned, "results": results}
def latest(self, folder: str | None = None, limit: int = 10) -> dict[str, Any]:
folder_path = self._resolve_folder(folder)
results = []
for index, msg in self._iter_messages(folder_path, newest_first=True):
results.append(self._email_from_message(folder_path, index, msg, []))
if len(results) >= limit:
break
return {"folder": str(folder_path), "limit": limit, "results": results}
def show(self, message_id: str, folder: str | None = None, include_body: bool = False) -> dict[str, Any]:
folder_path = self._resolve_folder(folder)
for index, msg in self._iter_messages(folder_path, newest_first=False):
if _message_id(folder_path, index, msg) == message_id or _decode_header_value(msg.get("Message-ID")) == message_id:
return self._email_from_message(folder_path, index, msg, [], include_body=include_body)
raise KeyError(f"Message not found: {message_id}")
def receipt_search(self, query: str = "", limit: int = 10) -> dict[str, Any]:
query_terms = [term.lower() for term in re.findall(r'"([^"]+)"|(\S+)', query) for term in term if term]
receipt_terms = ["receipt", "invoice", "order", "purchase", "confirmation", "warranty", "protection plan"]
folder_path = self.default_inbox()
results: list[dict[str, Any]] = []
scanned = 0
for index, msg in self._iter_messages(folder_path, newest_first=True):
scanned += 1
subject = _decode_header_value(msg.get("Subject"))
sender = _decode_header_value(msg.get("From"))
date = _decode_header_value(msg.get("Date"))
body = message_body(msg)
haystack = f"{subject}\n{sender}\n{date}\n{body}".lower()
if query_terms and not all(term in haystack for term in query_terms):
continue
if any(term in haystack for term in receipt_terms):
results.append(self._email_from_message(folder_path, index, msg, [*query_terms, *receipt_terms]))
if len(results) >= limit:
break
return {"folder": str(folder_path), "query": query, "limit": limit, "scanned": scanned, "results": results}
def email_tool_specs() -> list[dict[str, str]]:
return [
{"name": "doctor", "description": "Check local Thunderbird profile and default INBOX status"},
{"name": "folders", "description": "List local Thunderbird MBOX folders"},
{"name": "latest", "description": "List latest email headers from a folder"},
{"name": "search", "description": "Read-only search of local Thunderbird email"},
{"name": "find-receipts", "description": "Search receipts, orders, warranties, and protection-plan emails"},
{"name": "show", "description": "Show one message by id, optionally with a redacted body preview"},
]
+6 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import os
import uuid
from typing import Any, Dict, List, Optional
@@ -7,15 +8,19 @@ import httpx
class MCPClient:
def __init__(self, base_url: str, timeout: float = 2.0):
def __init__(self, base_url: str, timeout: float | None = None, headers: Optional[Dict[str, str]] = None):
self.base_url = base_url.rstrip("/")
self.session_id: Optional[str] = None
self.extra_headers = headers or {}
if timeout is None:
timeout = float(os.environ.get("REYNA_MCP_TIMEOUT", "10"))
self.client = httpx.Client(timeout=timeout)
def _headers(self) -> Dict[str, str]:
headers = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
**self.extra_headers,
}
if self.session_id:
headers["mcp-session-id"] = self.session_id
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
import os
import shlex
import shutil
import subprocess
import tempfile
import asyncio
from pathlib import Path
from typing import Sequence
class TTSError(RuntimeError):
"""Raised when local text-to-speech generation cannot produce a WAV file."""
def _run(command: Sequence[str], *, timeout: int = 60) -> None:
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False)
if result.returncode != 0:
details = (result.stderr or result.stdout or "").strip()
raise TTSError(f"TTS command failed ({' '.join(command)}): {details}")
def _rewrite_simple_pcm_wav(source: Path, dest: Path) -> None:
"""Rewrite WAV with only fmt/data chunks for picky embedded decoders."""
import wave
with wave.open(str(source), "rb") as reader:
params = reader.getparams()
frames = reader.readframes(reader.getnframes())
with wave.open(str(dest), "wb") as writer:
writer.setnchannels(params.nchannels)
writer.setsampwidth(params.sampwidth)
writer.setframerate(params.framerate)
writer.writeframes(frames)
def _convert_to_pcm_wav(source: Path, dest: Path, sample_rate: int) -> None:
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
if source.suffix.lower() == ".wav":
_rewrite_simple_pcm_wav(source, dest)
return
raise TTSError("ffmpeg is required to convert TTS audio to WAV, but it was not found")
ffmpeg_dest = dest.with_suffix(".ffmpeg.wav")
_run([
ffmpeg,
"-y",
"-loglevel",
"error",
"-i",
str(source),
"-ac",
"1",
"-ar",
str(sample_rate),
"-sample_fmt",
"s16",
str(ffmpeg_dest),
])
_rewrite_simple_pcm_wav(ffmpeg_dest, dest)
def _custom_tts(text: str, raw_output: Path, command_template: str, voice: str | None) -> None:
try:
template_args = shlex.split(command_template)
except ValueError as exc:
raise TTSError(f"Invalid custom TTS command template: {exc}") from exc
if not template_args:
raise TTSError("Custom TTS command template cannot be empty")
if any("{raw_text}" in arg for arg in template_args):
raise TTSError("{raw_text} is no longer supported in REYNA_TTS_COMMAND; use {text} as a command argument instead")
command = [
arg.format(
text=text,
out=str(raw_output),
voice=voice or "",
)
for arg in template_args
]
result = subprocess.run(command, capture_output=True, timeout=60, check=False)
if result.returncode != 0:
details = (result.stderr or result.stdout or b"").decode(errors="replace").strip()
raise TTSError(f"Custom TTS command failed: {details}")
if raw_output.exists() and raw_output.stat().st_size > 0:
return
if result.stdout:
raw_output.write_bytes(result.stdout)
return
raise TTSError("Custom TTS command did not write an output file or stdout audio")
async def _edge_tts_async(text: str, mp3_output: Path, voice: str | None) -> None:
import edge_tts
communicate = edge_tts.Communicate(text, voice or "en-US-AriaNeural")
await communicate.save(str(mp3_output))
def _edge_tts(text: str, mp3_output: Path, voice: str | None) -> None:
try:
import edge_tts # noqa: F401
except ImportError as exc:
raise TTSError("edge-tts is not installed") from exc
try:
asyncio.run(_edge_tts_async(text, mp3_output, voice))
except Exception as exc:
raise TTSError(f"edge-tts failed: {exc}") from exc
def synthesize_wav(
text: str,
output_path: Path,
*,
command_template: str | None = None,
voice: str | None = None,
sample_rate: int = 16000,
) -> Path:
"""Synthesize text into mono 16-bit PCM WAV suitable for small-device playback.
TTS command selection order:
1. explicit command_template argument
2. REYNA_TTS_COMMAND environment variable
3. espeak-ng/espeak/say if installed
4. edge-tts Python package if installed
command_template may use {text}, {out}, and {voice}. It is parsed with shlex and
executed directly without a shell; shell pipes/redirection are intentionally not
supported. If it does not write {out}, stdout is captured as audio.
"""
message = text.strip()
if not message:
raise TTSError("Text to speak cannot be empty")
command_template = command_template or os.environ.get("REYNA_TTS_COMMAND")
voice = voice or os.environ.get("REYNA_TTS_VOICE")
output_path = output_path.expanduser()
output_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="reyna-tts-") as tmp:
tmp_dir = Path(tmp)
raw_output = tmp_dir / "tts-output"
wav_output = tmp_dir / "speech.wav"
if command_template:
_custom_tts(message, raw_output, command_template, voice)
_convert_to_pcm_wav(raw_output, wav_output, sample_rate)
elif espeak := (shutil.which("espeak-ng") or shutil.which("espeak")):
espeak_args = [espeak]
if voice:
espeak_args.extend(["-v", voice])
espeak_args.extend(["-w", str(raw_output.with_suffix(".wav")), message])
_run(espeak_args)
_convert_to_pcm_wav(raw_output.with_suffix(".wav"), wav_output, sample_rate)
elif say := shutil.which("say"):
aiff_output = raw_output.with_suffix(".aiff")
say_args = [say]
if voice:
say_args.extend(["-v", voice])
say_args.extend(["-o", str(aiff_output), message])
_run(say_args)
_convert_to_pcm_wav(aiff_output, wav_output, sample_rate)
else:
mp3_output = raw_output.with_suffix(".mp3")
try:
_edge_tts(message, mp3_output, voice)
_convert_to_pcm_wav(mp3_output, wav_output, sample_rate)
except TTSError as exc:
raise TTSError(
"No local TTS engine found. Install espeak-ng/espeak, install edge-tts, or set "
"REYNA_TTS_COMMAND to a command that writes audio to {out} or stdout."
) from exc
output_path.write_bytes(wav_output.read_bytes())
return output_path
+3 -1
View File
@@ -9,6 +9,8 @@ CAPABILITY_MAPPINGS: Dict[str, List[str]] = {
"clear": ["clear_screen", "clear", "fill_screen"],
"screenshot": ["screenshot", "capture_screen", "get_screenshot"],
"battery": ["get_battery", "battery", "battery_status"],
"sensors": ["get_sensors", "sensors", "sensor_status"],
"stream_stats": ["get_stream_stats", "stream_stats", "video_stats"],
"state": ["get_arm_state", "get_state", "status"],
"home": ["home_arm", "home", "reset_position"],
"wave": ["approved_double_speed_wave", "wave", "wave_hand"],
@@ -61,5 +63,5 @@ def resolve_tool_name(capability: str, available_tools: List[Dict[str, Any]]) ->
def infer_capabilities(tools: List[Dict[str, Any]]) -> Dict[str, Optional[str]]:
caps = ["text", "speech", "clear", "screenshot", "battery", "state", "home", "wave"]
caps = ["text", "speech", "clear", "screenshot", "battery", "sensors", "stream_stats", "state", "home", "wave"]
return {cap: resolve_tool_name(cap, tools) for cap in caps}
+452
View File
@@ -0,0 +1,452 @@
from __future__ import annotations
import html
import json
import os
import secrets
import shlex
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from authlib.integrations.starlette_client import OAuth
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.security.utils import get_authorization_scheme_param
from reyna_cli.env import load_hermes_env
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel, Field
from starlette.middleware.sessions import SessionMiddleware
DEFAULT_AUTHENTIK_ISSUER = "https://auth.reynafamily.com/application/o/reyna-cli-web/"
SESSION_USER_KEY = "reyna_web_user"
@dataclass(frozen=True)
class WebSettings:
auth_enabled: bool
dev_auth: bool
session_secret: str
authentik_issuer: str
client_id: str | None
client_secret: str | None
basic_user: str | None
basic_password: str | None
public_url: str | None
command_timeout: int
output_limit: int
workdir: Path
@property
def oidc_configured(self) -> bool:
return bool(self.client_id and self.client_secret and self.session_secret)
@property
def basic_configured(self) -> bool:
return bool(self.basic_user and self.basic_password)
@property
def auth_configured(self) -> bool:
return self.oidc_configured or self.basic_configured
@property
def auth_label(self) -> str:
if self.dev_auth:
return "development auth"
if self.basic_configured:
return "HTTP Basic auth"
return f"Authentik ({self.authentik_issuer})"
def _env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def load_settings() -> WebSettings:
load_hermes_env()
issuer = os.getenv("REYNA_WEB_AUTH_ISSUER") or os.getenv("AUTHENTIK_ISSUER") or DEFAULT_AUTHENTIK_ISSUER
if not issuer.endswith("/"):
issuer += "/"
secret = os.getenv("REYNA_WEB_SESSION_SECRET") or os.getenv("SESSION_SECRET_KEY") or ""
dev_auth = _env_bool("REYNA_WEB_DEV_AUTH")
if dev_auth and not secret:
secret = "reyna-web-dev-session-secret-change-me"
return WebSettings(
auth_enabled=_env_bool("REYNA_WEB_AUTH_ENABLED", True),
dev_auth=dev_auth,
session_secret=secret,
authentik_issuer=issuer,
client_id=os.getenv("REYNA_WEB_AUTH_CLIENT_ID") or os.getenv("AUTHENTIK_CLIENT_ID"),
client_secret=os.getenv("REYNA_WEB_AUTH_CLIENT_SECRET") or os.getenv("AUTHENTIK_CLIENT_SECRET"),
basic_user=os.getenv("REYNA_WEB_BASIC_USER") or None,
basic_password=os.getenv("REYNA_WEB_BASIC_PASSWORD") or None,
public_url=(os.getenv("REYNA_WEB_PUBLIC_URL") or os.getenv("AUTHENTIK_REDIRECT_BASE_URL") or "").rstrip("/") or None,
command_timeout=int(os.getenv("REYNA_WEB_COMMAND_TIMEOUT", "120")),
output_limit=int(os.getenv("REYNA_WEB_OUTPUT_LIMIT", "60000")),
workdir=Path(os.getenv("REYNA_WEB_WORKDIR", str(Path.cwd()))).expanduser(),
)
class RunRequest(BaseModel):
command: str | None = Field(default=None, description="Command line after reyna-cli, e.g. 'doctor --json'.")
args: list[str] | None = Field(default=None, description="Argument vector after reyna-cli.")
class RunResult(BaseModel):
ok: bool
args: list[str]
exit_code: int
stdout: str
stderr: str
def create_app(settings: WebSettings | None = None) -> FastAPI:
settings = settings or load_settings()
app = FastAPI(title="Reyna CLI Web", version="0.1.0")
app.state.settings = settings
app.state.oauth = build_oauth(settings)
app.add_middleware(
SessionMiddleware,
secret_key=settings.session_secret or "missing-reyna-web-session-secret",
https_only=not settings.dev_auth,
same_site="lax",
)
@app.get("/healthz")
async def healthz() -> dict[str, Any]:
return {
"ok": True,
"auth_enabled": settings.auth_enabled,
"oidc_configured": settings.oidc_configured,
"basic_configured": settings.basic_configured,
}
@app.get("/login")
async def login(request: Request):
if not settings.auth_enabled or settings.dev_auth:
request.session[SESSION_USER_KEY] = {"sub": "dev", "name": "Development User", "email": "dev@localhost"}
return RedirectResponse(url="/")
ensure_oidc_configured(settings)
redirect_uri = callback_url(request, settings)
return await app.state.oauth.authentik.authorize_redirect(request, redirect_uri)
@app.get("/auth/callback")
async def auth_callback(request: Request):
ensure_oidc_configured(settings)
token = await app.state.oauth.authentik.authorize_access_token(request)
user = token.get("userinfo")
if not user:
user = await app.state.oauth.authentik.parse_id_token(request, token)
request.session[SESSION_USER_KEY] = public_user(user)
return RedirectResponse(url="/")
@app.post("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse(url="/", status_code=303)
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
user = await current_user_or_redirect(request, settings)
if isinstance(user, RedirectResponse):
return user
return HTMLResponse(render_index(settings, user))
@app.get("/api/me")
async def api_me(user: dict[str, Any] = Depends(require_user)) -> dict[str, Any]:
return {"ok": True, "user": user}
@app.get("/api/commands")
async def api_commands(user: dict[str, Any] = Depends(require_user)) -> dict[str, Any]:
return {"ok": True, "commands": command_presets()}
@app.post("/api/run", response_model=RunResult)
async def api_run(payload: RunRequest, user: dict[str, Any] = Depends(require_user)) -> RunResult:
args = normalize_args(payload)
result = run_reyna_cli(args, settings)
return RunResult(**result)
return app
def build_oauth(settings: WebSettings) -> OAuth:
oauth = OAuth()
if settings.auth_enabled and not settings.dev_auth and settings.client_id and settings.client_secret:
oauth.register(
name="authentik",
client_id=settings.client_id,
client_secret=settings.client_secret,
server_metadata_url=f"{settings.authentik_issuer}.well-known/openid-configuration",
client_kwargs={"scope": "openid email profile"},
)
return oauth
def ensure_oidc_configured(settings: WebSettings) -> None:
if settings.auth_enabled and not settings.dev_auth and not settings.auth_configured:
raise HTTPException(
status_code=503,
detail=(
"Reyna CLI Web auth is not configured. Set REYNA_WEB_SESSION_SECRET, "
"REYNA_WEB_AUTH_CLIENT_ID, REYNA_WEB_AUTH_CLIENT_SECRET, and optionally REYNA_WEB_AUTH_ISSUER; "
"or set REYNA_WEB_BASIC_USER and REYNA_WEB_BASIC_PASSWORD."
),
)
def basic_auth_challenge() -> HTTPException:
return HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": 'Basic realm="Reyna CLI Web"'},
)
def current_basic_user(request: Request, settings: WebSettings) -> dict[str, Any]:
authorization = request.headers.get("Authorization")
scheme, credentials = get_authorization_scheme_param(authorization)
if scheme.lower() != "basic" or not credentials:
raise basic_auth_challenge()
try:
import base64
decoded = base64.b64decode(credentials).decode("utf-8")
except Exception as exc:
raise basic_auth_challenge() from exc
username, separator, password = decoded.partition(":")
if not separator:
raise basic_auth_challenge()
expected_user = settings.basic_user or ""
expected_password = settings.basic_password or ""
if not (secrets.compare_digest(username, expected_user) and secrets.compare_digest(password, expected_password)):
raise basic_auth_challenge()
return {"sub": f"basic:{username}", "name": username, "email": None}
def callback_url(request: Request, settings: WebSettings) -> str:
if settings.public_url:
return f"{settings.public_url}/auth/callback"
return str(request.url_for("auth_callback"))
def public_user(user: Any) -> dict[str, Any]:
data = dict(user or {})
return {
"sub": str(data.get("sub", "")),
"name": data.get("name") or data.get("preferred_username") or data.get("email") or "Authenticated user",
"email": data.get("email"),
"preferred_username": data.get("preferred_username"),
}
async def current_user_or_redirect(request: Request, settings: WebSettings) -> dict[str, Any] | RedirectResponse:
if not settings.auth_enabled:
return {"sub": "auth-disabled", "name": "Auth disabled", "email": None}
if settings.dev_auth:
return {"sub": "dev", "name": "Development User", "email": "dev@localhost"}
ensure_oidc_configured(settings)
if settings.basic_configured:
return current_basic_user(request, settings)
user = request.session.get(SESSION_USER_KEY)
if user:
return user
return RedirectResponse(url="/login")
async def require_user(request: Request) -> dict[str, Any]:
settings: WebSettings = request.app.state.settings
user = await current_user_or_redirect(request, settings)
if isinstance(user, RedirectResponse):
raise HTTPException(status_code=401, detail="Not authenticated")
return user
def normalize_args(payload: RunRequest) -> list[str]:
if payload.args is not None:
args = payload.args
elif payload.command is not None:
args = shlex.split(payload.command)
else:
raise HTTPException(status_code=400, detail="Provide command or args")
if not args:
raise HTTPException(status_code=400, detail="Empty command")
if any("\x00" in arg for arg in args):
raise HTTPException(status_code=400, detail="Invalid NUL byte in argument")
forbidden = {"web", "serve", "server"}
if any(arg in forbidden for arg in args):
raise HTTPException(status_code=400, detail="Refusing to start a nested web server")
return args
def run_reyna_cli(args: list[str], settings: WebSettings) -> dict[str, Any]:
executable = shutil.which("reyna-cli")
if executable:
cmd = [executable, *args]
else:
cmd = [sys.executable, "-c", "from reyna_cli.cli import app; app()", *args]
env = os.environ.copy()
env.setdefault("PYTHONUNBUFFERED", "1")
try:
proc = subprocess.run(
cmd,
cwd=settings.workdir if settings.workdir.exists() else None,
env=env,
capture_output=True,
text=True,
timeout=settings.command_timeout,
check=False,
)
stdout = cap_output(proc.stdout, settings.output_limit)
stderr = cap_output(proc.stderr, settings.output_limit)
return {"ok": proc.returncode == 0, "args": args, "exit_code": proc.returncode, "stdout": stdout, "stderr": stderr}
except subprocess.TimeoutExpired as exc:
return {
"ok": False,
"args": args,
"exit_code": 124,
"stdout": cap_output(exc.stdout or "", settings.output_limit),
"stderr": f"Command timed out after {settings.command_timeout}s",
}
def cap_output(text: str | bytes, limit: int) -> str:
if isinstance(text, bytes):
text = text.decode("utf-8", errors="replace")
if len(text) <= limit:
return text
return text[:limit] + f"\n...[truncated at {limit} characters]"
def command_presets() -> list[dict[str, str]]:
return [
{"label": "Doctor", "command": "doctor --json"},
{"label": "Devices", "command": "devices list --json"},
{"label": "Deco overview", "command": "deco overview --json"},
{"label": "Deco clients", "command": "deco clients --json"},
{"label": "Immich stats", "command": "immich stats --json"},
{"label": "Mac mini ping", "command": "macmini ping --json"},
{"label": "Mac mini calendars", "command": "macmini calendar calendars --json"},
{"label": "Zoom users", "command": "zoom users --json"},
{"label": "Email search: Whirlpool", "command": "email search Whirlpool --limit 5 --json"},
]
def render_index(settings: WebSettings, user: dict[str, Any]) -> str:
presets = json.dumps(command_presets())
user_name = html.escape(str(user.get("name") or user.get("email") or "Authenticated user"))
auth_label = html.escape(settings.auth_label)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reyna CLI Web</title>
<style>
:root {{ color-scheme: dark; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
body {{ margin: 0; background: #0b1020; color: #eef2ff; }}
header {{ padding: 24px; background: linear-gradient(135deg, #172554, #312e81); border-bottom: 1px solid #334155; }}
main {{ max-width: 1100px; margin: 0 auto; padding: 24px; }}
.bar {{ display: flex; align-items: center; justify-content: space-between; gap: 16px; }}
.card {{ background: #111827; border: 1px solid #334155; border-radius: 16px; padding: 18px; box-shadow: 0 18px 50px rgba(0,0,0,.25); }}
.grid {{ display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); margin: 18px 0; }}
button {{ background: #2563eb; color: white; border: 0; border-radius: 10px; padding: 10px 14px; font-weight: 700; cursor: pointer; }}
button.secondary {{ background: #334155; }}
button:hover {{ filter: brightness(1.12); }}
input {{ width: 100%; box-sizing: border-box; padding: 14px; border-radius: 12px; border: 1px solid #475569; background: #020617; color: #e2e8f0; font-size: 16px; }}
pre {{ white-space: pre-wrap; word-break: break-word; background: #020617; color: #d1fae5; border-radius: 12px; padding: 16px; min-height: 220px; overflow: auto; }}
.muted {{ color: #94a3b8; }}
.ok {{ color: #86efac; }} .bad {{ color: #fca5a5; }}
form.inline {{ display: inline; }}
</style>
</head>
<body>
<header>
<div class="bar">
<div>
<h1>Reyna CLI Web</h1>
<div class="muted">Signed in as {user_name} via {auth_label}</div>
</div>
<form class="inline" method="post" action="/logout"><button class="secondary" type="submit">Log out</button></form>
</div>
</header>
<main>
<section class="card">
<h2>Run a command</h2>
<p class="muted">Type the arguments after <code>reyna-cli</code>. Commands run without a shell and return stdout/stderr.</p>
<div class="bar">
<input id="command" value="doctor --json" autocomplete="off" aria-label="reyna-cli command">
<button onclick="runCommand()">Run</button>
</div>
</section>
<section>
<h2>Shortcuts</h2>
<div id="presets" class="grid"></div>
</section>
<section class="card">
<div class="bar"><h2>Output</h2><span id="status" class="muted">Ready</span></div>
<pre id="output"></pre>
</section>
</main>
<script>
const presets = {presets};
const presetsEl = document.getElementById('presets');
for (const preset of presets) {{
const div = document.createElement('div');
div.className = 'card';
div.innerHTML = `<h3>${{preset.label}}</h3><p class="muted"><code>${{preset.command}}</code></p>`;
const btn = document.createElement('button');
btn.textContent = 'Run';
btn.onclick = () => {{ document.getElementById('command').value = preset.command; runCommand(); }};
div.appendChild(btn);
presetsEl.appendChild(div);
}}
async function runCommand() {{
const command = document.getElementById('command').value;
const status = document.getElementById('status');
const output = document.getElementById('output');
status.textContent = 'Running…'; status.className = 'muted';
output.textContent = '';
try {{
const res = await fetch('/api/run', {{method:'POST', headers:{{'Content-Type':'application/json'}}, body: JSON.stringify({{command}})}});
const data = await res.json();
status.textContent = data.ok ? `OK (exit ${{data.exit_code}})` : `Failed (exit ${{data.exit_code ?? res.status}})`;
status.className = data.ok ? 'ok' : 'bad';
output.textContent = `$ reyna-cli ${{(data.args || []).join(' ')}}\n\n${{data.stdout || ''}}${{data.stderr ? '\n--- stderr ---\n' + data.stderr : ''}}`;
}} catch (err) {{
status.textContent = 'Request failed'; status.className = 'bad'; output.textContent = String(err);
}}
}}
document.getElementById('command').addEventListener('keydown', (event) => {{ if (event.key === 'Enter') runCommand(); }});
</script>
</body>
</html>"""
app = create_app()
def run_web_server(host: str = "127.0.0.1", port: int = 8765, reload: bool = False) -> None:
import uvicorn
settings = load_settings()
lan_bind = host not in {"127.0.0.1", "localhost", "::1"}
if settings.dev_auth and lan_bind and not _env_bool("REYNA_WEB_ALLOW_INSECURE_LAN"):
raise SystemExit(
"Refusing to run Reyna CLI Web with REYNA_WEB_DEV_AUTH on a LAN bind. "
"Unset REYNA_WEB_DEV_AUTH and configure REYNA_WEB_AUTH_CLIENT_ID/SECRET, "
"or set REYNA_WEB_ALLOW_INSECURE_LAN=1 for temporary trusted-LAN testing."
)
if settings.auth_enabled and lan_bind and not settings.dev_auth and not settings.auth_configured:
raise SystemExit(
"Refusing to expose Reyna CLI Web on the LAN without configured authentication. "
"Set REYNA_WEB_SESSION_SECRET, REYNA_WEB_AUTH_CLIENT_ID, "
"REYNA_WEB_AUTH_CLIENT_SECRET, and REYNA_WEB_PUBLIC_URL; or set "
"REYNA_WEB_BASIC_USER and REYNA_WEB_BASIC_PASSWORD before using --host 0.0.0.0."
)
uvicorn.run("reyna_cli.web:app", host=host, port=port, reload=reload)
+31
View File
@@ -0,0 +1,31 @@
from typer.testing import CliRunner
from reyna_cli.cli import app
from reyna_cli.desktop_service import SERVICE_NAME, unit_content
runner = CliRunner()
def test_computer_has_device_and_service_subcommands():
result = runner.invoke(app, ["computer", "--help"])
assert result.exit_code == 0
assert "text" in result.stdout
assert "screenshot" in result.stdout
assert "service-install" in result.stdout
assert "service-status" in result.stdout
def test_devices_computer_has_subcommands():
result = runner.invoke(app, ["devices", "computer", "--help"])
assert result.exit_code == 0
assert "service-start" in result.stdout
assert "battery" in result.stdout
def test_unit_content_matches_desktop_client_gui_session():
content = unit_content()
assert SERVICE_NAME == "reyna-desktop-client.service"
assert "Environment=WAYLAND_DISPLAY=wayland-0" in content
assert "Environment=XDG_RUNTIME_DIR=/run/user/1000" in content
assert "ExecStart=/usr/bin/python3 /home/adolforeyna/Projects/Screen/desktop_client/client.py" in content
assert "Restart=always" in content
+74
View File
@@ -0,0 +1,74 @@
import json
import mailbox
from email.message import EmailMessage
from typer.testing import CliRunner
from reyna_cli.cli import app
from reyna_cli.email_local import ThunderbirdEmailClient
runner = CliRunner()
def make_profile(tmp_path):
profile = tmp_path / "profile"
inbox = profile / "ImapMail" / "imap.example.com" / "INBOX"
inbox.parent.mkdir(parents=True)
mbox = mailbox.mbox(str(inbox))
msg = EmailMessage()
msg["From"] = "The Home Depot Protection Plan Team <purchaseconfirmation@squaretrade.com>"
msg["To"] = "adolfo@example.com"
msg["Subject"] = "Thank You for Your The Home Depot Protection Plan Purchase"
msg["Date"] = "Mon, 01 Sep 2025 04:20:20 +0000"
msg["Message-ID"] = "<fridge@example.com>"
msg.set_content("Thank you for purchasing your new Whirlpool Refrigerator WRS321SDHZ Protection Plan.")
mbox.add(msg)
mbox.flush()
mbox.close()
return profile, inbox
def test_email_client_searches_local_thunderbird_mbox(tmp_path, monkeypatch):
profile, _ = make_profile(tmp_path)
monkeypatch.setenv("REYNA_THUNDERBIRD_PROFILE", str(profile))
result = ThunderbirdEmailClient().search("Whirlpool Refrigerator", limit=5)
assert result["results"]
first = result["results"][0]
assert first["subject"] == "Thank You for Your The Home Depot Protection Plan Purchase"
assert "Whirlpool Refrigerator" in " ".join(first["snippets"])
def test_email_cli_has_subcommands():
result = runner.invoke(app, ["email", "--help"])
assert result.exit_code == 0
assert "search" in result.stdout
assert "latest" in result.stdout
assert "find-receipts" in result.stdout
def test_email_cli_search_json(tmp_path, monkeypatch):
profile, _ = make_profile(tmp_path)
monkeypatch.setenv("REYNA_THUNDERBIRD_PROFILE", str(profile))
result = runner.invoke(app, ["email", "search", "Whirlpool Refrigerator", "--json"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["ok"] is True
assert payload["source"] == "local_thunderbird"
assert payload["result"]["results"][0]["message_id"] == "<fridge@example.com>"
def test_email_cli_show_by_id(tmp_path, monkeypatch):
profile, _ = make_profile(tmp_path)
monkeypatch.setenv("REYNA_THUNDERBIRD_PROFILE", str(profile))
search = runner.invoke(app, ["email", "search", "WRS321SDHZ", "--json"])
message_id = json.loads(search.stdout)["result"]["results"][0]["id"]
result = runner.invoke(app, ["email", "show", message_id, "--body", "--json"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert "Whirlpool Refrigerator" in payload["result"]["body"]
+123 -3
View File
@@ -3,7 +3,7 @@ from pathlib import Path
from typer.testing import CliRunner
from reyna_cli.cli import app
from reyna_cli.cli import app, build_tts_payload
from reyna_cli.config import Device, Registry, get_device, load_registry
from reyna_cli.utils import infer_capabilities, resolve_tool_name
@@ -53,8 +53,16 @@ devices:
def test_config_get_device():
registry = Registry(devices=[Device(id="iphone_mcp", url="http://localhost:8080/api/mcp")])
assert get_device("iphone", registry).url == "http://localhost:8080/api/mcp"
registry = Registry(devices=[Device(id="iphone_mcp", url="http://localhost:8080/api/mcp"), Device(id="personal_laptop_screen", url="http://localhost:8081/api/mcp"), Device(id="local_desktop_screen", url="http://127.0.0.1:8080/api/mcp")])
iphone = get_device("iphone", registry)
laptop = get_device("laptop", registry)
computer = get_device("computer", registry)
assert iphone is not None
assert laptop is not None
assert computer is not None
assert iphone.url == "http://localhost:8080/api/mcp"
assert laptop.url == "http://localhost:8081/api/mcp"
assert computer.url == "http://127.0.0.1:8080/api/mcp"
assert get_device("nonexistent", registry) is None
@@ -88,12 +96,74 @@ def test_screen_has_subcommands():
assert "speak" in result.stdout
def test_build_tts_payload_base64_encodes_wav(monkeypatch, tmp_path):
def fake_synthesize(message, wav_path, command_template=None, voice=None, sample_rate=16000):
wav_path.write_bytes(b"RIFFfake-wav")
return wav_path
monkeypatch.setattr("reyna_cli.cli.synthesize_wav", fake_synthesize)
payload = build_tts_payload("hello", tmp_path / "speech.wav", 42, None, None, 16000)
assert payload == {"wav_base64": "UklGRmZha2Utd2F2", "volume": 42}
def test_screen_speak_generates_wav_base64_payload(monkeypatch, tmp_path):
registry_path = tmp_path / "local_devices.yaml"
registry_path.write_text(
"""
devices:
esp32_screen:
display_name: ESP32 Screen
url: http://screen.local/api/mcp
""",
encoding="utf-8",
)
monkeypatch.setenv("REYNA_DEVICES_REGISTRY", str(registry_path))
def fake_get_tools(device, live_only=False, cache_only=False, refresh=False):
return {"ok": True, "tools": [{"name": "play_audio_base64"}]}
calls = []
def fake_call_device_tool(device, tool_name, arguments):
calls.append((device.id, tool_name, arguments))
return {"ok": True, "result": "played"}
def fake_synthesize(message, wav_path, command_template=None, voice=None, sample_rate=16000):
assert message == "hello screen"
wav_path.write_bytes(b"RIFFfake-wav")
return wav_path
monkeypatch.setattr("reyna_cli.cli.get_tools", fake_get_tools)
monkeypatch.setattr("reyna_cli.cli.call_device_tool", fake_call_device_tool)
monkeypatch.setattr("reyna_cli.cli.synthesize_wav", fake_synthesize)
result = runner.invoke(app, ["screen", "speak", "hello screen", "--volume", "42", "--out", str(tmp_path / "speech.wav"), "--json"])
assert result.exit_code == 0
assert calls == [("esp32_screen", "play_audio_base64", {"wav_base64": "UklGRmZha2Utd2F2", "volume": 42})]
def test_devices_screen_has_subcommands():
result = runner.invoke(app, ["devices", "screen", "--help"])
assert result.exit_code == 0
assert "text" in result.stdout
def test_laptop_has_subcommands():
result = runner.invoke(app, ["laptop", "--help"])
assert result.exit_code == 0
assert "text" in result.stdout
assert "speak" in result.stdout
assert "screenshot" in result.stdout
assert "sensors" in result.stdout
def test_devices_laptop_has_subcommands():
result = runner.invoke(app, ["devices", "laptop", "--help"])
assert result.exit_code == 0
assert "text" in result.stdout
assert "battery" in result.stdout
def test_devices_arm_has_subcommands():
result = runner.invoke(app, ["devices", "arm", "--help"])
assert result.exit_code == 0
@@ -116,3 +186,53 @@ def test_mongo_has_subcommands():
assert "dbs" in result.stdout
assert "collections" in result.stdout
assert "find" in result.stdout
def test_deco_has_subcommands():
result = runner.invoke(app, ["deco", "--help"])
assert result.exit_code == 0
assert "overview" in result.stdout
assert "clients" in result.stdout
assert "firmware" in result.stdout
assert "config" in result.stdout
def test_deco_config_is_direct(monkeypatch, tmp_path):
empty_env = tmp_path / ".env"
empty_env.write_text("", encoding="utf-8")
monkeypatch.setenv("HERMES_ENV_PATH", str(empty_env))
monkeypatch.setenv("DECO_HOST", "192.168.68.1")
monkeypatch.delenv("DECO_PASSWORD", raising=False)
result = runner.invoke(app, ["deco", "config", "--json"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["ok"] is True
assert payload["source"] == "direct"
assert payload["result"]["host"] == "192.168.68.1"
assert payload["result"]["passwordConfigured"] is False
def test_macmini_has_subcommands():
result = runner.invoke(app, ["macmini", "--help"])
assert result.exit_code == 0
assert "calendar" in result.stdout
assert "contacts" in result.stdout
assert "notes" in result.stdout
assert "reminders" in result.stdout
assert "deco" in result.stdout
def test_macmini_calendar_has_subcommands():
result = runner.invoke(app, ["macmini", "calendar", "--help"])
assert result.exit_code == 0
assert "calendars" in result.stdout
assert "events" in result.stdout
assert "create" in result.stdout
def test_macmini_deco_has_subcommands():
result = runner.invoke(app, ["macmini", "deco", "--help"])
assert result.exit_code == 0
assert "overview" in result.stdout
assert "clients" in result.stdout
assert "firmware" in result.stdout
+176
View File
@@ -0,0 +1,176 @@
from pathlib import Path
from fastapi.testclient import TestClient
from reyna_cli.web import WebSettings, create_app, normalize_args, RunRequest, run_web_server
from reyna_cli.tts import TTSError, _custom_tts
def test_web_health_without_auth_config():
app = create_app(
WebSettings(
auth_enabled=True,
dev_auth=False,
session_secret="",
authentik_issuer="https://auth.reynafamily.com/application/o/reyna-cli-web/",
client_id=None,
client_secret=None,
basic_user=None,
basic_password=None,
public_url=None,
command_timeout=5,
output_limit=1000,
workdir=Path.cwd(),
)
)
client = TestClient(app)
response = client.get("/healthz")
assert response.status_code == 200
assert response.json()["auth_enabled"] is True
assert response.json()["oidc_configured"] is False
def test_web_index_renders_with_dev_auth():
app = create_app(
WebSettings(
auth_enabled=True,
dev_auth=True,
session_secret="dev-secret",
authentik_issuer="https://auth.reynafamily.com/application/o/reyna-cli-web/",
client_id=None,
client_secret=None,
basic_user=None,
basic_password=None,
public_url=None,
command_timeout=5,
output_limit=1000,
workdir=Path.cwd(),
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert "Reyna CLI Web" in response.text
assert "doctor --json" in response.text
def test_web_refuses_dev_auth_on_lan_bind(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_ENV_PATH", str(tmp_path / "missing.env"))
monkeypatch.setenv("REYNA_WEB_DEV_AUTH", "1")
monkeypatch.delenv("REYNA_WEB_ALLOW_INSECURE_LAN", raising=False)
try:
run_web_server(host="0.0.0.0", port=8765)
except SystemExit as exc:
assert "Refusing to run" in str(exc)
else:
raise AssertionError("LAN dev-auth server was allowed")
def test_web_refuses_lan_bind_without_oidc_config(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_ENV_PATH", str(tmp_path / "missing.env"))
monkeypatch.delenv("REYNA_WEB_DEV_AUTH", raising=False)
monkeypatch.delenv("REYNA_WEB_AUTH_CLIENT_ID", raising=False)
monkeypatch.delenv("REYNA_WEB_AUTH_CLIENT_SECRET", raising=False)
monkeypatch.delenv("REYNA_WEB_BASIC_USER", raising=False)
monkeypatch.delenv("REYNA_WEB_BASIC_PASSWORD", raising=False)
monkeypatch.delenv("REYNA_WEB_SESSION_SECRET", raising=False)
try:
run_web_server(host="0.0.0.0", port=8765)
except SystemExit as exc:
assert "without configured authentication" in str(exc)
else:
raise AssertionError("LAN server without OIDC config was allowed")
def test_web_basic_auth_protects_index():
app = create_app(
WebSettings(
auth_enabled=True,
dev_auth=False,
session_secret="basic-secret",
authentik_issuer="https://auth.reynafamily.com/application/o/reyna-cli-web/",
client_id=None,
client_secret=None,
basic_user="adolfo",
basic_password="correct-password",
public_url=None,
command_timeout=5,
output_limit=1000,
workdir=Path.cwd(),
)
)
client = TestClient(app)
unauthenticated = client.get("/")
assert unauthenticated.status_code == 401
assert unauthenticated.headers["www-authenticate"] == 'Basic realm="Reyna CLI Web"'
authenticated = client.get("/", auth=("adolfo", "correct-password"))
assert authenticated.status_code == 200
assert "HTTP Basic auth" in authenticated.text
def test_web_allows_lan_bind_with_basic_auth(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_ENV_PATH", str(tmp_path / "missing.env"))
called = {}
def fake_run(app_path, host, port, reload):
called.update({"app_path": app_path, "host": host, "port": port, "reload": reload})
monkeypatch.delenv("REYNA_WEB_DEV_AUTH", raising=False)
monkeypatch.delenv("REYNA_WEB_AUTH_CLIENT_ID", raising=False)
monkeypatch.delenv("REYNA_WEB_AUTH_CLIENT_SECRET", raising=False)
monkeypatch.setenv("REYNA_WEB_BASIC_USER", "adolfo")
monkeypatch.setenv("REYNA_WEB_BASIC_PASSWORD", "correct-password")
monkeypatch.setattr("uvicorn.run", fake_run)
run_web_server(host="0.0.0.0", port=8765)
assert called["host"] == "0.0.0.0"
def test_web_api_run_uses_argument_vector(monkeypatch):
app = create_app(
WebSettings(
auth_enabled=False,
dev_auth=False,
session_secret="dev-secret",
authentik_issuer="https://auth.reynafamily.com/application/o/reyna-cli-web/",
client_id=None,
client_secret=None,
basic_user=None,
basic_password=None,
public_url=None,
command_timeout=5,
output_limit=1000,
workdir=Path.cwd(),
)
)
def fake_run(args, settings):
return {"ok": True, "args": args, "exit_code": 0, "stdout": "done", "stderr": ""}
monkeypatch.setattr("reyna_cli.web.run_reyna_cli", fake_run)
client = TestClient(app)
response = client.post("/api/run", json={"command": "doctor --json"})
assert response.status_code == 200
payload = response.json()
assert payload["ok"] is True
assert payload["args"] == ["doctor", "--json"]
assert payload["stdout"] == "done"
def test_normalize_args_refuses_nested_server():
try:
normalize_args(RunRequest(command="--help web"))
except Exception as exc:
assert getattr(exc, "status_code", None) == 400
else:
raise AssertionError("nested web command was not rejected")
def test_custom_tts_rejects_raw_text_placeholder(tmp_path):
try:
_custom_tts("hello", tmp_path / "audio", "printf {raw_text}", None)
except TTSError as exc:
assert "raw_text" in str(exc)
else:
raise AssertionError("raw_text placeholder was not rejected")
Generated
+1250
View File
File diff suppressed because it is too large Load Diff