feat: enhance ClawHub integration with new CLI paths and IPC handlers for config and skills directories (#41)

This commit is contained in:
Felix
2026-02-11 11:32:03 +08:00
committed by GitHub
Unverified
parent 177cf4c1ea
commit a0505490cd
6 changed files with 129 additions and 25 deletions

View File

@@ -6,7 +6,7 @@ import { spawn } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { app, shell } from 'electron'; import { app, shell } from 'electron';
import { getOpenClawConfigDir, ensureDir } from '../utils/paths'; import { getOpenClawConfigDir, ensureDir, getClawHubCliBinPath, getClawHubCliEntryPath } from '../utils/paths';
export interface ClawHubSearchParams { export interface ClawHubSearchParams {
query: string; query: string;
@@ -36,6 +36,8 @@ export interface ClawHubSkillResult {
export class ClawHubService { export class ClawHubService {
private workDir: string; private workDir: string;
private cliPath: string; private cliPath: string;
private cliEntryPath: string;
private useNodeRunner: boolean;
private ansiRegex: RegExp; private ansiRegex: RegExp;
constructor() { constructor() {
@@ -44,11 +46,17 @@ export class ClawHubService {
this.workDir = getOpenClawConfigDir(); this.workDir = getOpenClawConfigDir();
ensureDir(this.workDir); ensureDir(this.workDir);
// In development, we use the locally installed clawhub CLI from node_modules const binPath = getClawHubCliBinPath();
const isWin = process.platform === 'win32'; const entryPath = getClawHubCliEntryPath();
const binName = isWin ? 'clawhub.cmd' : 'clawhub';
const localCli = path.resolve(app.getAppPath(), 'node_modules', '.bin', binName); this.cliEntryPath = entryPath;
this.cliPath = localCli; 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 esc = String.fromCharCode(27);
const csi = String.fromCharCode(155); const csi = String.fromCharCode(155);
const pattern = `(?:${esc}|${csi})[[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]`; 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<string> { private async runCommand(args: string[]): Promise<string> {
return new Promise((resolve, reject) => { 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 isWin = process.platform === 'win32';
const child = spawn(this.cliPath, args, { const env = {
cwd: this.workDir,
shell: isWin,
env: {
...process.env, ...process.env,
CI: 'true', CI: 'true',
FORCE_COLOR: '0', // Disable colors for easier parsing 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 && !this.useNodeRunner,
env,
}); });
let stdout = ''; let stdout = '';

View File

@@ -21,7 +21,7 @@ import {
isEncryptionAvailable, isEncryptionAvailable,
type ProviderConfig, type ProviderConfig,
} from '../utils/secure-storage'; } 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 { 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';
@@ -506,6 +506,16 @@ function registerOpenClawHandlers(): void {
return getOpenClawDir(); 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 // Get a shell command to run OpenClaw CLI without modifying PATH
ipcMain.handle('openclaw:getCliCommand', () => { ipcMain.handle('openclaw:getCliCommand', () => {
try { try {

View File

@@ -117,6 +117,8 @@ const electronAPI = {
'log:listFiles', 'log:listFiles',
// OpenClaw extras // OpenClaw extras
'openclaw:getDir', 'openclaw:getDir',
'openclaw:getConfigDir',
'openclaw:getSkillsDir',
'openclaw:getCliCommand', 'openclaw:getCliCommand',
'openclaw:installCliMac', 'openclaw:installCliMac',
]; ];

View File

@@ -25,6 +25,13 @@ export function getOpenClawConfigDir(): string {
return join(homedir(), '.openclaw'); return join(homedir(), '.openclaw');
} }
/**
* Get OpenClaw skills directory
*/
export function getOpenClawSkillsDir(): string {
return join(getOpenClawConfigDir(), 'skills');
}
/** /**
* Get ClawX config directory * Get ClawX config directory
*/ */
@@ -108,6 +115,21 @@ export function getOpenClawEntryPath(): string {
return join(getOpenClawDir(), 'openclaw.mjs'); 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 * Check if OpenClaw package exists
*/ */

View File

@@ -2,7 +2,7 @@
* Skills Page * Skills Page
* Browse and manage AI skills * 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 { motion, AnimatePresence } from 'framer-motion';
import { import {
Search, Search,
@@ -26,6 +26,7 @@ import {
Save, Save,
Key, Key,
ChevronDown, ChevronDown,
FolderOpen,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -528,6 +529,7 @@ export function Skills() {
installSkill, installSkill,
uninstallSkill, uninstallSkill,
searching, searching,
searchError,
installing installing
} = useSkillsStore(); } = useSkillsStore();
const gatewayStatus = useGatewayStore((state) => state.status); const gatewayStatus = useGatewayStore((state) => state.status);
@@ -536,6 +538,7 @@ export function Skills() {
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null); const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
const [activeTab, setActiveTab] = useState('all'); const [activeTab, setActiveTab] = useState('all');
const [selectedSource, setSelectedSource] = useState<'all' | 'built-in' | 'marketplace'>('all'); const [selectedSource, setSelectedSource] = useState<'all' | 'built-in' | 'marketplace'>('all');
const marketplaceDiscoveryAttemptedRef = useRef(false);
const isGatewayRunning = gatewayStatus.state === 'running'; const isGatewayRunning = gatewayStatus.state === 'running';
const [showGatewayWarning, setShowGatewayWarning] = useState(false); const [showGatewayWarning, setShowGatewayWarning] = useState(false);
@@ -609,6 +612,21 @@ export function Skills() {
} }
}, [enableSkill, disableSkill]); }, [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 // Handle marketplace search
const handleMarketplaceSearch = useCallback((e: React.FormEvent) => { const handleMarketplaceSearch = useCallback((e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -632,10 +650,21 @@ export function Skills() {
// Initial marketplace load (Discovery) // Initial marketplace load (Discovery)
useEffect(() => { useEffect(() => {
if (activeTab === 'marketplace' && searchResults.length === 0 && !searching) { if (activeTab !== 'marketplace') {
searchSkills(''); 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 // Handle uninstall
const handleUninstall = useCallback(async (slug: string) => { const handleUninstall = useCallback(async (slug: string) => {
@@ -665,10 +694,16 @@ export function Skills() {
Browse and manage AI capabilities Browse and manage AI capabilities
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchSkills} disabled={!isGatewayRunning}> <Button variant="outline" onClick={fetchSkills} disabled={!isGatewayRunning}>
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
Refresh Refresh
</Button> </Button>
<Button variant="outline" onClick={handleOpenSkillsFolder}>
<FolderOpen className="h-4 w-4 mr-2" />
Open Skills Folder
</Button>
</div>
</div> </div>
{/* Gateway Warning */} {/* Gateway Warning */}
@@ -907,6 +942,15 @@ export function Skills() {
</form> </form>
</div> </div>
{searchError && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="py-3 text-sm text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<span>ClawHub search failed. Check your connection or installation.</span>
</CardContent>
</Card>
)}
{searchResults.length > 0 ? ( {searchResults.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{searchResults.map((skill) => { {searchResults.map((skill) => {

View File

@@ -39,6 +39,7 @@ interface SkillsState {
searchResults: MarketplaceSkill[]; searchResults: MarketplaceSkill[];
loading: boolean; loading: boolean;
searching: boolean; searching: boolean;
searchError: string | null;
installing: Record<string, boolean>; // slug -> boolean installing: Record<string, boolean>; // slug -> boolean
error: string | null; error: string | null;
@@ -58,6 +59,7 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
searchResults: [], searchResults: [],
loading: false, loading: false,
searching: false, searching: false,
searchError: null,
installing: {}, installing: {},
error: null, error: null,
@@ -145,7 +147,7 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
}, },
searchSkills: async (query: string) => { searchSkills: async (query: string) => {
set({ searching: true, error: null }); set({ searching: true, searchError: null });
try { try {
const result = await window.electron.ipcRenderer.invoke('clawhub:search', { query }) as { success: boolean; results?: MarketplaceSkill[]; error?: string }; const result = await window.electron.ipcRenderer.invoke('clawhub:search', { query }) as { success: boolean; results?: MarketplaceSkill[]; error?: string };
if (result.success) { if (result.success) {
@@ -154,7 +156,7 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
throw new Error(result.error || 'Search failed'); throw new Error(result.error || 'Search failed');
} }
} catch (error) { } catch (error) {
set({ error: String(error) }); set({ searchError: String(error) });
} finally { } finally {
set({ searching: false }); set({ searching: false });
} }