feat: enhance ClawHub integration with new CLI paths and IPC handlers for config and skills directories (#41)
This commit is contained in:
@@ -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 = '';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user