feat: expand Reyna CLI integrations
This commit is contained in:
@@ -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`.
|
- **Devices parent command**: All local devices are now under `reyna-cli devices`.
|
||||||
- `reyna-cli devices screen ...` (ESP32 screen)
|
- `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 iphone ...` (iPhone app)
|
||||||
- `reyna-cli devices arm ...` (Robot arm)
|
- `reyna-cli devices arm ...` (Robot arm)
|
||||||
- **Robot Arm**: Dynamic tool resolution for `state`, `home`, `wave`, `battery`.
|
- **Robot Arm**: Dynamic tool resolution for `state`, `home`, `wave`, `battery`.
|
||||||
- **Immich**: On-demand MCP bridge at `http://127.0.0.1:8626/mcp`.
|
- **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`.
|
- **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
|
## Install / run
|
||||||
|
|
||||||
@@ -44,13 +46,30 @@ uv run reyna-cli devices arm wave
|
|||||||
uv run reyna-cli devices arm battery --json
|
uv run reyna-cli devices arm battery --json
|
||||||
```
|
```
|
||||||
|
|
||||||
## ESP32 screen & iPhone
|
## ESP32 screen, laptop screen, this computer & iPhone
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run reyna-cli devices screen text "Dinner is ready"
|
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
|
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
|
## Immich
|
||||||
|
|
||||||
```bash
|
```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":{}}]'
|
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
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ dependencies = [
|
|||||||
"rich",
|
"rich",
|
||||||
"pytest-mock",
|
"pytest-mock",
|
||||||
"pymongo",
|
"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"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
+574
-12
@@ -13,9 +13,13 @@ from rich.console import Console
|
|||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from reyna_cli.config import Device, cache_path_for, get_device, load_registry
|
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.immich import ImmichClient
|
||||||
from reyna_cli.mcp import MCPClient
|
from reyna_cli.mcp import MCPClient
|
||||||
from reyna_cli.mongo_direct import MongoDirectClient
|
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.utils import infer_capabilities, resolve_tool_name
|
||||||
from reyna_cli.zoom_direct import ZoomClient
|
from reyna_cli.zoom_direct import ZoomClient
|
||||||
|
|
||||||
@@ -23,26 +27,48 @@ from reyna_cli.zoom_direct import ZoomClient
|
|||||||
MongoClient = MongoDirectClient
|
MongoClient = MongoDirectClient
|
||||||
|
|
||||||
app = typer.Typer(help="Reyna family CLI for on-demand local-device and service control.")
|
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.")
|
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.")
|
iphone_app = typer.Typer(help="iPhone MCP app wrapper commands.")
|
||||||
arm_app = typer.Typer(help="Robot arm wrapper commands.")
|
arm_app = typer.Typer(help="Robot arm wrapper commands.")
|
||||||
immich_app = typer.Typer(help="Immich direct REST API commands.")
|
immich_app = typer.Typer(help="Immich direct REST API commands.")
|
||||||
mongo_app = typer.Typer(help="MongoDB direct driver commands.")
|
mongo_app = typer.Typer(help="MongoDB direct driver commands.")
|
||||||
zoom_app = typer.Typer(help="Zoom direct REST API 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()
|
console = Console()
|
||||||
|
|
||||||
devices_app.add_typer(screen_app, name="screen")
|
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(iphone_app, name="iphone")
|
||||||
devices_app.add_typer(arm_app, name="arm")
|
devices_app.add_typer(arm_app, name="arm")
|
||||||
app.add_typer(devices_app, name="devices")
|
app.add_typer(devices_app, name="devices")
|
||||||
app.add_typer(screen_app, name="screen", hidden=True)
|
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(iphone_app, name="iphone", hidden=True)
|
||||||
app.add_typer(arm_app, name="arm", hidden=True)
|
app.add_typer(arm_app, name="arm", hidden=True)
|
||||||
app.add_typer(immich_app, name="immich")
|
app.add_typer(immich_app, name="immich")
|
||||||
app.add_typer(mongo_app, name="mongo")
|
app.add_typer(mongo_app, name="mongo")
|
||||||
app.add_typer(zoom_app, name="zoom")
|
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:
|
def scrub_sensitive(value: Any) -> Any:
|
||||||
@@ -108,7 +134,7 @@ def scrub_binary(value: Any, out: Optional[Path] = None) -> Any:
|
|||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
cleaned: Dict[str, Any] = {}
|
cleaned: Dict[str, Any] = {}
|
||||||
for key, item in value.items():
|
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:
|
if out:
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
raw = item.split(",", 1)[-1]
|
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}
|
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] = []
|
errors: List[str] = []
|
||||||
for url in device.candidate_urls():
|
for url in device.candidate_urls():
|
||||||
try:
|
try:
|
||||||
client = MCPClient(url)
|
client = MCPClient(url)
|
||||||
result = client.call_tool(tool_name, arguments)
|
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:
|
except Exception as exc:
|
||||||
errors.append(f"{url}: {exc}")
|
errors.append(f"{url}: {exc}")
|
||||||
return {"ok": False, "device": device.id, "error": "tool_call_failed", "tool": tool_name, "tried": errors}
|
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
|
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]:
|
def build_ram_plan() -> Dict[str, Any]:
|
||||||
replacements = {
|
replacements = {
|
||||||
"immich": "reyna-cli immich stats|albums|search (direct REST)",
|
"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)
|
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)
|
device = get_required_device(default_device, json_output)
|
||||||
tools_result = get_tools(device)
|
tools_result = get_tools(device)
|
||||||
tools = tools_result.get("tools", []) if tools_result.get("ok") else []
|
tools = tools_result.get("tools", []) if tools_result.get("ok") else []
|
||||||
tool_name = resolve_tool_name(capability, tools)
|
tool_name = resolve_tool_name(capability, tools)
|
||||||
if not tool_name:
|
if not tool_name:
|
||||||
fail(f"Capability '{capability}' unavailable on {device.id}", json_output, available_tools=[t.get("name") for t in tools])
|
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)
|
emit(result, json_output)
|
||||||
if not result.get("ok"):
|
if not result.get("ok"):
|
||||||
raise typer.Exit(1)
|
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")
|
@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):
|
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)
|
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")
|
@screen_app.command("speak")
|
||||||
def screen_speak(message: str, json_output: bool = typer.Option(False, "--json")):
|
def screen_speak(
|
||||||
wrapper_capability("esp32_screen", "speech", {"text": message}, json_output)
|
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")
|
@screen_app.command("clear")
|
||||||
@@ -411,7 +512,7 @@ def screen_clear(json_output: bool = typer.Option(False, "--json"), color: int =
|
|||||||
|
|
||||||
@screen_app.command("screenshot")
|
@screen_app.command("screenshot")
|
||||||
def screen_screenshot(out: Path = typer.Option(Path("/tmp/esp32-screen.png"), "--out"), json_output: bool = typer.Option(False, "--json")):
|
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")
|
@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)
|
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")
|
@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):
|
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)
|
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")
|
@iphone_app.command("speak")
|
||||||
def iphone_speak(message: str, json_output: bool = typer.Option(False, "--json")):
|
def iphone_speak(
|
||||||
wrapper_capability("iphone_mcp", "speech", {"text": message}, json_output)
|
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")
|
@iphone_app.command("screenshot")
|
||||||
def iphone_screenshot(out: Path = typer.Option(Path("/tmp/iphone-mcp.png"), "--out"), json_output: bool = typer.Option(False, "--json")):
|
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")
|
@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)
|
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__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|||||||
@@ -115,6 +115,12 @@ def get_device(name: str, registry: Optional[Registry] = None) -> Optional[Devic
|
|||||||
"iphone": "iphone_mcp",
|
"iphone": "iphone_mcp",
|
||||||
"phone": "iphone_mcp",
|
"phone": "iphone_mcp",
|
||||||
"arm": "robot_arm",
|
"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)
|
normalized = shorthand.get(normalized, normalized)
|
||||||
for device in registry.devices:
|
for device in registry.devices:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()}
|
||||||
@@ -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"},
|
||||||
|
]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
@@ -7,15 +8,19 @@ import httpx
|
|||||||
|
|
||||||
|
|
||||||
class MCPClient:
|
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.base_url = base_url.rstrip("/")
|
||||||
self.session_id: Optional[str] = None
|
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)
|
self.client = httpx.Client(timeout=timeout)
|
||||||
|
|
||||||
def _headers(self) -> Dict[str, str]:
|
def _headers(self) -> Dict[str, str]:
|
||||||
headers = {
|
headers = {
|
||||||
"Accept": "application/json, text/event-stream",
|
"Accept": "application/json, text/event-stream",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
**self.extra_headers,
|
||||||
}
|
}
|
||||||
if self.session_id:
|
if self.session_id:
|
||||||
headers["mcp-session-id"] = self.session_id
|
headers["mcp-session-id"] = self.session_id
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,6 +9,8 @@ CAPABILITY_MAPPINGS: Dict[str, List[str]] = {
|
|||||||
"clear": ["clear_screen", "clear", "fill_screen"],
|
"clear": ["clear_screen", "clear", "fill_screen"],
|
||||||
"screenshot": ["screenshot", "capture_screen", "get_screenshot"],
|
"screenshot": ["screenshot", "capture_screen", "get_screenshot"],
|
||||||
"battery": ["get_battery", "battery", "battery_status"],
|
"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"],
|
"state": ["get_arm_state", "get_state", "status"],
|
||||||
"home": ["home_arm", "home", "reset_position"],
|
"home": ["home_arm", "home", "reset_position"],
|
||||||
"wave": ["approved_double_speed_wave", "wave", "wave_hand"],
|
"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]]:
|
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}
|
return {cap: resolve_tool_name(cap, tools) for cap in caps}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
@@ -3,7 +3,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from typer.testing import CliRunner
|
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.config import Device, Registry, get_device, load_registry
|
||||||
from reyna_cli.utils import infer_capabilities, resolve_tool_name
|
from reyna_cli.utils import infer_capabilities, resolve_tool_name
|
||||||
|
|
||||||
@@ -53,8 +53,16 @@ devices:
|
|||||||
|
|
||||||
|
|
||||||
def test_config_get_device():
|
def test_config_get_device():
|
||||||
registry = Registry(devices=[Device(id="iphone_mcp", 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")])
|
||||||
assert get_device("iphone", registry).url == "http://localhost: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
|
assert get_device("nonexistent", registry) is None
|
||||||
|
|
||||||
|
|
||||||
@@ -88,12 +96,74 @@ def test_screen_has_subcommands():
|
|||||||
assert "speak" in result.stdout
|
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():
|
def test_devices_screen_has_subcommands():
|
||||||
result = runner.invoke(app, ["devices", "screen", "--help"])
|
result = runner.invoke(app, ["devices", "screen", "--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "text" in result.stdout
|
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():
|
def test_devices_arm_has_subcommands():
|
||||||
result = runner.invoke(app, ["devices", "arm", "--help"])
|
result = runner.invoke(app, ["devices", "arm", "--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -116,3 +186,53 @@ def test_mongo_has_subcommands():
|
|||||||
assert "dbs" in result.stdout
|
assert "dbs" in result.stdout
|
||||||
assert "collections" in result.stdout
|
assert "collections" in result.stdout
|
||||||
assert "find" 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
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user