#!/usr/bin/env python3 """ helios-remote CLI - Control devices connected to the Helios Remote Relay Server. Device labels are the sole identifiers. No session UUIDs. Labels must be lowercase, no whitespace, only a-z 0-9 - _ """ import argparse import base64 import os import re import sys from pathlib import Path # ── Load config.env ────────────────────────────────────────────────────────── _config_path = Path(__file__).parent / "config.env" _config: dict = {} if _config_path.exists(): for _line in _config_path.read_text().splitlines(): _line = _line.strip() if _line and not _line.startswith("#") and "=" in _line: k, v = _line.split("=", 1) _config[k.strip()] = v.strip() BASE_URL = _config.get("HELIOS_REMOTE_URL", "").rstrip("/") API_KEY = _config.get("HELIOS_REMOTE_API_KEY", "") if not BASE_URL or not API_KEY: sys.exit(f"[helios-remote] ERROR: config.env missing or incomplete at {_config_path}") try: import requests except ImportError: sys.exit("[helios-remote] ERROR: 'requests' not installed. Run: pip install requests") LABEL_RE = re.compile(r'^[a-z0-9][a-z0-9_-]*$') def validate_label(label: str) -> str: """Validate a device label.""" if not LABEL_RE.match(label): sys.exit( f"[helios-remote] Invalid label '{label}'. " f"Must be lowercase, no whitespace, only a-z 0-9 - _" ) return label # ── HTTP helpers ───────────────────────────────────────────────────────────── def _headers() -> dict: return {"X-Api-Key": API_KEY, "Content-Type": "application/json"} def _req(method: str, path: str, timeout: int = 30, **kwargs): url = f"{BASE_URL}{path}" try: resp = requests.request(method, url, headers=_headers(), timeout=timeout, **kwargs) except requests.exceptions.ConnectionError as exc: sys.exit(f"[helios-remote] CONNECTION ERROR: Cannot reach {url}\n → {exc}") except requests.exceptions.Timeout: sys.exit(f"[helios-remote] TIMEOUT: {url} did not respond within {timeout} s") if not resp.ok: body = resp.text[:1000] if resp.text else "(empty body)" sys.exit( f"[helios-remote] HTTP {resp.status_code} {resp.reason}\n" f" URL : {url}\n" f" Method : {method.upper()}\n" f" Body : {body}" ) return resp # ── Window resolution ──────────────────────────────────────────────────────── def resolve_window(device: str, window_label: str) -> int: """Resolve a window label to its HWND. Searches by label field.""" resp = _req("GET", f"/devices/{device}/windows") windows = resp.json().get("windows", []) query = window_label.lower() # Exact label match first for w in windows: if w.get("visible") and w.get("label", "") == query: return int(w["id"]) # Substring match on label matches = [w for w in windows if w.get("visible") and query in w.get("label", "")] if not matches: # Fallback: substring match on title matches = [w for w in windows if w.get("visible") and query in w.get("title", "").lower()] if not matches: raise SystemExit(f"[helios-remote] No visible window matching '{window_label}'") if len(matches) > 1: print(f"[helios-remote] Multiple matches for '{window_label}', using first:") for w in matches: print(f" {w.get('label', '?'):<30} {w['title']}") return int(matches[0]["id"]) # ── Commands ───────────────────────────────────────────────────────────────── def cmd_devices(_args): """List all connected devices.""" resp = _req("GET", "/devices") data = resp.json() devices = data.get("devices", []) if not devices: print("No devices connected.") return print(f"{'Device':<30}") print("-" * 30) for d in devices: print(d.get("label", "?")) def cmd_screenshot(args): """Capture a screenshot. Argument is 'screen' for full screen or a window label.""" device = validate_label(args.device) target = args.target out_path = Path("/tmp/helios-remote-screenshot.png") if target == "screen": resp = _req("POST", f"/devices/{device}/screenshot") else: # Resolve window label to HWND wid = resolve_window(device, target) resp = _req("POST", f"/devices/{device}/windows/{wid}/screenshot") data = resp.json() b64 = ( data.get("image_base64") or data.get("screenshot") or data.get("image") or data.get("data") or data.get("png") ) if not b64: sys.exit(f"[helios-remote] ERROR: No image in response. Keys: {list(data.keys())}") if "," in b64: b64 = b64.split(",", 1)[1] out_path.write_bytes(base64.b64decode(b64)) print(str(out_path)) def cmd_exec(args): """Run a shell command on the remote device.""" device = validate_label(args.device) command = " ".join(args.parts) if isinstance(args.parts, list) else args.parts body = {"command": command} if args.timeout: body["timeout_ms"] = args.timeout * 1000 resp = _req("POST", f"/devices/{device}/exec", json=body, timeout=max(35, (args.timeout or 30) + 5)) data = resp.json() stdout = data.get("stdout") or data.get("output") or "" stderr = data.get("stderr") or "" exit_code = data.get("exit_code") if stdout: print(stdout, end="" if stdout.endswith("\n") else "\n") if stderr: print("[stderr]", stderr, file=sys.stderr) if exit_code is not None and int(exit_code) != 0: print(f"[helios-remote] Command exited with code {exit_code}", file=sys.stderr) sys.exit(int(exit_code)) def cmd_windows(args): """List windows on the remote device (visible only).""" device = validate_label(args.device) resp = _req("GET", f"/devices/{device}/windows") data = resp.json() windows = data.get("windows", []) windows = [w for w in windows if w.get("visible")] if not windows: print("No windows returned.") return print(f"{'Label':<30} Title") print("-" * 70) for w in windows: label = w.get("label", "?") title = w.get("title", "") print(f"{label:<30} {title}") def cmd_minimize_all(args): """Minimize all windows on the remote device.""" device = validate_label(args.device) _req("POST", f"/devices/{device}/windows/minimize-all") print(f"All windows minimized on {device}.") def cmd_focus(args): """Bring a window to the foreground (by label).""" device = validate_label(args.device) wid = resolve_window(device, args.window) _req("POST", f"/devices/{device}/windows/{wid}/focus") print(f"Window '{args.window}' focused on {device}.") def cmd_maximize(args): """Maximize and focus a window (by label).""" device = validate_label(args.device) wid = resolve_window(device, args.window) _req("POST", f"/devices/{device}/windows/{wid}/maximize") print(f"Window '{args.window}' maximized on {device}.") def cmd_version(args): """Show relay, remote.py, and client commit — check if all are in sync.""" device = validate_label(args.device) # 1. Relay (public, no auth) try: r = requests.get(f"{BASE_URL}/version", timeout=10) relay = r.json() if r.ok else {"commit": f"HTTP {r.status_code}"} except Exception as e: relay = {"commit": str(e)} # 2. remote.py commit import subprocess try: r = subprocess.run( ["git", "log", "-1", "--format=%h", "--", __file__], capture_output=True, text=True, cwd=os.path.dirname(os.path.abspath(__file__)) ) script_commit = r.stdout.strip() or "unknown" except Exception: script_commit = "unknown" # 3. Client try: r2 = _req("GET", f"/devices/{device}/version") client = r2.json() except SystemExit: client = {"commit": "unreachable"} relay_commit = relay.get("commit", "?") client_commit = client.get("commit", "?") all_same = relay_commit == script_commit == client_commit print(f" relay {relay_commit}") print(f" remote.py {script_commit}") print(f" client {client_commit}") print(f" {'✅ all in sync' if all_same else '⚠️ OUT OF SYNC'}") def cmd_logs(args): """Fetch the last N lines of the client log file.""" device = validate_label(args.device) resp = _req("GET", f"/devices/{device}/logs", params={"lines": args.lines}) data = resp.json() if "error" in data: sys.exit(f"[helios-remote] {data['error']}") print(f"# {data.get('log_path', '?')} (last {args.lines} lines)") print(data.get("content", "")) def cmd_upload(args): """Upload a local file to the remote device.""" device = validate_label(args.device) local_path = Path(args.local_path) if not local_path.exists(): sys.exit(f"[helios-remote] ERROR: Local file not found: {local_path}") content_base64 = base64.b64encode(local_path.read_bytes()).decode() _req("POST", f"/devices/{device}/upload", json={ "path": args.remote_path, "content_base64": content_base64, }) print(f"Uploaded {local_path} → {args.remote_path} on {device}.") def cmd_download(args): """Download a file from the remote device to a local path.""" device = validate_label(args.device) from urllib.parse import quote encoded_path = quote(args.remote_path, safe="") resp = _req("GET", f"/devices/{device}/download?path={encoded_path}") data = resp.json() b64 = data.get("content_base64", "") if not b64: sys.exit(f"[helios-remote] ERROR: No content in download response.") local_path = Path(args.local_path) local_path.parent.mkdir(parents=True, exist_ok=True) local_path.write_bytes(base64.b64decode(b64)) size = data.get("size", len(base64.b64decode(b64))) print(f"Downloaded {args.remote_path} → {local_path} ({size} bytes).") def cmd_prompt(args): """Show a MessageBox on the remote PC asking the user to do something.""" device = validate_label(args.device) body = {"message": args.message} if args.title: body["title"] = args.title resp = _req("POST", f"/devices/{device}/prompt", json=body) data = resp.json() answer = data.get("answer", "") if answer: print(answer) else: print("Prompt confirmed.") def cmd_run(args): """Launch a program on the remote device (fire-and-forget).""" device = validate_label(args.device) _req("POST", f"/devices/{device}/run", json={"program": args.program, "args": args.args}) print(f"Started {args.program!r} on {device}.") def cmd_clipboard_get(args): """Get the clipboard contents from the remote device.""" device = validate_label(args.device) resp = _req("GET", f"/devices/{device}/clipboard") data = resp.json() print(data.get("text", "")) def cmd_clipboard_set(args): """Set the clipboard contents on the remote device.""" device = validate_label(args.device) _req("POST", f"/devices/{device}/clipboard", json={"text": args.text}) print(f"Clipboard set ({len(args.text)} chars) on {device}.") # ── CLI wiring ──────────────────────────────────────────────────────────────── def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="remote.py", description="Control devices connected to the Helios Remote Relay Server.", ) sub = p.add_subparsers(dest="subcmd", required=True) sub.add_parser("devices", help="List all connected devices") sp = sub.add_parser("screenshot", help="Capture screenshot (screen or window label)") sp.add_argument("device", help="Device label") sp.add_argument("target", help="'screen' for full screen, or a window label") ep = sub.add_parser("exec", help="Run a shell command on the remote device") ep.add_argument("device", help="Device label") ep.add_argument("parts", nargs=argparse.REMAINDER, metavar="command", help="Command (and arguments) to execute") ep.add_argument("--timeout", type=int, default=None, metavar="SECONDS", help="Timeout in seconds (default: 30)") wp = sub.add_parser("windows", help="List all visible windows on the remote device") wp.add_argument("device", help="Device label") mp = sub.add_parser("minimize-all", help="Minimize all windows") mp.add_argument("device", help="Device label") fp = sub.add_parser("focus", help="Bring a window to the foreground") fp.add_argument("device", help="Device label") fp.add_argument("window", help="Window label") xp = sub.add_parser("maximize", help="Maximize and focus a window") xp.add_argument("device", help="Device label") xp.add_argument("window", help="Window label") vp = sub.add_parser("version", help="Compare relay, remote.py, and client commits") vp.add_argument("device", help="Device label") lgp = sub.add_parser("logs", help="Fetch last N lines of client log file") lgp.add_argument("device", help="Device label") lgp.add_argument("--lines", type=int, default=100, metavar="N") up = sub.add_parser("upload", help="Upload a local file to the remote device") up.add_argument("device", help="Device label") up.add_argument("local_path") up.add_argument("remote_path") dp = sub.add_parser("download", help="Download a file from the remote device") dp.add_argument("device", help="Device label") dp.add_argument("remote_path") dp.add_argument("local_path") pp = sub.add_parser("prompt", help="Show a MessageBox asking the user to do something") pp.add_argument("device", help="Device label") pp.add_argument("message") pp.add_argument("--title", default=None) rp = sub.add_parser("run", help="Launch a program (fire-and-forget)") rp.add_argument("device", help="Device label") rp.add_argument("program") rp.add_argument("args", nargs=argparse.REMAINDER) cgp = sub.add_parser("clipboard-get", help="Get clipboard contents") cgp.add_argument("device", help="Device label") csp = sub.add_parser("clipboard-set", help="Set clipboard contents") csp.add_argument("device", help="Device label") csp.add_argument("text") return p def main(): parser = build_parser() args = parser.parse_args() { "devices": cmd_devices, "screenshot": cmd_screenshot, "exec": cmd_exec, "windows": cmd_windows, "minimize-all": cmd_minimize_all, "focus": cmd_focus, "maximize": cmd_maximize, "version": cmd_version, "logs": cmd_logs, "upload": cmd_upload, "download": cmd_download, "prompt": cmd_prompt, "run": cmd_run, "clipboard-get": cmd_clipboard_get, "clipboard-set": cmd_clipboard_set, }[args.subcmd](args) if __name__ == "__main__": main()