feat: add OpenClaw CLI command and install flow (#25)

Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
This commit is contained in:
Felix
2026-02-10 13:56:29 +08:00
committed by GitHub
Unverified
parent 7965b9c06e
commit 0cf4ad3a8c
4 changed files with 253 additions and 1 deletions

View File

@@ -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

View File

@@ -113,6 +113,8 @@ const electronAPI = {
'log:listFiles',
// OpenClaw extras
'openclaw:getDir',
'openclaw:getCliCommand',
'openclaw:installCliMac',
];
if (validChannels.includes(channel)) {

View File

@@ -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) };
}
}

View File

@@ -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<ControlUiInfo | null>(null);
const [openclawCliCommand, setOpenclawCliCommand] = useState('');
const [openclawCliError, setOpenclawCliError] = useState<string | null>(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 (
<div className="space-y-6 p-6">
<div>
@@ -328,6 +413,55 @@ export function Settings() {
</div>
</div>
</div>
{showCliTools && (
<>
<Separator />
<div className="space-y-2">
<Label>OpenClaw CLI</Label>
<p className="text-sm text-muted-foreground">
Copy a command to run OpenClaw without modifying PATH.
</p>
{isWindows && (
<p className="text-xs text-muted-foreground">
PowerShell command.
</p>
)}
<div className="flex gap-2">
<Input
readOnly
value={openclawCliCommand}
placeholder={openclawCliError || 'Command unavailable'}
className="font-mono"
/>
<Button
type="button"
variant="outline"
onClick={handleCopyCliCommand}
disabled={!openclawCliCommand}
>
<Copy className="h-4 w-4 mr-2" />
Copy
</Button>
</div>
{isMac && !isDev && (
<div className="space-y-1">
<Button
type="button"
variant="outline"
onClick={handleInstallCliCommand}
disabled={installingCli}
>
<Terminal className="h-4 w-4 mr-2" />
Install "openclaw" Command
</Button>
<p className="text-xs text-muted-foreground">
Installs ~/.local/bin/openclaw (no admin required)
</p>
</div>
)}
</div>
</>
)}
</CardContent>
</Card>
)}