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:
@@ -3,6 +3,7 @@
|
|||||||
* Registers all IPC handlers for main-renderer communication
|
* Registers all IPC handlers for main-renderer communication
|
||||||
*/
|
*/
|
||||||
import { ipcMain, BrowserWindow, shell, dialog, app } from 'electron';
|
import { ipcMain, BrowserWindow, shell, dialog, app } from 'electron';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
import { GatewayManager } from '../gateway/manager';
|
import { GatewayManager } from '../gateway/manager';
|
||||||
import { ClawHubService, ClawHubSearchParams, ClawHubInstallParams, ClawHubUninstallParams } from '../gateway/clawhub';
|
import { ClawHubService, ClawHubSearchParams, ClawHubInstallParams, ClawHubUninstallParams } from '../gateway/clawhub';
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
type ProviderConfig,
|
type ProviderConfig,
|
||||||
} from '../utils/secure-storage';
|
} from '../utils/secure-storage';
|
||||||
import { getOpenClawStatus, getOpenClawDir } from '../utils/paths';
|
import { getOpenClawStatus, getOpenClawDir } from '../utils/paths';
|
||||||
|
import { getOpenClawCliCommand, installOpenClawCliMac } from '../utils/openclaw-cli';
|
||||||
import { getSetting } from '../utils/store';
|
import { getSetting } from '../utils/store';
|
||||||
import { saveProviderKeyToOpenClaw, setOpenClawDefaultModel } from '../utils/openclaw-auth';
|
import { saveProviderKeyToOpenClaw, setOpenClawDefaultModel } from '../utils/openclaw-auth';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
@@ -500,6 +502,27 @@ function registerOpenClawHandlers(): void {
|
|||||||
return getOpenClawDir();
|
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 ====================
|
// ==================== Channel Configuration Handlers ====================
|
||||||
|
|
||||||
// Save channel configuration
|
// Save channel configuration
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ const electronAPI = {
|
|||||||
'log:listFiles',
|
'log:listFiles',
|
||||||
// OpenClaw extras
|
// OpenClaw extras
|
||||||
'openclaw:getDir',
|
'openclaw:getDir',
|
||||||
|
'openclaw:getCliCommand',
|
||||||
|
'openclaw:installCliMac',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (validChannels.includes(channel)) {
|
if (validChannels.includes(channel)) {
|
||||||
|
|||||||
93
electron/utils/openclaw-cli.ts
Normal file
93
electron/utils/openclaw-cli.ts
Normal 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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* Settings Page
|
* Settings Page
|
||||||
* Application configuration
|
* Application configuration
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -50,6 +50,15 @@ export function Settings() {
|
|||||||
const { status: gatewayStatus, restart: restartGateway } = useGatewayStore();
|
const { status: gatewayStatus, restart: restartGateway } = useGatewayStore();
|
||||||
const currentVersion = useUpdateStore((state) => state.currentVersion);
|
const currentVersion = useUpdateStore((state) => state.currentVersion);
|
||||||
const [controlUiInfo, setControlUiInfo] = useState<ControlUiInfo | null>(null);
|
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
|
// Open developer console
|
||||||
const openDevConsole = async () => {
|
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 (
|
return (
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -328,6 +413,55 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user