/** * Skills Page * Browse and manage AI skills */ import { useEffect, useState, useCallback, useRef } from 'react'; import { Search, Puzzle, Lock, Package, X, AlertCircle, Plus, Key, Trash2, RefreshCw, FolderOpen, FileCode, Globe, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { Badge } from '@/components/ui/badge'; import { Sheet, SheetContent } from '@/components/ui/sheet'; import { useSkillsStore } from '@/stores/skills'; import { useGatewayStore } from '@/stores/gateway'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { cn } from '@/lib/utils'; import { invokeIpc } from '@/lib/api-client'; import { hostApiFetch } from '@/lib/host-api'; import { trackUiEvent } from '@/lib/telemetry'; import { toast } from 'sonner'; import type { Skill } from '@/types/skill'; import { useTranslation } from 'react-i18next'; // Skill detail dialog component interface SkillDetailDialogProps { skill: Skill | null; isOpen: boolean; onClose: () => void; onToggle: (enabled: boolean) => void; onUninstall?: (slug: string) => void; } function SkillDetailDialog({ skill, isOpen, onClose, onToggle, onUninstall }: SkillDetailDialogProps) { const { t } = useTranslation('skills'); const { fetchSkills } = useSkillsStore(); const [envVars, setEnvVars] = useState>([]); const [apiKey, setApiKey] = useState(''); const [isSaving, setIsSaving] = useState(false); // Initialize config from skill useEffect(() => { if (!skill) return; // API Key if (skill.config?.apiKey) { setApiKey(String(skill.config.apiKey)); } else { setApiKey(''); } // Env Vars if (skill.config?.env) { const vars = Object.entries(skill.config.env).map(([key, value]) => ({ key, value: String(value), })); setEnvVars(vars); } else { setEnvVars([]); } }, [skill]); const handleOpenClawhub = async () => { if (!skill?.slug) return; await invokeIpc('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`); }; const handleOpenEditor = async () => { if (!skill?.id) return; try { const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/open-readme', { method: 'POST', body: JSON.stringify({ skillKey: skill.id, slug: skill.slug }), }); if (result.success) { toast.success(t('toast.openedEditor')); } else { toast.error(result.error || t('toast.failedEditor')); } } catch (err) { toast.error(t('toast.failedEditor') + ': ' + String(err)); } }; const handleAddEnv = () => { setEnvVars([...envVars, { key: '', value: '' }]); }; const handleUpdateEnv = (index: number, field: 'key' | 'value', value: string) => { const newVars = [...envVars]; newVars[index] = { ...newVars[index], [field]: value }; setEnvVars(newVars); }; const handleRemoveEnv = (index: number) => { const newVars = [...envVars]; newVars.splice(index, 1); setEnvVars(newVars); }; const handleSaveConfig = async () => { if (isSaving || !skill) return; setIsSaving(true); try { // Build env object, filtering out empty keys const envObj = envVars.reduce((acc, curr) => { const key = curr.key.trim(); const value = curr.value.trim(); if (key) { acc[key] = value; } return acc; }, {} as Record); // Use direct file access instead of Gateway RPC for reliability const result = await invokeIpc<{ success: boolean; error?: string }>( 'skill:updateConfig', { skillKey: skill.id, apiKey: apiKey || '', // Empty string will delete the key env: envObj // Empty object will clear all env vars } ) as { success: boolean; error?: string }; if (!result.success) { throw new Error(result.error || 'Unknown error'); } // Refresh skills from gateway to get updated config await fetchSkills(); toast.success(t('detail.configSaved')); } catch (err) { toast.error(t('toast.failedSave') + ': ' + String(err)); } finally { setIsSaving(false); } }; if (!skill) return null; return ( !open && onClose()}> {/* Scrollable Content */}
{skill.icon || '🔧'} {skill.isCore && (
)}

{skill.name}

v{skill.version} {skill.isCore ? t('detail.coreSystem') : skill.isBundled ? t('detail.bundled') : t('detail.userInstalled')}
{skill.description && (

{skill.description}

)}
{/* API Key Section */} {!skill.isCore && (

{t('detail.apiKey')}

setApiKey(e.target.value)} type="password" className="h-[44px] font-mono text-[13px] bg-[#eeece3] dark:bg-muted border-black/10 dark:border-white/10 rounded-xl focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40" />

{t('detail.apiKeyDesc', 'The primary API key for this skill. Leave blank if not required or configured elsewhere.')}

)} {/* Environment Variables Section */} {!skill.isCore && (

{t('detail.envVars')} {envVars.length > 0 && ( {envVars.length} )}

{envVars.length === 0 && (
{t('detail.noEnvVars', 'No environment variables configured.')}
)} {envVars.map((env, index) => (
handleUpdateEnv(index, 'key', e.target.value)} className="flex-1 h-[40px] font-mono text-[13px] bg-[#eeece3] dark:bg-muted border-black/10 dark:border-white/10 rounded-xl focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm text-foreground" placeholder={t('detail.keyPlaceholder', 'Key')} /> handleUpdateEnv(index, 'value', e.target.value)} className="flex-1 h-[40px] font-mono text-[13px] bg-[#eeece3] dark:bg-muted border-black/10 dark:border-white/10 rounded-xl focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm text-foreground" placeholder={t('detail.valuePlaceholder', 'Value')} />
))}
)} {/* External Links */} {skill.slug && !skill.isBundled && !skill.isCore && (
)}
{/* Centered Footer Buttons */}
{!skill.isCore && ( )} {!skill.isCore && ( )}
); } export function Skills() { const { skills, loading, error, fetchSkills, enableSkill, disableSkill, searchResults, searchSkills, installSkill, uninstallSkill, searching, searchError, installing } = useSkillsStore(); const { t } = useTranslation('skills'); const gatewayStatus = useGatewayStore((state) => state.status); const [searchQuery, setSearchQuery] = useState(''); const [marketplaceQuery, setMarketplaceQuery] = useState(''); 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); // Debounce the gateway warning to avoid flickering during brief restarts (like skill toggles) useEffect(() => { let timer: NodeJS.Timeout; if (!isGatewayRunning) { // Wait 1.5s before showing the warning timer = setTimeout(() => { setShowGatewayWarning(true); }, 1500); } else { // Use setTimeout to avoid synchronous setState in effect timer = setTimeout(() => { setShowGatewayWarning(false); }, 0); } return () => clearTimeout(timer); }, [isGatewayRunning]); // Fetch skills on mount useEffect(() => { if (isGatewayRunning) { fetchSkills(); } }, [fetchSkills, isGatewayRunning]); // Filter skills const safeSkills = Array.isArray(skills) ? skills : []; const filteredSkills = safeSkills.filter((skill) => { const q = searchQuery.toLowerCase().trim(); const matchesSearch = q.length === 0 || skill.name.toLowerCase().includes(q) || skill.description.toLowerCase().includes(q) || skill.id.toLowerCase().includes(q) || (skill.slug || '').toLowerCase().includes(q) || (skill.author || '').toLowerCase().includes(q); let matchesSource = true; if (selectedSource === 'built-in') { matchesSource = !!skill.isBundled; } else if (selectedSource === 'marketplace') { matchesSource = !skill.isBundled; } return matchesSearch && matchesSource; }).sort((a, b) => { // Enabled skills first if (a.enabled && !b.enabled) return -1; if (!a.enabled && b.enabled) return 1; // Then core/bundled if (a.isCore && !b.isCore) return -1; if (!a.isCore && b.isCore) return 1; // Finally alphabetical return a.name.localeCompare(b.name); }); const sourceStats = { all: safeSkills.length, builtIn: safeSkills.filter(s => s.isBundled).length, marketplace: safeSkills.filter(s => !s.isBundled).length, }; const bulkToggleVisible = useCallback(async (enable: boolean) => { const candidates = filteredSkills.filter((skill) => !skill.isCore && skill.enabled !== enable); if (candidates.length === 0) { toast.info(enable ? t('toast.noBatchEnableTargets') : t('toast.noBatchDisableTargets')); return; } let succeeded = 0; for (const skill of candidates) { try { if (enable) { await enableSkill(skill.id); } else { await disableSkill(skill.id); } succeeded += 1; } catch { // Continue to next skill and report final summary. } } trackUiEvent('skills.batch_toggle', { enable, total: candidates.length, succeeded }); if (succeeded === candidates.length) { toast.success(enable ? t('toast.batchEnabled', { count: succeeded }) : t('toast.batchDisabled', { count: succeeded })); return; } toast.warning(t('toast.batchPartial', { success: succeeded, total: candidates.length })); }, [disableSkill, enableSkill, filteredSkills, t]); // Handle toggle const handleToggle = useCallback(async (skillId: string, enable: boolean) => { try { if (enable) { await enableSkill(skillId); toast.success(t('toast.enabled')); } else { await disableSkill(skillId); toast.success(t('toast.disabled')); } } catch (err) { toast.error(String(err)); } }, [enableSkill, disableSkill, t]); const hasInstalledSkills = safeSkills.some(s => !s.isBundled); const handleOpenSkillsFolder = useCallback(async () => { try { const skillsDir = await invokeIpc('openclaw:getSkillsDir'); if (!skillsDir) { throw new Error('Skills directory not available'); } const result = await invokeIpc('shell:openPath', skillsDir); if (result) { // shell.openPath returns an error string if the path doesn't exist if (result.toLowerCase().includes('no such file') || result.toLowerCase().includes('not found') || result.toLowerCase().includes('failed to open')) { toast.error(t('toast.failedFolderNotFound')); } else { throw new Error(result); } } } catch (err) { toast.error(t('toast.failedOpenFolder') + ': ' + String(err)); } }, [t]); const [skillsDirPath, setSkillsDirPath] = useState('~/.openclaw/skills'); useEffect(() => { invokeIpc('openclaw:getSkillsDir') .then((dir) => setSkillsDirPath(dir as string)) .catch(console.error); }, []); // Auto-reset when query is cleared useEffect(() => { if (activeTab === 'marketplace' && marketplaceQuery === '' && marketplaceDiscoveryAttemptedRef.current) { searchSkills(''); } }, [marketplaceQuery, activeTab, searchSkills]); // Handle install const handleInstall = useCallback(async (slug: string) => { try { await installSkill(slug); // Automatically enable after install // We need to find the skill id which is usually the slug await enableSkill(slug); toast.success(t('toast.installed')); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); if (['installTimeoutError', 'installRateLimitError'].includes(errorMessage)) { toast.error(t(`toast.${errorMessage}`, { path: skillsDirPath }), { duration: 10000 }); } else { toast.error(t('toast.failedInstall') + ': ' + errorMessage); } } }, [installSkill, enableSkill, t, skillsDirPath]); // Initial marketplace load (Discovery) useEffect(() => { if (activeTab !== 'marketplace') { return; } 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) => { try { await uninstallSkill(slug); toast.success(t('toast.uninstalled')); } catch (err) { toast.error(t('toast.failedUninstall') + ': ' + String(err)); } }, [uninstallSkill, t]); if (loading) { return (
); } return (
{/* Header */}

{t('title')}

{t('subtitle')}

{hasInstalledSkills && ( )}
{/* Gateway Warning */} {showGatewayWarning && (
{t('gatewayWarning')}
)} {/* Sub Navigation and Actions */}
activeTab === 'marketplace' ? setMarketplaceQuery(e.target.value) : setSearchQuery(e.target.value)} className="ml-2 bg-transparent outline-none w-24 focus:w-40 md:focus:w-56 transition-all font-normal placeholder:text-foreground/50 text-[13px] text-foreground" /> {((activeTab === 'marketplace' && marketplaceQuery) || (activeTab === 'all' && searchQuery)) && ( )}
{activeTab === 'all' && ( <> )}
{/* Content Area */}
{error && activeTab === 'all' && (
{['fetchTimeoutError', 'fetchRateLimitError', 'timeoutError', 'rateLimitError'].includes(error) ? t(`toast.${error}`, { path: skillsDirPath }) : error}
)}
{activeTab === 'all' && ( filteredSkills.length === 0 ? (

{searchQuery ? t('noSkillsSearch') : t('noSkillsAvailable')}

) : ( filteredSkills.map((skill) => (
setSelectedSkill(skill)} >
{skill.icon || '🧩'}

{skill.name}

{skill.isCore ? ( ) : skill.isBundled ? ( ) : null} {skill.slug && skill.slug !== skill.name ? ( {skill.slug} ) : null}

{skill.description}

e.stopPropagation()}> {skill.version && ( v{skill.version} )} handleToggle(skill.id, checked)} disabled={skill.isCore} />
)) ) )} {activeTab === 'marketplace' && (
{searchError && (
{['searchTimeoutError', 'searchRateLimitError', 'timeoutError', 'rateLimitError'].includes(searchError.replace('Error: ', '')) ? t(`toast.${searchError.replace('Error: ', '')}`, { path: skillsDirPath }) : t('marketplace.searchError')}
)} {activeTab === 'marketplace' && marketplaceQuery && searching && (

{t('marketplace.searching')}

)} {searchResults.length > 0 ? ( searchResults.map((skill) => { const isInstalled = safeSkills.some(s => s.id === skill.slug || s.name === skill.name); const isInstallLoading = !!installing[skill.slug]; return (
invokeIpc('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`)} >
📦

{skill.name}

{skill.author && ( • {skill.author} )}

{skill.description}

e.stopPropagation()}> {skill.version && ( v{skill.version} )} {isInstalled ? ( ) : ( )}
); }) ) : ( !searching && marketplaceQuery && (

{t('marketplace.noResults')}

) )}
)}
{/* Skill Detail Dialog */} setSelectedSkill(null)} onToggle={(enabled) => { if (!selectedSkill) return; handleToggle(selectedSkill.id, enabled); setSelectedSkill({ ...selectedSkill, enabled }); }} onUninstall={handleUninstall} />
); } export default Skills;