From fdd2124da80f712ec073c246cc3ef627ec6318dc Mon Sep 17 00:00:00 2001 From: Helios Agent Date: Tue, 3 Mar 2026 15:39:33 +0100 Subject: [PATCH] feat: add OpenClaw skill (remote.py + SKILL.md + config.env.example) --- skills/SKILL.md | 106 ++++++++++ skills/config.env.example | 2 + skills/remote.py | 419 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 527 insertions(+) create mode 100644 skills/SKILL.md create mode 100644 skills/config.env.example create mode 100644 skills/remote.py diff --git a/skills/SKILL.md b/skills/SKILL.md new file mode 100644 index 0000000..ef76965 --- /dev/null +++ b/skills/SKILL.md @@ -0,0 +1,106 @@ +# Skill: helios-remote + +Steuere PCs die über den Helios Remote Relay-Server verbunden sind. + +## Wann nutzen + +Wenn Moritz sagt, dass ich etwas auf einem verbundenen PC tun soll: +- "Mach auf meinem PC..." +- "Schau mal was auf dem Rechner läuft..." +- "Nimm einen Screenshot von..." +- "Klick auf..." +- Allgemein: Remote-Zugriff auf einen PC der gerade online ist + +## Setup + +- **Script:** `skills/helios-remote/remote.py` +- **Config:** `skills/helios-remote/config.env` (URL + API-Key, nicht ändern) +- **Abhängigkeit:** `pip install requests` (falls fehlt) + +## Label-Routing + +`session_id` kann ein UUID oder ein Label-Name sein. Falls kein UUID, wird der Name in der Session-Liste nachgeschlagen: + +```bash +python $SKILL_DIR/remote.py screenshot "Moritz PC" # sucht nach Label +``` + +## Befehle + +```bash +# Skill-Verzeichnis +SKILL_DIR=/home/moritz/.openclaw/workspace/skills/helios-remote + +# Alle verbundenen Sessions anzeigen +python $SKILL_DIR/remote.py sessions + +# Session benennen +python $SKILL_DIR/remote.py label "Moritz-Laptop" + +# Screenshot machen → /tmp/helios-remote-screenshot.png +python $SKILL_DIR/remote.py screenshot + +# Shell-Befehl ausführen +python $SKILL_DIR/remote.py exec whoami +python $SKILL_DIR/remote.py exec ls -la ~/Desktop + +# Mausklick senden +python $SKILL_DIR/remote.py click 960 540 + +# Text tippen +python $SKILL_DIR/remote.py type "Hello World" + +# Fenster auflisten +python $SKILL_DIR/remote.py windows + +# Fenster nach Titel suchen (case-insensitive substring) +python $SKILL_DIR/remote.py find-window "chrome" + +# Alle Fenster minimieren +python $SKILL_DIR/remote.py minimize-all + +# Fenster fokussieren / maximieren +python $SKILL_DIR/remote.py focus +python $SKILL_DIR/remote.py maximize + +# Programm starten (fire-and-forget) +python $SKILL_DIR/remote.py run notepad.exe +python $SKILL_DIR/remote.py run "C:\Program Files\app.exe" --arg1 + +# Clipboard lesen / setzen +python $SKILL_DIR/remote.py clipboard-get +python $SKILL_DIR/remote.py clipboard-set "Text in die Zwischenablage" + +# Datei hoch-/runterladen +python $SKILL_DIR/remote.py upload /tmp/local.txt "C:\Users\User\Desktop\remote.txt" +python $SKILL_DIR/remote.py download "C:\Users\User\file.txt" /tmp/downloaded.txt +``` + +## Beispiel-Workflow + +1. Sessions abfragen um die Session-ID zu finden: + ```bash + python $SKILL_DIR/remote.py sessions + ``` + → Ausgabe z.B.: `a1b2c3d4-... [Moritz-Laptop] (Windows 11)` + +2. Screenshot machen und anzeigen: + ```bash + python $SKILL_DIR/remote.py screenshot a1b2c3d4-... + # → /tmp/helios-remote-screenshot.png + ``` + Dann mit `Read` tool oder als Bild an Moritz senden. + +3. Etwas ausführen: + ```bash + python $SKILL_DIR/remote.py exec a1b2c3d4-... tasklist + ``` + +## Fehlerbehandlung + +Das Script gibt bei Fehlern immer aus: +- HTTP-Status + Reason +- Vollständige URL +- Response Body + +Keine unklaren Fehlermeldungen - alles ist beschreibend. diff --git a/skills/config.env.example b/skills/config.env.example new file mode 100644 index 0000000..ce4b228 --- /dev/null +++ b/skills/config.env.example @@ -0,0 +1,2 @@ +HELIOS_REMOTE_URL=https://your-relay-server.example.com +HELIOS_REMOTE_API_KEY=your-api-key-here diff --git a/skills/remote.py b/skills/remote.py new file mode 100644 index 0000000..801a5dc --- /dev/null +++ b/skills/remote.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +""" +helios-remote - CLI to control PCs connected to the Helios Remote Relay Server. +Config is loaded from config.env in the same directory as this script. +""" + +import argparse +import base64 +import os +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") + + +# ── HTTP helpers ───────────────────────────────────────────────────────────── + +def _headers() -> dict: + return {"X-Api-Key": API_KEY, "Content-Type": "application/json"} + + +def _req(method: str, path: str, **kwargs): + url = f"{BASE_URL}{path}" + try: + resp = requests.request(method, url, headers=_headers(), timeout=30, **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 30 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 + + +# ── Subcommands ────────────────────────────────────────────────────────────── + +HEADERS = _headers # alias for lazy evaluation + + +def resolve_session(session_id: str) -> str: + """If session_id looks like a label (not a UUID), resolve it to an actual UUID.""" + import re + uuid_pattern = re.compile( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.I + ) + if uuid_pattern.match(session_id): + return session_id + # Look up by label + resp = _req("GET", "/sessions") + sessions = resp.json().get("sessions", []) + for s in sessions: + if s.get("label", "").lower() == session_id.lower(): + return s["id"] + raise SystemExit(f"[helios-remote] No session found with label '{session_id}'") + + +def cmd_sessions(_args): + """List all connected sessions.""" + resp = _req("GET", "/sessions") + data = resp.json() + sessions = data if isinstance(data, list) else data.get("sessions", []) + + if not sessions: + print("No sessions connected.") + return + + print(f"{'ID':<36} {'Label':<20} Info") + print("-" * 70) + for s in sessions: + sid = str(s.get("id") or s.get("session_id") or "?") + label = str(s.get("label") or s.get("name") or "") + info = str(s.get("os") or s.get("platform") or s.get("info") or "") + print(f"{sid:<36} {label:<20} {info}") + + +def cmd_label(args): + """Assign a human-readable name to a session.""" + sid = resolve_session(args.session_id) + _req("POST", f"/sessions/{sid}/label", json={"label": args.name}) + print(f"Session {sid!r} labeled as {args.name!r}.") + + +def cmd_screenshot(args): + """Capture a screenshot → /tmp/helios-remote-screenshot.png""" + sid = resolve_session(args.session_id) + resp = _req("POST", f"/sessions/{sid}/screenshot") + out_path = Path("/tmp/helios-remote-screenshot.png") + + content_type = resp.headers.get("Content-Type", "") + if "image" in content_type: + out_path.write_bytes(resp.content) + else: + data = resp.json() + b64 = ( + data.get("screenshot") + or data.get("image") + or data.get("image_base64") + or data.get("data") + or data.get("png") + ) + if not b64: + sys.exit( + f"[helios-remote] ERROR: Screenshot response has no image field.\n" + f" Content-Type : {content_type}\n" + f" Keys present : {list(data.keys()) if isinstance(data, dict) else type(data).__name__}" + ) + if "," in b64: # strip data-URI prefix (data:image/png;base64,...) + 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 session.""" + sid = resolve_session(args.session_id) + command = " ".join(args.parts) if isinstance(args.parts, list) else args.parts + resp = _req("POST", f"/sessions/{sid}/exec", json={"command": command}) + 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_click(args): + """Send a mouse click to the remote session.""" + sid = resolve_session(args.session_id) + _req("POST", f"/sessions/{sid}/click", json={"x": args.x, "y": args.y}) + print(f"Clicked ({args.x}, {args.y}) on session {sid!r}.") + + +def cmd_type(args): + """Send keyboard input to the remote session.""" + sid = resolve_session(args.session_id) + _req("POST", f"/sessions/{sid}/type", json={"text": args.text}) + print(f"Typed {len(args.text)} chars on session {sid!r}.") + + +def cmd_windows(args): + """List windows on the remote session (visible only by default).""" + sid = resolve_session(args.session_id) + resp = _req("GET", f"/sessions/{sid}/windows") + data = resp.json() + windows = data.get("windows", []) + # Filter to visible only unless --all is passed + all_windows = getattr(args, "all", False) + if not all_windows: + windows = [w for w in windows if w.get("visible")] + if not windows: + print("No windows returned.") + return + print(f"{'ID':<20} Title") + print("-" * 70) + for w in windows: + wid = str(w.get("id", "?")) + title = w.get("title", "") + print(f"{wid:<20} {title}") + + +def cmd_minimize_all(args): + """Minimize all windows on the remote session.""" + sid = resolve_session(args.session_id) + _req("POST", f"/sessions/{sid}/windows/minimize-all") + print(f"All windows minimized on session {sid!r}.") + + +def cmd_focus(args): + """Bring a window to the foreground on the remote session.""" + sid = resolve_session(args.session_id) + _req("POST", f"/sessions/{sid}/windows/{args.window_id}/focus") + print(f"Window {args.window_id} focused on session {sid!r}.") + + +def cmd_maximize(args): + """Maximize and focus a window on the remote session.""" + sid = resolve_session(args.session_id) + _req("POST", f"/sessions/{sid}/windows/{args.window_id}/maximize") + print(f"Window {args.window_id} maximized and focused on session {sid!r}.") + + +def cmd_server_version(_args): + """Get server version (no auth required).""" + import requests as _requests + try: + resp = _requests.get(f"{BASE_URL}/version", timeout=10) + except Exception as exc: + sys.exit(f"[helios-remote] CONNECTION ERROR: {exc}") + if not resp.ok: + sys.exit(f"[helios-remote] HTTP {resp.status_code}: {resp.text[:500]}") + data = resp.json() + print(f"Server version : {data.get('version', '?')}") + print(f"Commit : {data.get('commit', '?')}") + + +def cmd_version(args): + """Get the client version for a session.""" + sid = resolve_session(args.session_id) + resp = _req("GET", f"/sessions/{sid}/version") + data = resp.json() + print(f"Client version : {data.get('version', '?')}") + print(f"Commit : {data.get('commit', '?')}") + + +def cmd_upload(args): + """Upload a local file to the remote session.""" + sid = resolve_session(args.session_id) + 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"/sessions/{sid}/upload", json={ + "path": args.remote_path, + "content_base64": content_base64, + }) + print(f"Uploaded {local_path} → {args.remote_path} on session {sid!r}.") + + +def cmd_download(args): + """Download a file from the remote session to a local path.""" + sid = resolve_session(args.session_id) + from urllib.parse import quote + encoded_path = quote(args.remote_path, safe="") + resp = _req("GET", f"/sessions/{sid}/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. Keys: {list(data.keys())}") + 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_find_window(args): + """Find visible windows by title substring on the remote session.""" + sid = resolve_session(args.session_id) + resp = _req("GET", f"/sessions/{sid}/windows") + data = resp.json() + windows = data.get("windows", []) + query = args.title.lower() + # Only visible windows + matches = [w for w in windows if w.get("visible") and query in w.get("title", "").lower()] + if not matches: + print(f"No visible windows matching '{args.title}'.") + return + print(f"{'ID':<20} Title") + print("-" * 70) + for w in matches: + wid = str(w.get("id", "?")) + title = w.get("title", "") + print(f"{wid:<20} {title}") + + +def cmd_run(args): + """Launch a program on the remote session (fire-and-forget).""" + sid = resolve_session(args.session_id) + _req("POST", f"/sessions/{sid}/run", json={"program": args.program, "args": args.args}) + print(f"Started {args.program!r} on session {sid!r}.") + + +def cmd_clipboard_get(args): + """Get the clipboard contents from the remote session.""" + sid = resolve_session(args.session_id) + resp = _req("GET", f"/sessions/{sid}/clipboard") + data = resp.json() + print(data.get("text", "")) + + +def cmd_clipboard_set(args): + """Set the clipboard contents on the remote session.""" + sid = resolve_session(args.session_id) + _req("POST", f"/sessions/{sid}/clipboard", json={"text": args.text}) + print(f"Clipboard set ({len(args.text)} chars) on session {sid!r}.") + + +# ── CLI wiring ──────────────────────────────────────────────────────────────── + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="remote.py", + description="Control PCs connected to the Helios Remote Relay Server.", + ) + sub = p.add_subparsers(dest="subcmd", required=True) + + sub.add_parser("sessions", help="List all connected sessions") + + lp = sub.add_parser("label", help="Assign a name to a session") + lp.add_argument("session_id") + lp.add_argument("name") + + sp = sub.add_parser("screenshot", help="Capture screenshot → /tmp/helios-remote-screenshot.png") + sp.add_argument("session_id") + + ep = sub.add_parser("exec", help="Run a shell command on the remote session") + ep.add_argument("session_id") + ep.add_argument("parts", nargs=argparse.REMAINDER, metavar="command", + help="Command (and arguments) to execute") + + cp = sub.add_parser("click", help="Send a mouse click") + cp.add_argument("session_id") + cp.add_argument("x", type=int) + cp.add_argument("y", type=int) + + tp = sub.add_parser("type", help="Send keyboard input") + tp.add_argument("session_id") + tp.add_argument("text") + + wp = sub.add_parser("windows", help="List all windows on the remote session") + wp.add_argument("session_id") + + mp = sub.add_parser("minimize-all", help="Minimize all windows") + mp.add_argument("session_id") + + fp = sub.add_parser("focus", help="Bring a window to the foreground") + fp.add_argument("session_id") + fp.add_argument("window_id", type=int) + + xp = sub.add_parser("maximize", help="Maximize and focus a window") + xp.add_argument("session_id") + xp.add_argument("window_id", type=int) + + sub.add_parser("server-version", help="Get server version (no auth required)") + + vp = sub.add_parser("version", help="Get client version for a session") + vp.add_argument("session_id") + + up = sub.add_parser("upload", help="Upload a local file to the remote session") + up.add_argument("session_id") + up.add_argument("local_path") + up.add_argument("remote_path") + + dp = sub.add_parser("download", help="Download a file from the remote session") + dp.add_argument("session_id") + dp.add_argument("remote_path") + dp.add_argument("local_path") + + fwp = sub.add_parser("find-window", help="Find windows by title substring") + fwp.add_argument("session_id") + fwp.add_argument("title", help="Substring to search for (case-insensitive)") + + rp = sub.add_parser("run", help="Launch a program on the remote session (fire-and-forget)") + rp.add_argument("session_id") + rp.add_argument("program", help="Program to launch (e.g. notepad.exe)") + rp.add_argument("args", nargs=argparse.REMAINDER, help="Optional arguments") + + cgp = sub.add_parser("clipboard-get", help="Get clipboard contents from the remote session") + cgp.add_argument("session_id") + + csp = sub.add_parser("clipboard-set", help="Set clipboard contents on the remote session") + csp.add_argument("session_id") + csp.add_argument("text") + + return p + + +def main(): + parser = build_parser() + args = parser.parse_args() + + { + "sessions": cmd_sessions, + "label": cmd_label, + "screenshot": cmd_screenshot, + "exec": cmd_exec, + "click": cmd_click, + "type": cmd_type, + "windows": cmd_windows, + "minimize-all": cmd_minimize_all, + "focus": cmd_focus, + "maximize": cmd_maximize, + "server-version": cmd_server_version, + "version": cmd_version, + "upload": cmd_upload, + "download": cmd_download, + "find-window": cmd_find_window, + "run": cmd_run, + "clipboard-get": cmd_clipboard_get, + "clipboard-set": cmd_clipboard_set, + }[args.subcmd](args) + + +if __name__ == "__main__": + main()