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