feat: add OpenClaw skill (remote.py + SKILL.md + config.env.example)
This commit is contained in:
parent
4bad20a24c
commit
fdd2124da8
3 changed files with 527 additions and 0 deletions
106
skills/SKILL.md
Normal file
106
skills/SKILL.md
Normal 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.
|
||||
2
skills/config.env.example
Normal file
2
skills/config.env.example
Normal 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
419
skills/remote.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue