feat: add OpenClaw skill (remote.py + SKILL.md + config.env.example)

This commit is contained in:
Helios Agent 2026-03-03 15:39:33 +01:00
parent 4bad20a24c
commit fdd2124da8
No known key found for this signature in database
GPG key ID: C8259547CD8309B5
3 changed files with 527 additions and 0 deletions

106
skills/SKILL.md Normal file
View file

@ -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 <session_id> "Moritz-Laptop"
# Screenshot machen → /tmp/helios-remote-screenshot.png
python $SKILL_DIR/remote.py screenshot <session_id>
# Shell-Befehl ausführen
python $SKILL_DIR/remote.py exec <session_id> whoami
python $SKILL_DIR/remote.py exec <session_id> ls -la ~/Desktop
# Mausklick senden
python $SKILL_DIR/remote.py click <session_id> 960 540
# Text tippen
python $SKILL_DIR/remote.py type <session_id> "Hello World"
# Fenster auflisten
python $SKILL_DIR/remote.py windows <session_id>
# Fenster nach Titel suchen (case-insensitive substring)
python $SKILL_DIR/remote.py find-window <session_id> "chrome"
# Alle Fenster minimieren
python $SKILL_DIR/remote.py minimize-all <session_id>
# Fenster fokussieren / maximieren
python $SKILL_DIR/remote.py focus <session_id> <window_id>
python $SKILL_DIR/remote.py maximize <session_id> <window_id>
# Programm starten (fire-and-forget)
python $SKILL_DIR/remote.py run <session_id> notepad.exe
python $SKILL_DIR/remote.py run <session_id> "C:\Program Files\app.exe" --arg1
# Clipboard lesen / setzen
python $SKILL_DIR/remote.py clipboard-get <session_id>
python $SKILL_DIR/remote.py clipboard-set <session_id> "Text in die Zwischenablage"
# Datei hoch-/runterladen
python $SKILL_DIR/remote.py upload <session_id> /tmp/local.txt "C:\Users\User\Desktop\remote.txt"
python $SKILL_DIR/remote.py download <session_id> "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.

View file

@ -0,0 +1,2 @@
HELIOS_REMOTE_URL=https://your-relay-server.example.com
HELIOS_REMOTE_API_KEY=your-api-key-here

419
skills/remote.py Normal file
View file

@ -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()