diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index cb5f23f0f..a5d6474b6 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -3,6 +3,7 @@ * Registers all IPC handlers for main-renderer communication */ import { ipcMain, BrowserWindow, shell, dialog, app } from 'electron'; +import { existsSync } from 'node:fs'; import { GatewayManager } from '../gateway/manager'; import { ClawHubService, ClawHubSearchParams, ClawHubInstallParams, ClawHubUninstallParams } from '../gateway/clawhub'; import { @@ -21,6 +22,7 @@ import { type ProviderConfig, } from '../utils/secure-storage'; import { getOpenClawStatus, getOpenClawDir } from '../utils/paths'; +import { getOpenClawCliCommand, installOpenClawCliMac } from '../utils/openclaw-cli'; import { getSetting } from '../utils/store'; import { saveProviderKeyToOpenClaw, setOpenClawDefaultModel } from '../utils/openclaw-auth'; import { logger } from '../utils/logger'; @@ -500,6 +502,27 @@ function registerOpenClawHandlers(): void { return getOpenClawDir(); }); + // Get a shell command to run OpenClaw CLI without modifying PATH + ipcMain.handle('openclaw:getCliCommand', () => { + try { + const status = getOpenClawStatus(); + if (!status.packageExists) { + return { success: false, error: `OpenClaw package not found at: ${status.dir}` }; + } + if (!existsSync(status.entryPath)) { + return { success: false, error: `OpenClaw entry script not found at: ${status.entryPath}` }; + } + return { success: true, command: getOpenClawCliCommand() }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + // Install a system-wide openclaw command on macOS (requires admin prompt) + ipcMain.handle('openclaw:installCliMac', async () => { + return installOpenClawCliMac(); + }); + // ==================== Channel Configuration Handlers ==================== // Save channel configuration diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 2261347d0..589c6fb2e 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -113,6 +113,8 @@ const electronAPI = { 'log:listFiles', // OpenClaw extras 'openclaw:getDir', + 'openclaw:getCliCommand', + 'openclaw:installCliMac', ]; if (validChannels.includes(channel)) { diff --git a/electron/utils/openclaw-cli.ts b/electron/utils/openclaw-cli.ts new file mode 100644 index 000000000..31a00156e --- /dev/null +++ b/electron/utils/openclaw-cli.ts @@ -0,0 +1,93 @@ +/** + * OpenClaw CLI utilities + */ +import { app } from 'electron'; +import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { getOpenClawDir, getOpenClawEntryPath } from './paths'; +import { logger } from './logger'; + +function escapeForDoubleQuotes(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function quoteForPosix(value: string): string { + return `"${escapeForDoubleQuotes(value)}"`; +} + +function quoteForPowerShell(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +export function getOpenClawCliCommand(): string { + const entryPath = getOpenClawEntryPath(); + const platform = process.platform; + + if (platform === 'darwin') { + const localBinPath = join(homedir(), '.local', 'bin', 'openclaw'); + if (existsSync(localBinPath)) { + return quoteForPosix(localBinPath); + } + } + + if (!app.isPackaged) { + const openclawDir = getOpenClawDir(); + const nodeModulesDir = dirname(openclawDir); + const binName = platform === 'win32' ? 'openclaw.cmd' : 'openclaw'; + const binPath = join(nodeModulesDir, '.bin', binName); + + if (existsSync(binPath)) { + if (platform === 'win32') { + return `& ${quoteForPowerShell(binPath)}`; + } + return quoteForPosix(binPath); + } + } + + if (app.isPackaged) { + const execPath = process.execPath; + if (platform === 'win32') { + return `$env:ELECTRON_RUN_AS_NODE=1; & ${quoteForPowerShell(execPath)} ${quoteForPowerShell(entryPath)}`; + } + return `ELECTRON_RUN_AS_NODE=1 ${quoteForPosix(execPath)} ${quoteForPosix(entryPath)}`; + } + + if (platform === 'win32') { + return `node ${quoteForPowerShell(entryPath)}`; + } + + return `node ${quoteForPosix(entryPath)}`; +} + +export async function installOpenClawCliMac(): Promise<{ success: boolean; path?: string; error?: string }> +{ + if (process.platform !== 'darwin') { + return { success: false, error: 'Install is only supported on macOS.' }; + } + + const entryPath = getOpenClawEntryPath(); + if (!existsSync(entryPath)) { + return { success: false, error: `OpenClaw entry not found at: ${entryPath}` }; + } + + const execPath = process.execPath; + const targetDir = join(homedir(), '.local', 'bin'); + const target = join(targetDir, 'openclaw'); + + try { + const script = [ + '#!/bin/sh', + `ELECTRON_RUN_AS_NODE=1 "${escapeForDoubleQuotes(execPath)}" "${escapeForDoubleQuotes(entryPath)}" "$@"`, + '', + ].join('\n'); + + mkdirSync(targetDir, { recursive: true }); + writeFileSync(target, script, { mode: 0o755 }); + chmodSync(target, 0o755); + return { success: true, path: target }; + } catch (error) { + logger.error('Failed to install OpenClaw CLI:', error); + return { success: false, error: String(error) }; + } +} diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index 7331514ae..aa58daec8 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -2,7 +2,7 @@ * Settings Page * Application configuration */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Sun, Moon, @@ -50,6 +50,15 @@ export function Settings() { const { status: gatewayStatus, restart: restartGateway } = useGatewayStore(); const currentVersion = useUpdateStore((state) => state.currentVersion); const [controlUiInfo, setControlUiInfo] = useState(null); + const [openclawCliCommand, setOpenclawCliCommand] = useState(''); + const [openclawCliError, setOpenclawCliError] = useState(null); + const [installingCli, setInstallingCli] = useState(false); + + const isMac = window.electron.platform === 'darwin'; + const isWindows = window.electron.platform === 'win32'; + const isLinux = window.electron.platform === 'linux'; + const isDev = window.electron.isDev; + const showCliTools = isMac || isWindows || isLinux; // Open developer console const openDevConsole = async () => { @@ -98,6 +107,82 @@ export function Settings() { } }; + useEffect(() => { + if (!showCliTools) return; + let cancelled = false; + + const loadCliCommand = async () => { + try { + const result = await window.electron.ipcRenderer.invoke('openclaw:getCliCommand') as { + success: boolean; + command?: string; + error?: string; + }; + if (cancelled) return; + if (result.success && result.command) { + setOpenclawCliCommand(result.command); + setOpenclawCliError(null); + } else { + setOpenclawCliCommand(''); + setOpenclawCliError(result.error || 'OpenClaw CLI unavailable'); + } + } catch (error) { + if (cancelled) return; + setOpenclawCliCommand(''); + setOpenclawCliError(String(error)); + } + }; + + loadCliCommand(); + return () => { + cancelled = true; + }; + }, [devModeUnlocked, showCliTools]); + + const handleCopyCliCommand = async () => { + if (!openclawCliCommand) return; + try { + await navigator.clipboard.writeText(openclawCliCommand); + toast.success('CLI command copied'); + } catch (error) { + toast.error(`Failed to copy command: ${String(error)}`); + } + }; + + const handleInstallCliCommand = async () => { + if (!isMac || installingCli) return; + try { + const confirmation = await window.electron.ipcRenderer.invoke('dialog:message', { + type: 'question', + title: 'Install OpenClaw Command', + message: 'Install the "openclaw" command?', + detail: 'This will create ~/.local/bin/openclaw. Ensure ~/.local/bin is on your PATH if you want to run it globally.', + buttons: ['Cancel', 'Install'], + defaultId: 1, + cancelId: 0, + }) as { response: number }; + + if (confirmation.response !== 1) return; + + setInstallingCli(true); + const result = await window.electron.ipcRenderer.invoke('openclaw:installCliMac') as { + success: boolean; + path?: string; + error?: string; + }; + + if (result.success) { + toast.success(`Installed command at ${result.path ?? '/usr/local/bin/openclaw'}`); + } else { + toast.error(result.error || 'Failed to install command'); + } + } catch (error) { + toast.error(`Install failed: ${String(error)}`); + } finally { + setInstallingCli(false); + } + }; + return (
@@ -328,6 +413,55 @@ export function Settings() {
+ {showCliTools && ( + <> + +
+ +

+ Copy a command to run OpenClaw without modifying PATH. +

+ {isWindows && ( +

+ PowerShell command. +

+ )} +
+ + +
+ {isMac && !isDev && ( +
+ +

+ Installs ~/.local/bin/openclaw (no admin required) +

+
+ )} +
+ + )} )}