From a0505490cda51af70526127f073771614224fc91 Mon Sep 17 00:00:00 2001 From: Felix <24791380+vcfgv@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:32:03 +0800 Subject: [PATCH] feat: enhance ClawHub integration with new CLI paths and IPC handlers for config and skills directories (#41) --- electron/gateway/clawhub.ts | 52 ++++++++++++++++++++++-------- electron/main/ipc-handlers.ts | 12 ++++++- electron/preload/index.ts | 2 ++ electron/utils/paths.ts | 22 +++++++++++++ src/pages/Skills/index.tsx | 60 ++++++++++++++++++++++++++++++----- src/stores/skills.ts | 6 ++-- 6 files changed, 129 insertions(+), 25 deletions(-) diff --git a/electron/gateway/clawhub.ts b/electron/gateway/clawhub.ts index 591c933c7..fc93e0853 100644 --- a/electron/gateway/clawhub.ts +++ b/electron/gateway/clawhub.ts @@ -6,7 +6,7 @@ import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; import { app, shell } from 'electron'; -import { getOpenClawConfigDir, ensureDir } from '../utils/paths'; +import { getOpenClawConfigDir, ensureDir, getClawHubCliBinPath, getClawHubCliEntryPath } from '../utils/paths'; export interface ClawHubSearchParams { query: string; @@ -36,6 +36,8 @@ export interface ClawHubSkillResult { export class ClawHubService { private workDir: string; private cliPath: string; + private cliEntryPath: string; + private useNodeRunner: boolean; private ansiRegex: RegExp; constructor() { @@ -44,11 +46,17 @@ export class ClawHubService { this.workDir = getOpenClawConfigDir(); ensureDir(this.workDir); - // In development, we use the locally installed clawhub CLI from node_modules - const isWin = process.platform === 'win32'; - const binName = isWin ? 'clawhub.cmd' : 'clawhub'; - const localCli = path.resolve(app.getAppPath(), 'node_modules', '.bin', binName); - this.cliPath = localCli; + const binPath = getClawHubCliBinPath(); + const entryPath = getClawHubCliEntryPath(); + + this.cliEntryPath = entryPath; + if (!app.isPackaged && fs.existsSync(binPath)) { + this.cliPath = binPath; + this.useNodeRunner = false; + } else { + this.cliPath = process.execPath; + this.useNodeRunner = true; + } const esc = String.fromCharCode(27); const csi = String.fromCharCode(155); const pattern = `(?:${esc}|${csi})[[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]`; @@ -64,17 +72,33 @@ export class ClawHubService { */ private async runCommand(args: string[]): Promise { return new Promise((resolve, reject) => { - console.log(`Running ClawHub command: ${this.cliPath} ${args.join(' ')}`); + if (this.useNodeRunner && !fs.existsSync(this.cliEntryPath)) { + reject(new Error(`ClawHub CLI entry not found at: ${this.cliEntryPath}`)); + return; + } + + if (!this.useNodeRunner && !fs.existsSync(this.cliPath)) { + reject(new Error(`ClawHub CLI not found at: ${this.cliPath}`)); + return; + } + + const commandArgs = this.useNodeRunner ? [this.cliEntryPath, ...args] : args; + const displayCommand = [this.cliPath, ...commandArgs].join(' '); + console.log(`Running ClawHub command: ${displayCommand}`); const isWin = process.platform === 'win32'; - const child = spawn(this.cliPath, args, { + const env = { + ...process.env, + CI: 'true', + FORCE_COLOR: '0', // Disable colors for easier parsing + }; + if (this.useNodeRunner) { + env.ELECTRON_RUN_AS_NODE = '1'; + } + const child = spawn(this.cliPath, commandArgs, { cwd: this.workDir, - shell: isWin, - env: { - ...process.env, - CI: 'true', - FORCE_COLOR: '0', // Disable colors for easier parsing - }, + shell: isWin && !this.useNodeRunner, + env, }); let stdout = ''; diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 21493e899..c2cc5dec8 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -21,7 +21,7 @@ import { isEncryptionAvailable, type ProviderConfig, } from '../utils/secure-storage'; -import { getOpenClawStatus, getOpenClawDir } from '../utils/paths'; +import { getOpenClawStatus, getOpenClawDir, getOpenClawConfigDir, getOpenClawSkillsDir } from '../utils/paths'; import { getOpenClawCliCommand, installOpenClawCliMac } from '../utils/openclaw-cli'; import { getSetting } from '../utils/store'; import { saveProviderKeyToOpenClaw, setOpenClawDefaultModel } from '../utils/openclaw-auth'; @@ -506,6 +506,16 @@ function registerOpenClawHandlers(): void { return getOpenClawDir(); }); + // Get the OpenClaw config directory (~/.openclaw) + ipcMain.handle('openclaw:getConfigDir', () => { + return getOpenClawConfigDir(); + }); + + // Get the OpenClaw skills directory (~/.openclaw/skills) + ipcMain.handle('openclaw:getSkillsDir', () => { + return getOpenClawSkillsDir(); + }); + // Get a shell command to run OpenClaw CLI without modifying PATH ipcMain.handle('openclaw:getCliCommand', () => { try { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 9267a14c0..f2e3eb211 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -117,6 +117,8 @@ const electronAPI = { 'log:listFiles', // OpenClaw extras 'openclaw:getDir', + 'openclaw:getConfigDir', + 'openclaw:getSkillsDir', 'openclaw:getCliCommand', 'openclaw:installCliMac', ]; diff --git a/electron/utils/paths.ts b/electron/utils/paths.ts index c308ca163..c29792897 100644 --- a/electron/utils/paths.ts +++ b/electron/utils/paths.ts @@ -25,6 +25,13 @@ export function getOpenClawConfigDir(): string { return join(homedir(), '.openclaw'); } +/** + * Get OpenClaw skills directory + */ +export function getOpenClawSkillsDir(): string { + return join(getOpenClawConfigDir(), 'skills'); +} + /** * Get ClawX config directory */ @@ -108,6 +115,21 @@ export function getOpenClawEntryPath(): string { return join(getOpenClawDir(), 'openclaw.mjs'); } +/** + * Get ClawHub CLI entry script path (clawdhub.js) + */ +export function getClawHubCliEntryPath(): string { + return join(app.getAppPath(), 'node_modules', 'clawhub', 'bin', 'clawdhub.js'); +} + +/** + * Get ClawHub CLI binary path (node_modules/.bin) + */ +export function getClawHubCliBinPath(): string { + const binName = process.platform === 'win32' ? 'clawhub.cmd' : 'clawhub'; + return join(app.getAppPath(), 'node_modules', '.bin', binName); +} + /** * Check if OpenClaw package exists */ diff --git a/src/pages/Skills/index.tsx b/src/pages/Skills/index.tsx index 96a376b8e..8bf69f31d 100644 --- a/src/pages/Skills/index.tsx +++ b/src/pages/Skills/index.tsx @@ -2,7 +2,7 @@ * Skills Page * Browse and manage AI skills */ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Search, @@ -26,6 +26,7 @@ import { Save, Key, ChevronDown, + FolderOpen, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -528,6 +529,7 @@ export function Skills() { installSkill, uninstallSkill, searching, + searchError, installing } = useSkillsStore(); const gatewayStatus = useGatewayStore((state) => state.status); @@ -536,6 +538,7 @@ export function Skills() { const [selectedSkill, setSelectedSkill] = useState(null); const [activeTab, setActiveTab] = useState('all'); const [selectedSource, setSelectedSource] = useState<'all' | 'built-in' | 'marketplace'>('all'); + const marketplaceDiscoveryAttemptedRef = useRef(false); const isGatewayRunning = gatewayStatus.state === 'running'; const [showGatewayWarning, setShowGatewayWarning] = useState(false); @@ -609,6 +612,21 @@ export function Skills() { } }, [enableSkill, disableSkill]); + const handleOpenSkillsFolder = useCallback(async () => { + try { + const skillsDir = await window.electron.ipcRenderer.invoke('openclaw:getSkillsDir') as string; + if (!skillsDir) { + throw new Error('Skills directory not available'); + } + const result = await window.electron.ipcRenderer.invoke('shell:openPath', skillsDir) as string; + if (result) { + throw new Error(result); + } + } catch (err) { + toast.error('Failed to open skills folder: ' + String(err)); + } + }, []); + // Handle marketplace search const handleMarketplaceSearch = useCallback((e: React.FormEvent) => { e.preventDefault(); @@ -632,10 +650,21 @@ export function Skills() { // Initial marketplace load (Discovery) useEffect(() => { - if (activeTab === 'marketplace' && searchResults.length === 0 && !searching) { - searchSkills(''); + if (activeTab !== 'marketplace') { + return; } - }, [activeTab, searchResults.length, searching, searchSkills]); + if (marketplaceQuery.trim()) { + return; + } + if (searching) { + return; + } + if (marketplaceDiscoveryAttemptedRef.current) { + return; + } + marketplaceDiscoveryAttemptedRef.current = true; + searchSkills(''); + }, [activeTab, marketplaceQuery, searching, searchSkills]); // Handle uninstall const handleUninstall = useCallback(async (slug: string) => { @@ -665,10 +694,16 @@ export function Skills() { Browse and manage AI capabilities

- +
+ + +
{/* Gateway Warning */} @@ -907,6 +942,15 @@ export function Skills() { + {searchError && ( + + + + ClawHub search failed. Check your connection or installation. + + + )} + {searchResults.length > 0 ? (
{searchResults.map((skill) => { diff --git a/src/stores/skills.ts b/src/stores/skills.ts index 042fa4681..9576728c9 100644 --- a/src/stores/skills.ts +++ b/src/stores/skills.ts @@ -39,6 +39,7 @@ interface SkillsState { searchResults: MarketplaceSkill[]; loading: boolean; searching: boolean; + searchError: string | null; installing: Record; // slug -> boolean error: string | null; @@ -58,6 +59,7 @@ export const useSkillsStore = create((set, get) => ({ searchResults: [], loading: false, searching: false, + searchError: null, installing: {}, error: null, @@ -145,7 +147,7 @@ export const useSkillsStore = create((set, get) => ({ }, searchSkills: async (query: string) => { - set({ searching: true, error: null }); + set({ searching: true, searchError: null }); try { const result = await window.electron.ipcRenderer.invoke('clawhub:search', { query }) as { success: boolean; results?: MarketplaceSkill[]; error?: string }; if (result.success) { @@ -154,7 +156,7 @@ export const useSkillsStore = create((set, get) => ({ throw new Error(result.error || 'Search failed'); } } catch (error) { - set({ error: String(error) }); + set({ searchError: String(error) }); } finally { set({ searching: false }); }