From 87d5db0c25798387d65fc6a24d7749620a021a45 Mon Sep 17 00:00:00 2001 From: David Piccinini Date: Sat, 16 May 2026 16:22:11 -0300 Subject: [PATCH] v2.0.0: tool-based architecture (no LLM on client) --- easybot_windows_agent/__init__.py | 2 + easybot_windows_agent/main.py | 566 ++++++++++++++++++++++++++++++ install.ps1 | 207 +++++++++++ pyproject.toml | 19 + 4 files changed, 794 insertions(+) create mode 100644 easybot_windows_agent/__init__.py create mode 100644 easybot_windows_agent/main.py create mode 100644 install.ps1 create mode 100644 pyproject.toml diff --git a/easybot_windows_agent/__init__.py b/easybot_windows_agent/__init__.py new file mode 100644 index 0000000..510295b --- /dev/null +++ b/easybot_windows_agent/__init__.py @@ -0,0 +1,2 @@ +"""EasyBot Windows Agent — tool-based remote PC automation client.""" +__version__ = "2.0.0" diff --git a/easybot_windows_agent/main.py b/easybot_windows_agent/main.py new file mode 100644 index 0000000..90d3819 --- /dev/null +++ b/easybot_windows_agent/main.py @@ -0,0 +1,566 @@ +""" +EasyBot Windows Agent — tool-based remote automation client. + +The PC exposes granular tools (run_command, get_ui_tree, click, type, etc.) +and the EasyBot LLM agent controls everything directly. No LLM on client side. + +Configuration via environment variables: + EASYBOT_SERVER WebSocket URL (e.g., wss://app.example.com/ws/windows-agent) + EASYBOT_TOKEN Authentication token (eb_wag_...) + LOG_LEVEL DEBUG | INFO | WARNING (default: INFO) +""" + +import asyncio +import base64 +import io +import json +import logging +import os +import platform +import signal +import subprocess +import sys +import time +from typing import Any + +import websockets +from websockets.client import WebSocketClientProtocol + +from . import __version__ + +logger = logging.getLogger("easybot-windows-agent") + +# ── Configuration ──────────────────────────────────────────────────────────── + +RECONNECT_DELAY_BASE = 5 +RECONNECT_DELAY_MAX = 120 +TOOL_TIMEOUT = 60 # max seconds for a single tool call + + +# ── Tool Handlers ──────────────────────────────────────────────────────────── + +async def tool_run_command(args: dict[str, Any]) -> dict[str, Any]: + """Execute a shell command and return output.""" + command = args.get("command", "") + shell = args.get("shell", "powershell") + + if not command: + return {"error": "No command provided"} + + try: + if shell == "cmd": + proc = await asyncio.create_subprocess_exec( + "cmd", "/c", command, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0, + ) + else: + proc = await asyncio.create_subprocess_exec( + "powershell", "-NoProfile", "-NonInteractive", "-Command", command, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0, + ) + + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=TOOL_TIMEOUT) + return { + "stdout": stdout.decode("utf-8", errors="replace").strip(), + "stderr": stderr.decode("utf-8", errors="replace").strip(), + "exit_code": proc.returncode, + } + except asyncio.TimeoutError: + return {"error": "Command timed out", "exit_code": -1} + except Exception as e: + return {"error": str(e)} + + +async def tool_get_ui_tree(args: dict[str, Any]) -> dict[str, Any]: + """Get the accessibility tree of a window or the desktop.""" + import uiautomation as auto + + window_title = args.get("window") + max_depth = args.get("depth", 3) + + try: + if window_title: + root = auto.WindowControl(searchDepth=1, Name=window_title) + if not root.Exists(maxSearchSeconds=3): + # Try substring match + root = auto.WindowControl(searchDepth=1, SubName=window_title) + if not root.Exists(maxSearchSeconds=2): + return {"error": f"Window '{window_title}' not found"} + else: + root = auto.GetForegroundControl() + if not root: + root = auto.GetRootControl() + + def walk(ctrl: Any, depth: int, max_d: int) -> list[dict]: + if depth > max_d: + return [] + items = [] + try: + name = ctrl.Name or "" + ctrl_type = ctrl.ControlTypeName or "" + auto_id = ctrl.AutomationId or "" + rect = ctrl.BoundingRectangle + value = "" + try: + value = ctrl.GetValuePattern().Value if ctrl.GetValuePattern() else "" + except Exception: + pass + + item: dict[str, Any] = { + "type": ctrl_type, + "name": name, + } + if auto_id: + item["automation_id"] = auto_id + if value: + item["value"] = value + if rect and rect.width() > 0: + item["rect"] = {"x": rect.left, "y": rect.top, "w": rect.width(), "h": rect.height()} + + children = [] + if depth < max_d: + for child in ctrl.GetChildren(): + children.extend(walk(child, depth + 1, max_d)) + if children: + item["children"] = children + + items.append(item) + except Exception: + pass + return items + + tree = walk(root, 0, max_depth) + return {"window": root.Name or "Desktop", "tree": tree} + except Exception as e: + return {"error": str(e)} + + +async def tool_list_windows(args: dict[str, Any]) -> dict[str, Any]: + """List all open top-level windows.""" + import uiautomation as auto + + try: + windows = [] + for win in auto.GetRootControl().GetChildren(): + try: + if win.ControlTypeName == "WindowControl" or win.ClassName == "Window": + name = win.Name or "" + if name and name != "Program Manager": + windows.append({ + "title": name, + "class": win.ClassName or "", + "process_id": win.ProcessId, + }) + except Exception: + continue + return {"windows": windows} + except Exception as e: + return {"error": str(e)} + + +async def tool_click_element(args: dict[str, Any]) -> dict[str, Any]: + """Click a UI element by name, automation_id, or coordinates.""" + name = args.get("name") + auto_id = args.get("automation_id") + x = args.get("x") + y = args.get("y") + button = args.get("button", "left") + double = args.get("double", False) + + try: + if x is not None and y is not None: + import pyautogui + if double: + pyautogui.doubleClick(x, y) + elif button == "right": + pyautogui.rightClick(x, y) + else: + pyautogui.click(x, y) + return {"success": True, "method": "coordinates", "x": x, "y": y} + + import uiautomation as auto + ctrl = None + if auto_id: + ctrl = auto.Control(searchDepth=8, AutomationId=auto_id) + elif name: + ctrl = auto.Control(searchDepth=8, Name=name) + + if ctrl and ctrl.Exists(maxSearchSeconds=3): + if double: + ctrl.DoubleClick() + else: + ctrl.Click() + return {"success": True, "method": "ui_automation", "element": ctrl.Name} + else: + return {"error": f"Element not found: name={name}, automation_id={auto_id}"} + except Exception as e: + return {"error": str(e)} + + +async def tool_type_text(args: dict[str, Any]) -> dict[str, Any]: + """Type text, optionally into a specific element.""" + text = args.get("text", "") + element_name = args.get("element_name") + + if not text: + return {"error": "No text provided"} + + try: + if element_name: + import uiautomation as auto + ctrl = auto.Control(searchDepth=8, Name=element_name) + if ctrl.Exists(maxSearchSeconds=3): + try: + ctrl.GetValuePattern().SetValue(text) + return {"success": True, "method": "set_value", "element": element_name} + except Exception: + ctrl.Click() + await asyncio.sleep(0.2) + + import pyautogui + pyautogui.typewrite(text, interval=0.02) if text.isascii() else pyautogui.write(text) + return {"success": True, "method": "keyboard"} + except Exception as e: + return {"error": str(e)} + + +async def tool_press_keys(args: dict[str, Any]) -> dict[str, Any]: + """Press keyboard keys/shortcuts (e.g. 'ctrl+s', 'enter', 'alt+f4').""" + keys = args.get("keys", "") + if not keys: + return {"error": "No keys provided"} + + try: + import pyautogui + parts = [k.strip() for k in keys.lower().split("+")] + if len(parts) > 1: + pyautogui.hotkey(*parts) + else: + pyautogui.press(parts[0]) + return {"success": True, "keys": keys} + except Exception as e: + return {"error": str(e)} + + +async def tool_screenshot(args: dict[str, Any]) -> dict[str, Any]: + """Take a screenshot and return as base64 PNG.""" + from PIL import ImageGrab + + try: + region = args.get("region") + if region: + bbox = (region["x"], region["y"], region["x"] + region["w"], region["y"] + region["h"]) + img = ImageGrab.grab(bbox=bbox) + else: + img = ImageGrab.grab() + + # Resize for efficiency (max 1280px wide) + if img.width > 1280: + ratio = 1280 / img.width + img = img.resize((1280, int(img.height * ratio))) + + buf = io.BytesIO() + img.save(buf, format="PNG", optimize=True) + b64 = base64.b64encode(buf.getvalue()).decode("ascii") + return {"image_base64": b64, "width": img.width, "height": img.height} + except Exception as e: + return {"error": str(e)} + + +async def tool_read_clipboard(args: dict[str, Any]) -> dict[str, Any]: + """Read text from the clipboard.""" + try: + import pyperclip + text = pyperclip.paste() + return {"text": text} + except ImportError: + # Fallback via PowerShell + proc = await asyncio.create_subprocess_exec( + "powershell", "-NoProfile", "-Command", "Get-Clipboard", + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + return {"text": stdout.decode("utf-8", errors="replace").strip()} + except Exception as e: + return {"error": str(e)} + + +async def tool_set_clipboard(args: dict[str, Any]) -> dict[str, Any]: + """Set clipboard text content.""" + text = args.get("text", "") + try: + import pyperclip + pyperclip.copy(text) + return {"success": True} + except ImportError: + proc = await asyncio.create_subprocess_exec( + "powershell", "-NoProfile", "-Command", f"Set-Clipboard -Value '{text}'", + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + await proc.communicate() + return {"success": True} + except Exception as e: + return {"error": str(e)} + + +async def tool_focus_window(args: dict[str, Any]) -> dict[str, Any]: + """Bring a window to the foreground.""" + title = args.get("title", "") + if not title: + return {"error": "No window title provided"} + + try: + import uiautomation as auto + win = auto.WindowControl(searchDepth=1, SubName=title) + if win.Exists(maxSearchSeconds=3): + win.SetFocus() + win.SetActive() + return {"success": True, "window": win.Name} + else: + return {"error": f"Window '{title}' not found"} + except Exception as e: + return {"error": str(e)} + + +async def tool_file_read(args: dict[str, Any]) -> dict[str, Any]: + """Read a text file.""" + path = args.get("path", "") + if not path: + return {"error": "No path provided"} + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + content = f.read(100_000) # limit to 100KB + return {"content": content, "size": os.path.getsize(path)} + except Exception as e: + return {"error": str(e)} + + +async def tool_file_write(args: dict[str, Any]) -> dict[str, Any]: + """Write content to a text file.""" + path = args.get("path", "") + content = args.get("content", "") + if not path: + return {"error": "No path provided"} + try: + os.makedirs(os.path.dirname(path), exist_ok=True) if os.path.dirname(path) else None + with open(path, "w", encoding="utf-8") as f: + f.write(content) + return {"success": True, "bytes_written": len(content.encode("utf-8"))} + except Exception as e: + return {"error": str(e)} + + +# ── Tool Registry ──────────────────────────────────────────────────────────── + +TOOLS: dict[str, Any] = { + "run_command": tool_run_command, + "get_ui_tree": tool_get_ui_tree, + "list_windows": tool_list_windows, + "click_element": tool_click_element, + "type_text": tool_type_text, + "press_keys": tool_press_keys, + "screenshot": tool_screenshot, + "read_clipboard": tool_read_clipboard, + "set_clipboard": tool_set_clipboard, + "focus_window": tool_focus_window, + "file_read": tool_file_read, + "file_write": tool_file_write, +} + + +# ── Agent Client ───────────────────────────────────────────────────────────── + +class AgentClient: + def __init__(self, server_url: str, token: str): + self.server_url = server_url + self.token = token + self.ws: WebSocketClientProtocol | None = None + self.running = True + self.current_call_id: str | None = None + self.cancel_event = asyncio.Event() + self._reconnect_delay = RECONNECT_DELAY_BASE + + async def run(self) -> None: + """Main loop: connect → handle messages → reconnect on failure.""" + while self.running: + try: + await self._connect_and_handle() + except (websockets.ConnectionClosed, OSError, ConnectionRefusedError) as e: + logger.warning("Connection lost: %s — reconnecting in %ds", e, self._reconnect_delay) + except Exception: + logger.exception("Unexpected error — reconnecting in %ds", self._reconnect_delay) + + if not self.running: + break + await asyncio.sleep(self._reconnect_delay) + self._reconnect_delay = min(self._reconnect_delay * 2, RECONNECT_DELAY_MAX) + + async def _connect_and_handle(self) -> None: + """Single connection lifecycle.""" + extra_headers = {"Authorization": f"Bearer {self.token}"} + + async with websockets.connect( + self.server_url, + additional_headers=extra_headers, + ping_interval=None, + max_size=16 * 1024 * 1024, + close_timeout=10, + ) as ws: + self.ws = ws + self._reconnect_delay = RECONNECT_DELAY_BASE + logger.info("Connected to %s", self.server_url) + + # Send client info with list of available tools + await self._send({ + "type": "client_info", + "hostname": platform.node(), + "os_version": f"{platform.system()} {platform.version()}", + "python_version": platform.python_version(), + "client_version": __version__, + "tools": list(TOOLS.keys()), + }) + + try: + async for raw in ws: + msg = json.loads(raw) + await self._handle_message(msg) + finally: + self.ws = None + self.current_call_id = None + + async def _handle_message(self, msg: dict[str, Any]) -> None: + """Dispatch incoming messages from the server.""" + msg_type = msg.get("type") + + if msg_type == "ping": + await self._send({"type": "pong"}) + + elif msg_type == "tool_call": + call_id = msg["call_id"] + tool_name = msg["tool_name"] + arguments = msg.get("arguments", {}) + logger.info("Tool call: %s (call_id=%s)", tool_name, call_id[:8]) + asyncio.create_task(self._execute_tool(call_id, tool_name, arguments)) + + elif msg_type == "cancel": + call_id = msg.get("call_id") + if self.current_call_id == call_id: + logger.info("Cancel requested for %s", call_id) + self.cancel_event.set() + + else: + logger.debug("Unknown message type: %s", msg_type) + + async def _execute_tool(self, call_id: str, tool_name: str, arguments: dict[str, Any]) -> None: + """Execute a tool and return the result.""" + self.current_call_id = call_id + self.cancel_event.clear() + start = time.time() + + try: + handler = TOOLS.get(tool_name) + if not handler: + await self._send({ + "type": "tool_result", + "call_id": call_id, + "error": f"Unknown tool: {tool_name}", + "duration_ms": 0, + }) + return + + result = await asyncio.wait_for(handler(arguments), timeout=TOOL_TIMEOUT) + duration_ms = int((time.time() - start) * 1000) + + if "error" in result: + await self._send({ + "type": "tool_result", + "call_id": call_id, + "error": result["error"], + "duration_ms": duration_ms, + }) + else: + await self._send({ + "type": "tool_result", + "call_id": call_id, + "result": result, + "duration_ms": duration_ms, + }) + + logger.info("Tool %s completed in %dms", tool_name, duration_ms) + + except asyncio.TimeoutError: + await self._send({ + "type": "tool_result", + "call_id": call_id, + "error": f"Tool '{tool_name}' timed out after {TOOL_TIMEOUT}s", + "duration_ms": int((time.time() - start) * 1000), + }) + except Exception as e: + logger.error("Tool %s failed: %s", tool_name, e) + await self._send({ + "type": "tool_result", + "call_id": call_id, + "error": f"{type(e).__name__}: {e}", + "duration_ms": int((time.time() - start) * 1000), + }) + finally: + self.current_call_id = None + + async def _send(self, msg: dict[str, Any]) -> None: + """Send a JSON message to the server.""" + if self.ws: + try: + await self.ws.send(json.dumps(msg)) + except (websockets.ConnectionClosed, Exception): + pass + + def stop(self) -> None: + """Signal the client to stop.""" + self.running = False + self.cancel_event.set() + + +# ── Entry Point ────────────────────────────────────────────────────────────── + +def main() -> None: + server_url = os.environ.get("EASYBOT_SERVER") + token = os.environ.get("EASYBOT_TOKEN") + + if not server_url or not token: + print("Error: EASYBOT_SERVER and EASYBOT_TOKEN environment variables are required.") + print() + print("Usage:") + print(" set EASYBOT_SERVER=wss://app.example.com/ws/windows-agent") + print(" set EASYBOT_TOKEN=eb_wag_...") + print(" easybot-windows-agent") + sys.exit(1) + + log_level = os.environ.get("LOG_LEVEL", "INFO").upper() + logging.basicConfig( + level=getattr(logging, log_level, logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + logger.info("EasyBot Windows Agent v%s starting", __version__) + logger.info("Server: %s", server_url) + logger.info("Token: %s...", token[:12] if len(token) > 12 else "***") + logger.info("Available tools: %s", ", ".join(TOOLS.keys())) + + client = AgentClient(server_url, token) + + def handle_signal(*_: Any) -> None: + logger.info("Shutdown signal received") + client.stop() + + signal.signal(signal.SIGINT, handle_signal) + signal.signal(signal.SIGTERM, handle_signal) + + asyncio.run(client.run()) + logger.info("Agent stopped") + + +if __name__ == "__main__": + main() diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..e93d7cb --- /dev/null +++ b/install.ps1 @@ -0,0 +1,207 @@ +# ============================================================================ +# EasyBot Windows Agent — One-Line Installer +# ============================================================================ +# +# Usage: +# powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/pyptechnologies/easybot-windows-agent/main/install.ps1 | iex" +# +# Then configure: +# $env:EASYBOT_SERVER = "wss://your-server.com/ws/windows-agent" +# $env:EASYBOT_TOKEN = "eb_wag_..." +# easybot-windows-agent +# +# ============================================================================ + +$ErrorActionPreference = "Stop" +$InstallDir = "$env:ProgramData\EasyBot\WindowsAgent" +$ServiceName = "EasyBotWindowsAgent" +$PythonMinVersion = [version]"3.11.0" + +Write-Host "" +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " EasyBot Windows Agent Installer v1.0" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# ── Check admin ────────────────────────────────────────────────────────────── + +$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $isAdmin) { + Write-Host "[!] This installer requires administrator privileges." -ForegroundColor Yellow + Write-Host " Please run PowerShell as Administrator and try again." -ForegroundColor Yellow + exit 1 +} + +# ── Check Python ───────────────────────────────────────────────────────────── + +Write-Host "[1/5] Checking Python..." -ForegroundColor White +$pythonCmd = $null +foreach ($cmd in @("python", "python3", "py -3")) { + try { + $ver = & $cmd.Split()[0] $cmd.Split()[1..99] --version 2>&1 + if ($ver -match "Python (\d+\.\d+\.\d+)") { + $pyVer = [version]$Matches[1] + if ($pyVer -ge $PythonMinVersion) { + $pythonCmd = $cmd + Write-Host " Found Python $pyVer" -ForegroundColor Green + break + } + } + } catch {} +} + +if (-not $pythonCmd) { + Write-Host " Python >= 3.11 not found. Installing via winget..." -ForegroundColor Yellow + try { + winget install Python.Python.3.12 --accept-source-agreements --accept-package-agreements --silent + # Refresh PATH + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") + $pythonCmd = "python" + Write-Host " Python installed successfully" -ForegroundColor Green + } catch { + Write-Host "[X] Failed to install Python. Please install Python 3.11+ manually." -ForegroundColor Red + Write-Host " https://www.python.org/downloads/" -ForegroundColor Gray + exit 1 + } +} + +# ── Create install directory ───────────────────────────────────────────────── + +Write-Host "[2/5] Creating install directory..." -ForegroundColor White +New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +Write-Host " $InstallDir" -ForegroundColor Gray + +# ── Install Python package from Gitea ──────────────────────────────────────── + +Write-Host "[3/5] Installing easybot-windows-agent from repository..." -ForegroundColor White +$repoZip = "$env:TEMP\easybot-windows-agent.zip" +$repoExtract = "$env:TEMP\easybot-windows-agent" +$repoUrl = "https://repo.pyp.ar/public-pull/easybot-windows-agent/archive/main.zip" + +# Download and extract +if (Test-Path $repoExtract) { Remove-Item $repoExtract -Recurse -Force } +Write-Host " Downloading from repository..." -ForegroundColor Gray +Invoke-WebRequest -Uri $repoUrl -OutFile $repoZip -UseBasicParsing +Expand-Archive -Path $repoZip -DestinationPath $repoExtract -Force + +# Find the extracted folder (Gitea names it easybot-windows-agent/) +$pkgDir = Get-ChildItem -Path $repoExtract -Directory | Select-Object -First 1 + +# Temporarily allow errors (pip writes warnings to stderr which PowerShell treats as errors) +$ErrorActionPreference = "Continue" + +# pip install from local directory +Write-Host " Installing package..." -ForegroundColor Gray +& $pythonCmd.Split()[0] $pythonCmd.Split()[1..99] -m pip install --force-reinstall --no-deps $pkgDir.FullName 2>&1 | ForEach-Object { + if ($_ -match "Successfully installed") { Write-Host " $_" -ForegroundColor Green } +} + +# Install dependencies +Write-Host " Installing dependencies..." -ForegroundColor Gray +& $pythonCmd.Split()[0] $pythonCmd.Split()[1..99] -m pip install "websockets>=12.0,<14" "uiautomation>=2.0" "pyautogui>=0.9" "pywinauto>=0.6" "Pillow>=10.0" 2>&1 | Out-Null + +$ErrorActionPreference = "Stop" + +# Cleanup +Remove-Item $repoZip -Force -ErrorAction SilentlyContinue +Remove-Item $repoExtract -Recurse -Force -ErrorAction SilentlyContinue +Write-Host " Package installed successfully" -ForegroundColor Green + +# ── Configure environment ──────────────────────────────────────────────────── + +Write-Host "[4/5] Configuration..." -ForegroundColor White + +$configFile = "$InstallDir\config.env" +if (Test-Path $configFile) { + Write-Host " Existing config found at $configFile" -ForegroundColor Gray +} else { + # Use env vars if already set, otherwise prompt + $server = if ($env:EASYBOT_SERVER) { $env:EASYBOT_SERVER } else { Read-Host " Enter EasyBot server URL (e.g., wss://app.example.com/ws/windows-agent)" } + $token = if ($env:EASYBOT_TOKEN) { $env:EASYBOT_TOKEN } else { Read-Host " Enter authentication token (eb_wag_...)" } + + Write-Host " Server: $server" -ForegroundColor Gray + Write-Host " Token: $($token.Substring(0, [Math]::Min(12, $token.Length)))..." -ForegroundColor Gray + + @" +EASYBOT_SERVER=$server +EASYBOT_TOKEN=$token +LOG_LEVEL=INFO +"@ | Set-Content -Path $configFile -Encoding UTF8 + + Write-Host " Config saved to $configFile" -ForegroundColor Green +} + +# ── Install as Windows Service via nssm ────────────────────────────────────── + +Write-Host "[5/5] Installing as Windows Service..." -ForegroundColor White + +# Download nssm if not present +$nssmPath = "$InstallDir\nssm.exe" +if (-not (Test-Path $nssmPath)) { + Write-Host " Downloading nssm..." -ForegroundColor Gray + $nssmUrl = "https://repo.pyp.ar/public-pull/easybot-windows-agent/raw/branch/main/bin/nssm.exe" + Invoke-WebRequest -Uri $nssmUrl -OutFile $nssmPath -UseBasicParsing +} + +# Find the easybot-windows-agent script +$agentScript = & $pythonCmd.Split()[0] $pythonCmd.Split()[1..99] -c "import shutil; print(shutil.which('easybot-windows-agent') or '')" 2>$null +if (-not $agentScript) { + # Fallback: use python -m + $agentScript = "" +} + +# Remove existing service if present (ignore errors if not installed) +$ErrorActionPreference = "Continue" +& $nssmPath stop $ServiceName 2>&1 | Out-Null +& $nssmPath remove $ServiceName confirm 2>&1 | Out-Null +$ErrorActionPreference = "Stop" + +# Parse config.env for environment variables +$envVars = @() +Get-Content $configFile | ForEach-Object { + if ($_ -match "^([^#=]+)=(.*)$") { + $envVars += "$($Matches[1])=$($Matches[2])" + } +} + +if ($agentScript) { + & $nssmPath install $ServiceName $agentScript +} else { + $pythonExe = & $pythonCmd.Split()[0] $pythonCmd.Split()[1..99] -c "import sys; print(sys.executable)" + & $nssmPath install $ServiceName $pythonExe "-m" "easybot_windows_agent.main" +} + +& $nssmPath set $ServiceName DisplayName "EasyBot Windows Agent" +& $nssmPath set $ServiceName Description "Remote Windows GUI automation agent for EasyBot" +& $nssmPath set $ServiceName AppDirectory $InstallDir +& $nssmPath set $ServiceName AppEnvironmentExtra ($envVars -join "`n") +& $nssmPath set $ServiceName AppStdout "$InstallDir\agent.log" +& $nssmPath set $ServiceName AppStderr "$InstallDir\agent.log" +& $nssmPath set $ServiceName AppRotateFiles 1 +& $nssmPath set $ServiceName AppRotateBytes 10485760 +& $nssmPath set $ServiceName Start SERVICE_AUTO_START +& $nssmPath set $ServiceName AppExit Default Restart +& $nssmPath set $ServiceName AppRestartDelay 5000 + +Write-Host " Service installed: $ServiceName" -ForegroundColor Green + +# Start the service +& $nssmPath start $ServiceName | Out-Null +Write-Host " Service started!" -ForegroundColor Green + +Write-Host "" +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " Installation Complete!" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" +Write-Host " Install dir: $InstallDir" -ForegroundColor Gray +Write-Host " Config file: $configFile" -ForegroundColor Gray +Write-Host " Log file: $InstallDir\agent.log" -ForegroundColor Gray +Write-Host " Service: $ServiceName" -ForegroundColor Gray +Write-Host "" +Write-Host " Commands:" -ForegroundColor White +Write-Host " nssm status $ServiceName # Check status" -ForegroundColor Gray +Write-Host " nssm restart $ServiceName # Restart agent" -ForegroundColor Gray +Write-Host " nssm stop $ServiceName # Stop agent" -ForegroundColor Gray +Write-Host " nssm remove $ServiceName # Uninstall" -ForegroundColor Gray +Write-Host "" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6054ac0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "easybot-windows-agent" +version = "2.0.0" +description = "EasyBot Windows Agent — remote PC tools client (no LLM)" +requires-python = ">=3.11" +dependencies = [ + "websockets>=12.0,<14", + "uiautomation>=2.0", + "pyautogui>=0.9", + "pywinauto>=0.6", + "Pillow>=10.0", +] + +[project.scripts] +easybot-windows-agent = "easybot_windows_agent.main:main"