Skill: sutaixu/iaterm-ws-client (version 1.0.4)

--- name: iaterm-ws-agent description: Interact with IATerm terminals via the local WebSocket API (ws://127.0.0.1:19790/ws). Use when the user asks to list workspaces, displays, or panels, view active terminal connections, send commands to a terminal, or capture/subscribe to terminal output. Also use when integrating external AI agents with IATerm. metadata: short-description: Control IATerm terminals via WebSocket API --- # IATerm WebSocket Agent Control IATerm terminals via the local WebSocket API. ## CRITICAL: Always use the ws_client.py script **NEVER write inline WebSocket code.** Always use the provided CLI script at `scripts/ws_client.py` (relative to this skill file). Find the script path: ```bash SKILL_DIR="$(dirname "$(find ~/.opc/skills -name ws_client.py -path "*/iaterm-ws-agent/*" 2>/dev/null | head -1)")" WS_CLIENT="$SKILL_DIR/ws_client.py" ``` ### Prerequisites ```bash pip install websockets 2>/dev/null || pip3 install websockets 2>/dev/null ``` ### Session ID (IMPORTANT) `IATERM_SESSION_ID` environment variable must be set by the host application (e.g. IATerm) before invoking the client. **Do NOT generate this value yourself** — if the variable is missing, the client will exit with an error, indicating the host application has not properly initialized the session. The first command triggers user approval in the IATerm UI. Once approved, the WS token is cached at `~/.cache/iaterm-ws-client/ws_token.json` (permissions 0600). Subsequent commands reuse the cached token and skip approval. If the token expires, the client automatically clears the cache and re-prompts for approval. ### Operation Approval `send_input` and `subscribe_output` require interactive confirmation before execution: ``` [Approval] send_input to conn-123: 'ls -la\n' y = approve this time | n = reject | a = always approve for this target [y/n/a]: ``` - **y** — approve this single invocation - **n** — reject (command exits with error) - **a** — always approve for this specific target (saved to `~/.cache/iaterm-ws-client/approval.json`) Use `--auto-approve` to skip all confirmation prompts (for automated pipelines): ```bash python3 "$WS_CLIENT" --auto-approve send_input --connection-id <id> --data "ls\n" ``` ## Commands ### List workspaces ```bash python3 "$WS_CLIENT" list_workspaces ``` ### List displays for a workspace ```bash python3 "$WS_CLIENT" list_displays --workspace-id <id> ``` ### List panels (all or filtered by workspace) ```bash python3 "$WS_CLIENT" list_panels python3 "$WS_CLIENT" list_panels --workspace-id <id> ``` ### Get panel info ```bash python3 "$WS_CLIENT" get_panel_info --panel-id <id> ``` ### List active connections **IMPORTANT:** `list_connections` only returns **remote** connections (SSH, Serial, JumpServer). Local terminal connections are automatically filtered out by the backend. If this command returns connections, they are ALL remote — do NOT judge by the `name` field. Always check the `connection_type` field to determine the actual type: - `ssh` — SSH remote connection - `serial` — Serial port connection If the result is empty, it means there are no active remote connections (local terminals may still exist but are excluded from this API). ```bash python3 "$WS_CLIENT" list_connections python3 "$WS_CLIENT" list_connections --type ssh ``` ### Send input to a terminal ```bash python3 "$WS_CLIENT" send_input --connection-id <id> --data "ls -la\n" ``` Use `\n` for Enter, `\t` for Tab, `\x03` for Ctrl-C. ### Subscribe to terminal output (long-lived, Ctrl-C to stop) ```bash python3 "$WS_CLIENT" subscribe_output --connection-id <id> ``` ## How it works 1. **Each command** establishes an independent WebSocket connection, executes, and disconnects. 2. **First connection** — sends `identify(session_id)` → user approves in IATerm UI (up to 60s) → receives `ws_token` → cached to disk. 3. **Subsequent connections** — sends `identify(session_id + cached token)` → server recognizes the token → skips approval → executes immediately. 4. **Token expiry** — if the server rejects a cached token (error -15 or `connection_rejected`), the client clears the cache and retries with a fresh approval flow. 5. **Approval gate** — `send_input` and `subscribe_output` prompt for interactive confirmation (y/n/a) unless `--auto-approve` is set. ## Configuration | Env Variable | Default | Description | |---|---|---| | `IATERM_SESSION_ID` | *(required)* | Stable identifier for this agent session | | `IATERM_WS_PORT` | `19790` | WebSocket server port | | `XDG_CACHE_HOME` | `~/.cache` | Base directory for token/approval cache | ## Changelog ### v2.0.0 — 无 Daemon 直连架构 + 安全防护增强 **架构重写：去 Daemon 化** - 删除 `WsDaemon` 类、Unix socket IPC、daemon 生命周期管理（`_send_msg`/`_recv_msg`/`_start_daemon_bg`/`_stop_daemon` 等） - 删除 `daemon`、`stop` 子命令和 `--session` 参数 - 删除不再需要的 imports：`signal`、`socket`、`struct`、`subprocess` - 每条命令独立建立 WS 连接，执行完断开，无常驻后台进程 **连接与认证** - Session ID 从 `IATERM_SESSION_ID` 环境变量获取，由宿主应用设置，禁止自行生成，缺失则报错退出 - 首次连接：`identify(session_id)` → IATerm UI 审批 → 获得 `ws_token` → 缓存至 `~/.cache/iaterm-ws-client/ws_token.json`（权限 0600） - 后续连接：`identify(session_id + cached token)` → 服务端识别 → 跳过审批 → 直接执行 - Token 失效（error -15 或 `connection_rejected`）时自动清缓存，重走审批流程 **操作审批** - `send_input` / `subscribe_output` 执行前交互式确认：y（本次通过）/ n（拒绝）/ a（始终通过） - 选 `a` 记入 `~/.cache/iaterm-ws-client/approval.json`，后续同目标操作自动通过 - `--auto-approve` CLI 参数跳过所有确认（用于自动化流水线） **文档补充** - `list_connections` 明确只返回远程连接（SSH/Serial/JumpServer），本地终端被后端过滤 - 连接类型判断统一使用 `connection_type` 字段，不依赖 `name` 字段 ### v1.0.2 — Security scan compliance - Daemon startup via `subprocess.Popen(start_new_session=True)` - State directory moved to `~/.cache/iaterm-ws-client/` (XDG) - Explicit `.replace()` escapes instead of `.encode().decode("unicode_escape")` - Session token written to file (permissions 0600) instead of stderr ## Typical Workflow ```bash # IATERM_SESSION_ID is set by the host application — do not generate it manually # 1. Connect — first call triggers approval in IATerm UI python3 "$WS_CLIENT" list_workspaces # 2. Discover — find the connection_id (token cached, no re-approval) python3 "$WS_CLIENT" list_panels python3 "$WS_CLIENT" list_connections # 3. Execute — send command to a terminal (prompts for approval) python3 "$WS_CLIENT" send_input --connection-id <id> --data "ls -la\n" # 4. Read — get terminal output python3 "$WS_CLIENT" subscribe_output --connection-id <id> ``` ## API Reference (for understanding responses) ### Response format - Success: `{ "result": { ... } }` - Error: `{ "error": { "code": <int>, "message": "..." } }` ### Error codes | Code | Meaning | |------|---------| | -1 | Method not found | | -2 | Invalid params | | -3 | Internal error | | -5 | Connection not found | | -6 | Rate limited | | -14 | Connection limit (another client connected) | | -15 | Auth failed (invalid or missing token) | ### Key object fields - **workspace**: `id`, `name`, `description`, `is_default`, `sort_order` - **display**: `id`, `workspace_id`, `display_index`, `name` - **panel**: `id`, `workspace_id`, `connection_id`, `display_id`, `name`, `grid_row`, `grid_column` - **connection**: `id`, `name`, `connection_type` (**use this to determine type, NOT `name`**), `status`, `created_at`, `bytes_sent`, `bytes_received` ## Important Notes - IATerm must be running for the API to be available - The API only binds to 127.0.0.1; not accessible remotely - First connection requires user approval in IATerm UI (60s timeout) - Only one client connection allowed at a time - `send_input` is rate-limited to 30 calls/sec per connection - `connection_id` comes from `list_connections` or panel's `connection_id` field ## Custom Theme Format Custom themes are stored as JSON in `~/.iaterm/themes/{theme-id}.json`. Optional CSS override: `~/.iaterm/themes/{theme-id}.css`. ```json { "id": "my-theme", "name": "my-theme", "displayName": "My Theme", "type": "dark", "designRationale": "Design description (200-500 chars)", "variables": { "backgroundPrimary": "#0d1117", "backgroundSecondary": "#161b22", "backgroundTertiary": "#21262d", "backgroundCard": "#1c2128", "backgroundCardHover": "#262c36", "textPrimary": "#e6edf3", "textSecondary": "#8b949e", "textMuted": "#484f58", "accentPrimary": "#58a6ff", "error": "#f85149", "success": "#3fb950", "warning": "#d29922", "borderPrimary": "#30363d", "borderSubtle": "#21262d", "borderAccent": "#58a6ff", "iconDefault": "#8b949e", "iconHover": "#e6edf3" } } ``` - `type`: `"light"` | `"dark"` | `"auto"` - All color values: hex format (`#rrggbb` or `#rrggbbaa`) - CSS variables auto-mapped: `backgroundPrimary` → `--color-background-primary`

path: scripts/ws_client.py
content: #!/usr/bin/env python3
"""
IATerm WebSocket API Client

Standalone CLI client for interacting with IATerm's local WebSocket API.
Each command establishes an independent WebSocket connection, executes, and disconnects.

Connection flow:
  - Session ID from IATERM_SESSION_ID env var (required)
  - First connection: identify(session_id) → IATerm UI approval → ws_token → cached
  - Subsequent connections: identify(session_id + cached token) → server recognizes → skip approval
  - Token invalidated: auto-clear cache, re-prompt approval

Usage:
    python ws_client.py list_workspaces
    python ws_client.py list_panels
    python ws_client.py list_panels --workspace-id <id>
    python ws_client.py list_displays --workspace-id <id>
    python ws_client.py list_connections
    python ws_client.py list_connections --type ssh
    python ws_client.py get_panel_info --panel-id <id>
    python ws_client.py send_input --connection-id <id> --data "ls -la\\n"
    python ws_client.py subscribe_output --connection-id <id>
    python ws_client.py interactive
"""

import asyncio
import json
import sys
import uuid
import argparse
import os
import time

try:
    import websockets
except ImportError:
    print("Error: 'websockets' package required. Install with: pip install websockets", file=sys.stderr)
    sys.exit(1)


# ─── Configuration ───────────────────────────────────────────────────────────

_cache_home = os.environ.get("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
_state_dir = os.path.join(_cache_home, "iaterm-ws-client")
_token_file = os.path.join(_state_dir, "ws_token.json")
_approval_file = os.path.join(_state_dir, "approval.json")


def get_ws_url():
    """Build the WebSocket URL."""
    port = os.environ.get("IATERM_WS_PORT", "19790")
    return f"ws://127.0.0.1:{port}/ws"


def _ensure_dirs():
    os.makedirs(_state_dir, exist_ok=True)


def _get_session_id():
    """Get session ID from IATERM_SESSION_ID environment variable."""
    sid = os.environ.get("IATERM_SESSION_ID")
    if not sid:
        print("Error: IATERM_SESSION_ID environment variable is not set.", file=sys.stderr)
        print("This variable should be set by the host application (e.g. IATerm).", file=sys.stderr)
        sys.exit(1)
    return sid


def _log(msg):
    line = f"[{time.strftime('%H:%M:%S')}] {msg}"
    print(line, file=sys.stderr, flush=True)


# ─── Token cache ─────────────────────────────────────────────────────────────

def _load_cached_token(session_id):
    """Load cached WS token for this session_id. Returns token string or None."""
    try:
        with open(_token_file, "r") as f:
            data = json.load(f)
        if data.get("session_id") == session_id:
            return data.get("token")
    except (FileNotFoundError, json.JSONDecodeError, KeyError):
        pass
    return None


def _save_cached_token(session_id, token):
    """Save WS token to cache file (permissions 0600)."""
    _ensure_dirs()
    data = {"session_id": session_id, "token": token}
    fd = os.open(_token_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as f:
        json.dump(data, f)


def _clear_cached_token():
    """Remove cached token file."""
    try:
        os.unlink(_token_file)
    except FileNotFoundError:
        pass


# ─── Approval ────────────────────────────────────────────────────────────────

def _load_approvals():
    """Load the approval whitelist."""
    try:
        with open(_approval_file, "r") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return {}


def _save_approvals(approvals):
    """Save the approval whitelist."""
    _ensure_dirs()
    fd = os.open(_approval_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as f:
        json.dump(approvals, f, indent=2)


def _check_approval(method, params, auto_approve=False):
    """Check if an operation needs approval. Returns True if approved, False if rejected."""
    # Only gate sensitive operations
    if method not in ("send_input", "subscribe_output"):
        return True

    if auto_approve:
        return True

    # Check persistent approvals
    approvals = _load_approvals()
    approval_key = method
    if method == "send_input":
        conn_id = params.get("connection_id", "")
        approval_key = f"send_input:{conn_id}"
    elif method == "subscribe_output":
        conn_id = params.get("connection_id", "")
        approval_key = f"subscribe_output:{conn_id}"

    if approvals.get(approval_key) == "always":
        return True

    # Interactive confirmation
    desc = method
    if method == "send_input":
        data_preview = params.get("data", "")
        if len(data_preview) > 80:
            data_preview = data_preview[:77] + "..."
        desc = f"send_input to {params.get('connection_id', '?')}: {repr(data_preview)}"
    elif method == "subscribe_output":
        desc = f"subscribe_output on {params.get('connection_id', '?')}"

    print(f"\n[Approval] {desc}", file=sys.stderr)
    print("  y = approve this time  |  n = reject  |  a = always approve for this target", file=sys.stderr)
    try:
        choice = input("  [y/n/a]: ").strip().lower()
    except (EOFError, KeyboardInterrupt):
        print("", file=sys.stderr)
        return False

    if choice == "a":
        approvals[approval_key] = "always"
        _save_approvals(approvals)
        return True
    elif choice == "y":
        return True
    else:
        return False


# ─── WebSocket connection ────────────────────────────────────────────────────

async def _connect_and_identify(session_id, client_type="ws-client", client_id=None):
    """
    Connect to IATerm WS, send identify, handle approval.
    Returns (ws, token) on success.
    Raises SystemExit on failure.
    """
    url = get_ws_url()
    cached_token = _load_cached_token(session_id)

    try:
        ws = await websockets.connect(url)
    except (ConnectionRefusedError, OSError) as e:
        print(f"Cannot connect to IATerm at {url}: {e}", file=sys.stderr)
        print("Ensure IATerm is running.", file=sys.stderr)
        sys.exit(1)

    # Send identify with session_id and optional cached token
    identify_msg = {
        "type": "identify",
        "client_type": client_type,
        "client_id": client_id or f"agent-{os.getpid()}",
        "session_id": session_id,
    }
    if cached_token:
        identify_msg["token"] = cached_token

    await ws.send(json.dumps(identify_msg))

    # Wait for approval response
    if not cached_token:
        _log("Waiting for connection approval in IATerm...")

    try:
        raw = await asyncio.wait_for(ws.recv(), timeout=65)
    except asyncio.TimeoutError:
        _log("Approval timeout (65s)")
        await ws.close()
        sys.exit(1)
    except websockets.exceptions.ConnectionClosed:
        _log("Connection closed during approval")
        sys.exit(1)

    data = json.loads(raw)

    if "event" in data:
        if data["event"] == "connected":
            [REDACTED_SECRET]"data", {}).get("token")
            sid = data.get("data", {}).get("session_id", session_id)
            if [REDACTED_SECRET], token)
            _log(f"Connected (session: {sid})")
            return ws, token

        elif data["event"] == "connection_rejected":
            msg = data.get("data", {}).get("message", "rejected")
            # Token might be stale — clear cache and retry once
            if cached_token:
                _log(f"Connection rejected (cached token may be stale): {msg}")
                _clear_cached_token()
                await ws.close()
                return await _connect_and_identify(session_id, client_type, client_id)
            _log(f"Connection rejected: {msg}")
            await ws.close()
            sys.exit(1)

    if "error" in data:
        err_code = data["error"].get("code")
        err_msg = data["error"].get("message", "")
        # Auth failure with cached token — clear and retry
        if cached_token and err_code == -15:
            _log(f"Token expired: {err_msg}")
            _clear_cached_token()
            await ws.close()
            return await _connect_and_identify(session_id, client_type, client_id)
        _log(f"Connection error: {err_msg}")
        await ws.close()
        sys.exit(1)

    _log(f"Unexpected response: {json.dumps(data)}")
    await ws.close()
    sys.exit(1)


async def _ws_request(ws, method, params=None, token=None):
    """Send a request on a WS and wait for the response."""
    req_id = str(uuid.uuid4())[:8]
    msg = {"id": req_id, "method": method, "params": params or {}}
    if token:
        msg["token"] = token
    await ws.send(json.dumps(msg))
    while True:
        raw = await ws.recv()
        data = json.loads(raw)
        if "event" in data:
            continue
        if data.get("id") == req_id:
            return data


# ─── CLI command execution ───────────────────────────────────────────────────

def print_result(data):
    """Pretty-print a response."""
    if "error" in data:
        print(f"Error ({data['error']['code']}): {data['error']['message']}", file=sys.stderr)
        sys.exit(1)
    print(json.dumps(data.get("result", data), ensure_ascii=False, indent=2))


async def cmd_execute(method, params=None, auto_approve=False, client_type="ws-client", client_id=None):
    """Execute a single command: connect, request, disconnect."""
    session_id = _get_session_id()
    params = params or {}

    # Check approval for sensitive operations
    if not _check_approval(method, params, auto_approve=auto_approve):
        print("Operation rejected by user.", file=sys.stderr)
        sys.exit(1)

    ws, token = await _connect_and_identify(session_id, client_type, client_id)
    try:
        result = await _ws_request(ws, method, params, token=token)
        print_result(result)
    finally:
        await ws.close()


async def cmd_subscribe(connection_id, auto_approve=False, client_type="ws-client", client_id=None):
    """Subscribe to terminal output."""
    session_id = _get_session_id()
    params = {"connection_id": connection_id}

    if not _check_approval("subscribe_output", params, auto_approve=auto_approve):
        print("Operation rejected by user.", file=sys.stderr)
        sys.exit(1)

    ws, token = await _connect_and_identify(session_id, client_type, client_id)
    try:
        result = await _ws_request(ws, "subscribe_output", params, token=token)
        if "error" in result:
            print_result(result)
            return

        print(f"Subscribed to {connection_id}. Press Ctrl-C to stop.", file=sys.stderr)
        try:
            while True:
                raw = await ws.recv()
                data = json.loads(raw)
                if "event" in data and data["event"] == "terminal_output":
                    sys.stdout.write(data["data"].get("output", ""))
                    sys.stdout.flush()
                elif "event" in data and data["event"] == "disconnected":
                    print("\nServer disconnected.", file=sys.stderr)
                    break
        except (KeyboardInterrupt, asyncio.CancelledError):
            await _ws_request(ws, "unsubscribe_output", {"connection_id": connection_id}, token=token)
            print("\nUnsubscribed.", file=sys.stderr)
    finally:
        await ws.close()


async def cmd_interactive(client_type="ws-client", client_id=None):
    """Interactive mode."""
    session_id = _get_session_id()
    ws, token = await _connect_and_identify(session_id, client_type, client_id)
    try:
        print("Connected. Type JSON requests or use shortcuts:", file=sys.stderr)
        print("  lw = list_workspaces | lp = list_panels | lc = list_connections", file=sys.stderr)
        print("  q = quit", file=sys.stderr)

        shortcuts = {
            "lw": ("list_workspaces", {}),
            "lp": ("list_panels", {}),
            "lc": ("list_connections", {}),
        }

        async def reader():
            try:
                while True:
                    raw = await ws.recv()
                    data = json.loads(raw)
                    if "event" in data:
                        print(f"\n[EVENT] {json.dumps(data, ensure_ascii=False)}")
                    else:
                        print(f"\n[RESP] {json.dumps(data, ensure_ascii=False, indent=2)}")
                    print("> ", end="", flush=True)
            except websockets.exceptions.ConnectionClosed:
                pass

        reader_task = asyncio.create_task(reader())
        loop = asyncio.get_event_loop()
        try:
            while True:
                print("> ", end="", flush=True)
                line = await loop.run_in_executor(None, sys.stdin.readline)
                line = line.strip()
                if not line:
                    continue
                if line == "q":
                    break

                if line in shortcuts:
                    method, params = shortcuts[line]
                    req_id = str(uuid.uuid4())[:8]
                    msg = {"id": req_id, "method": method, "params": params, "token": token}
                else:
                    try:
                        msg = json.loads(line)
                        if "id" not in msg:
                            msg["id"] = str(uuid.uuid4())[:8]
                        if "token" not in msg:
                            msg["token"] = token
                    except json.JSONDecodeError:
                        print("Invalid JSON. Use shortcuts (lw/lp/lc) or full JSON.", file=sys.stderr)
                        continue
                await ws.send(json.dumps(msg))
        except (KeyboardInterrupt, EOFError):
            pass
        finally:
            reader_task.cancel()
            print("\nDisconnected.", file=sys.stderr)
    finally:
        await ws.close()


# ─── Main ────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="IATerm WebSocket API Client")
    parser.add_argument("--auto-approve", action="store_true",
                        help="Skip interactive approval for send_input/subscribe_output")
    parser.add_argument("--client-type", default=None, help="Client type identifier (e.g. claude-code, gemini)")
    parser.add_argument("--client-id", default=None, help="Client instance identifier")
    sub = parser.add_subparsers(dest="command")

    sub.add_parser("list_workspaces", help="List all workspaces")

    p_ld = sub.add_parser("list_displays", help="List displays for a workspace")
    p_ld.add_argument("--workspace-id", required=True)

    p_lp = sub.add_parser("list_panels", help="List panels")
    p_lp.add_argument("--workspace-id", default=None)

    p_pi = sub.add_parser("get_panel_info", help="Get panel details")
    p_pi.add_argument("--panel-id", required=True)

    p_lc = sub.add_parser("list_connections", help="List active connections")
    p_lc.add_argument("--type", default=None, dest="conn_type")

    p_si = sub.add_parser("send_input", help="Send input to terminal")
    p_si.add_argument("--connection-id", required=True)
    p_si.add_argument("--data", required=True, help="Text to send (supports \\n, \\t escapes)")

    p_so = sub.add_parser("subscribe_output", help="Subscribe to terminal output")
    p_so.add_argument("--connection-id", required=True)

    sub.add_parser("interactive", help="Interactive mode")

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        sys.exit(1)

    ct = args.client_type
    ci = args.client_id
    aa = args.auto_approve

    if args.command == "list_workspaces":
        asyncio.run(cmd_execute("list_workspaces", auto_approve=aa, client_type=ct, client_id=ci))
    elif args.command == "list_displays":
        asyncio.run(cmd_execute("list_displays", {"workspace_id": args.workspace_id}, auto_approve=aa, client_type=ct, client_id=ci))
    elif args.command == "list_panels":
        params = {}
        if args.workspace_id:
            params["workspace_id"] = args.workspace_id
        asyncio.run(cmd_execute("list_panels", params, auto_approve=aa, client_type=ct, client_id=ci))
    elif args.command == "get_panel_info":
        asyncio.run(cmd_execute("get_panel_info", {"panel_id": args.panel_id}, auto_approve=aa, client_type=ct, client_id=ci))
    elif args.command == "list_connections":
        params = {}
        if args.conn_type:
            params["type"] = args.conn_type
        asyncio.run(cmd_execute("list_connections", params, auto_approve=aa, client_type=ct, client_id=ci))
    elif args.command == "send_input":
        data = args.data.replace("\\n", "\n").replace("\\t", "\t").replace("\\r", "\r")
        asyncio.run(cmd_execute("send_input", {"connection_id": args.connection_id, "data": data}, auto_approve=aa, client_type=ct, client_id=ci))
    elif args.command == "subscribe_output":
        asyncio.run(cmd_subscribe(args.connection_id, auto_approve=aa, client_type=ct, client_id=ci))
    elif args.command == "interactive":
        asyncio.run(cmd_interactive(client_type=ct, client_id=ci))


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)