From 9fba7bfdd34de3e3849f2f0f588abcfa2cc64225 Mon Sep 17 00:00:00 2001 From: DeskClaw Bot Date: Tue, 21 Apr 2026 17:52:26 +0000 Subject: [PATCH] feat(win): hermes wsl installer hooks --- electron/api/context.ts | 2 + electron/api/routes/hermes.ts | 31 ++++++++ electron/api/server.ts | 2 + electron/hermes/manager.ts | 97 ++++++++++++++++++++++++ electron/main/index.ts | 4 + resources/hermes/install-hermes.ps1 | 12 +++ resources/hermes/start-hermes.ps1 | 10 +++ src/pages/Settings/index.tsx | 110 ++++++++++++++++++++++++++++ 8 files changed, 268 insertions(+) create mode 100644 electron/api/routes/hermes.ts create mode 100644 electron/hermes/manager.ts create mode 100644 resources/hermes/install-hermes.ps1 create mode 100644 resources/hermes/start-hermes.ps1 diff --git a/electron/api/context.ts b/electron/api/context.ts index 0cdc726a7..c56b80801 100644 --- a/electron/api/context.ts +++ b/electron/api/context.ts @@ -2,9 +2,11 @@ import type { BrowserWindow } from 'electron'; import type { GatewayManager } from '../gateway/manager'; import type { ClawHubService } from '../gateway/clawhub'; import type { HostEventBus } from './event-bus'; +import type { HermesManager } from '../hermes/manager'; export interface HostApiContext { gatewayManager: GatewayManager; + hermesManager: HermesManager; clawHubService: ClawHubService; eventBus: HostEventBus; mainWindow: BrowserWindow | null; diff --git a/electron/api/routes/hermes.ts b/electron/api/routes/hermes.ts new file mode 100644 index 000000000..cdbfe2c83 --- /dev/null +++ b/electron/api/routes/hermes.ts @@ -0,0 +1,31 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import type { HostApiContext } from '../context'; +import { sendJson } from '../route-utils'; + +export async function handleHermesRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + if (url.pathname === '/api/hermes/status' && req.method === 'GET') { + const status = await ctx.hermesManager.refreshStatus(); + sendJson(res, 200, { success: true, status }); + return true; + } + + if (url.pathname === '/api/hermes/install' && req.method === 'POST') { + const result = await ctx.hermesManager.install(); + sendJson(res, result.success ? 200 : 500, { ...result }); + return true; + } + + if (url.pathname === '/api/hermes/start' && req.method === 'POST') { + const result = await ctx.hermesManager.startGateway(); + sendJson(res, result.success ? 200 : 500, { ...result }); + return true; + } + + return false; +} + diff --git a/electron/api/server.ts b/electron/api/server.ts index d2085cdce..d2b714cde 100644 --- a/electron/api/server.ts +++ b/electron/api/server.ts @@ -6,6 +6,7 @@ import { extensionRegistry } from '../extensions/registry'; import type { HostApiContext } from './context'; import { handleAppRoutes } from './routes/app'; import { handleGatewayRoutes } from './routes/gateway'; +import { handleHermesRoutes } from './routes/hermes'; import { handleSettingsRoutes } from './routes/settings'; import { handleProviderRoutes } from './routes/providers'; import { handleAgentRoutes } from './routes/agents'; @@ -29,6 +30,7 @@ type RouteHandler = ( const coreRouteHandlers: RouteHandler[] = [ handleAppRoutes, handleGatewayRoutes, + handleHermesRoutes, handleSettingsRoutes, handleProviderRoutes, handleAgentRoutes, diff --git a/electron/hermes/manager.ts b/electron/hermes/manager.ts new file mode 100644 index 000000000..3c8ec7477 --- /dev/null +++ b/electron/hermes/manager.ts @@ -0,0 +1,97 @@ +import { spawn } from 'node:child_process'; +import { join } from 'node:path'; +import { getResourcesDir } from '../utils/paths'; + +export type HermesInstallState = 'unknown' | 'not_installed' | 'installed' | 'wsl_missing' | 'error'; + +export type HermesRunState = 'stopped' | 'running' | 'error'; + +export interface HermesStatus { + installState: HermesInstallState; + runState: HermesRunState; + version?: string; + error?: string; +} + +function runPowerShell(scriptPath: string): Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number | null }> { + return new Promise((resolve) => { + const child = spawn('powershell.exe', [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + scriptPath, + ], { windowsHide: true }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => { stdout += String(d); }); + child.stderr.on('data', (d) => { stderr += String(d); }); + child.on('close', (code) => { + resolve({ success: code === 0, stdout, stderr, exitCode: code }); + }); + }); +} + +function runWsl(args: string[]): Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number | null }> { + return new Promise((resolve) => { + const child = spawn('wsl.exe', args, { windowsHide: true }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => { stdout += String(d); }); + child.stderr.on('data', (d) => { stderr += String(d); }); + child.on('close', (code) => resolve({ success: code === 0, stdout, stderr, exitCode: code })); + }); +} + +export class HermesManager { + private status: HermesStatus = { installState: 'unknown', runState: 'stopped' }; + + getStatus(): HermesStatus { + return { ...this.status }; + } + + async refreshStatus(): Promise { + if (process.platform !== 'win32') { + this.status = { installState: 'error', runState: 'stopped', error: 'Hermes Windows integration requires WSL2' }; + return this.getStatus(); + } + + const wslCheck = await runWsl(['-e', 'bash', '-lc', 'echo ok']); + if (!wslCheck.success) { + this.status = { installState: 'wsl_missing', runState: 'stopped', error: wslCheck.stderr.trim() || 'WSL2 not available' }; + return this.getStatus(); + } + + const version = await runWsl(['-e', 'bash', '-lc', 'command -v hermes >/dev/null 2>&1 && hermes --version || hermes version || true']); + const raw = (version.stdout || version.stderr).trim(); + if (!raw) { + this.status = { installState: 'not_installed', runState: 'stopped' }; + return this.getStatus(); + } + + this.status = { installState: 'installed', runState: this.status.runState, version: raw }; + return this.getStatus(); + } + + async install(): Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number | null }> { + if (process.platform !== 'win32') { + return { success: false, stdout: '', stderr: 'Windows only', exitCode: 1 }; + } + const scriptPath = join(getResourcesDir(), 'hermes', 'install-hermes.ps1'); + const result = await runPowerShell(scriptPath); + await this.refreshStatus(); + return result; + } + + async startGateway(): Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number | null }> { + if (process.platform !== 'win32') { + return { success: false, stdout: '', stderr: 'Windows only', exitCode: 1 }; + } + const scriptPath = join(getResourcesDir(), 'hermes', 'start-hermes.ps1'); + const result = await runPowerShell(scriptPath); + this.status = { ...this.status, runState: result.success ? 'running' : 'error', error: result.success ? undefined : result.stderr.trim() }; + return result; + } +} + diff --git a/electron/main/index.ts b/electron/main/index.ts index f115da0c1..0c6a29034 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -6,6 +6,7 @@ import { app, BrowserWindow, nativeImage, session, shell } from 'electron'; import type { Server } from 'node:http'; import { join } from 'path'; import { GatewayManager } from '../gateway/manager'; +import { HermesManager } from '../hermes/manager'; import { registerIpcHandlers } from './ipc-handlers'; import { createTray } from './tray'; import { createMenu } from './menu'; @@ -121,6 +122,7 @@ const gotTheLock = gotElectronLock && gotFileLock; // Global references let mainWindow: BrowserWindow | null = null; let gatewayManager!: GatewayManager; +let hermesManager!: HermesManager; let clawHubService!: ClawHubService; let hostEventBus!: HostEventBus; let hostApiServer: Server | null = null; @@ -339,6 +341,7 @@ async function initialize(): Promise { hostApiServer = startHostApiServer({ gatewayManager, + hermesManager, clawHubService, eventBus: hostEventBus, mainWindow: window, @@ -538,6 +541,7 @@ if (gotTheLock) { } gatewayManager = new GatewayManager(); + hermesManager = new HermesManager(); clawHubService = new ClawHubService(); hostEventBus = new HostEventBus(); diff --git a/resources/hermes/install-hermes.ps1 b/resources/hermes/install-hermes.ps1 new file mode 100644 index 000000000..a4ffec9ec --- /dev/null +++ b/resources/hermes/install-hermes.ps1 @@ -0,0 +1,12 @@ +$ErrorActionPreference = "Stop" + +$wsl = Get-Command wsl.exe -ErrorAction SilentlyContinue +if (-not $wsl) { + Write-Output "WSL2 is required. Install it first: https://learn.microsoft.com/windows/wsl/install" + exit 2 +} + +& wsl.exe -e bash -lc "command -v curl >/dev/null 2>&1 || (sudo apt-get update -y && sudo apt-get install -y curl)" +& wsl.exe -e bash -lc "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash" +& wsl.exe -e bash -lc "command -v hermes && hermes --help >/dev/null 2>&1 && echo OK" + diff --git a/resources/hermes/start-hermes.ps1 b/resources/hermes/start-hermes.ps1 new file mode 100644 index 000000000..13ff8ac03 --- /dev/null +++ b/resources/hermes/start-hermes.ps1 @@ -0,0 +1,10 @@ +$ErrorActionPreference = "Stop" + +$wsl = Get-Command wsl.exe -ErrorAction SilentlyContinue +if (-not $wsl) { + Write-Output "WSL2 is required." + exit 2 +} + +& wsl.exe -e bash -lc "command -v hermes >/dev/null 2>&1 || exit 3; nohup hermes gateway start >/tmp/hermes-gateway.log 2>&1 ([]); + const [hermesStatus, setHermesStatus] = useState(null); + const [hermesBusy, setHermesBusy] = useState(false); const isWindows = window.electron.platform === 'win32'; const showCliTools = true; @@ -233,6 +242,51 @@ export function Settings() { } }; + const refreshHermesStatus = async () => { + try { + const result = await hostApiFetch<{ success: boolean; status?: HermesStatus }>('/api/hermes/status'); + if (result.success && result.status) { + setHermesStatus(result.status); + } + } catch { + setHermesStatus({ installState: 'error', runState: 'stopped', error: 'Failed to query Hermes status' }); + } + }; + + const handleInstallHermes = async () => { + setHermesBusy(true); + try { + const result = await hostApiFetch<{ success: boolean; stderr?: string }>('/api/hermes/install', { method: 'POST' }); + if (result.success) { + toast.success('Hermes installed'); + } else { + toast.error(result.stderr || 'Hermes install failed'); + } + } catch (error) { + toast.error(toUserMessage(error) || 'Hermes install failed'); + } finally { + setHermesBusy(false); + void refreshHermesStatus(); + } + }; + + const handleStartHermes = async () => { + setHermesBusy(true); + try { + const result = await hostApiFetch<{ success: boolean; stderr?: string }>('/api/hermes/start', { method: 'POST' }); + if (result.success) { + toast.success('Hermes started'); + } else { + toast.error(result.stderr || 'Hermes start failed'); + } + } catch (error) { + toast.error(toUserMessage(error) || 'Hermes start failed'); + } finally { + setHermesBusy(false); + void refreshHermesStatus(); + } + }; + useEffect(() => { if (!showCliTools) return; let cancelled = false; @@ -262,6 +316,12 @@ export function Settings() { return () => { cancelled = true; }; }, [devModeUnlocked, showCliTools]); + useEffect(() => { + if (!isWindows) return; + if (gatewayBackend !== 'hermes') return; + void refreshHermesStatus(); + }, [gatewayBackend, isWindows]); + const handleCopyCliCommand = async () => { if (!openclawCliCommand) return; try { @@ -648,6 +708,56 @@ export function Settings() { + {isWindows && gatewayBackend === 'hermes' && ( +
+
+
+ +

+ {hermesStatus + ? `${hermesStatus.installState} / ${hermesStatus.runState}${hermesStatus.version ? ` (${hermesStatus.version})` : ''}` + : 'unknown'} +

+ {hermesStatus?.error ? ( +

+ {hermesStatus.error} +

+ ) : null} +
+ +
+
+ + +
+
+ )} +