mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 10:47:44 +02:00
[eric] marketplace: claude.ai-style Directory for skills + connectors, with upload-skill and custom-connector modals
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import React, { useState } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import Link from '@mui/material/Link';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { createTool, discoverTools } from '@/shared/state/toolsSlice';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** "get started with pre-built ones" jumps into the Directory's Connectors tab. */
|
||||
onBrowsePrebuilt?: () => void;
|
||||
onAdded?: (message: string, severity: 'success' | 'error') => void;
|
||||
}
|
||||
|
||||
const fieldSx = (c: ReturnType<typeof useClaudeTokens>) => ({
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: c.bg.surface, borderRadius: `${c.radius.md}px`, fontSize: '0.9375rem',
|
||||
'& input': { py: 1.3 },
|
||||
'& fieldset': { borderColor: c.border.medium },
|
||||
'&:hover fieldset': { borderColor: c.border.strong },
|
||||
'&.Mui-focused fieldset': { borderColor: c.border.strong, borderWidth: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
// claude.ai's Add custom connector modal, phrasing kept identical; Add wires a remote http MCP tool
|
||||
// and runs discovery immediately.
|
||||
const AddCustomConnectorDialog: React.FC<Props> = ({ open, onClose, onBrowsePrebuilt, onAdded }) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [clientSecret, setClientSecret] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const reset = () => { setName(''); setUrl(''); setClientId(''); setClientSecret(''); setAdvancedOpen(false); };
|
||||
|
||||
const handleAdd = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const credentials: Record<string, string> = {};
|
||||
if (clientId.trim()) credentials.oauth_client_id = clientId.trim();
|
||||
if (clientSecret.trim()) credentials.oauth_client_secret = clientSecret.trim();
|
||||
const result = await dispatch(createTool({
|
||||
name: name.trim(),
|
||||
description: '',
|
||||
command: '',
|
||||
mcp_config: { type: 'http', url: url.trim() },
|
||||
credentials,
|
||||
auth_type: 'none',
|
||||
auth_status: 'configured',
|
||||
}));
|
||||
if (!createTool.fulfilled.match(result)) {
|
||||
onAdded?.(`Could not add ${name.trim()}`, 'error');
|
||||
return;
|
||||
}
|
||||
const discovered = await dispatch(discoverTools(result.payload.id));
|
||||
if (discoverTools.fulfilled.match(discovered)) {
|
||||
onAdded?.(`${name.trim()} connected, tools discovered`, 'success');
|
||||
} else {
|
||||
const detail = (discovered as { error?: { message?: string } }).error?.message || 'discovery failed';
|
||||
onAdded?.(`${name.trim()}: ${detail}`, 'error');
|
||||
}
|
||||
reset();
|
||||
onClose();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{ sx: { bgcolor: c.bg.surface, backgroundImage: 'none', borderRadius: '16px', border: `1px solid ${c.border.subtle}`, boxShadow: c.shadow.lg, p: 3.5 } }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<Typography sx={{ fontSize: '1.5rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.2 }}>
|
||||
Add custom connector
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={onClose} sx={{ color: c.text.tertiary, mt: -0.5, mr: -1, '&:hover': { color: c.text.primary } }}>
|
||||
<CloseIcon sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Typography sx={{ fontSize: '0.9375rem', color: c.text.secondary, mt: 1, lineHeight: 1.55 }}>
|
||||
Connect Claude to your data and tools.{' '}
|
||||
<Link href="https://modelcontextprotocol.io" target="_blank" rel="noreferrer" sx={{ color: c.text.secondary, textDecorationColor: c.text.tertiary }}>
|
||||
Learn more about connectors
|
||||
</Link>{' '}
|
||||
or get started with{' '}
|
||||
<Link component="button" type="button" onClick={() => { onClose(); onBrowsePrebuilt?.(); }} sx={{ color: c.text.secondary, textDecorationColor: c.text.tertiary, verticalAlign: 'baseline' }}>
|
||||
pre-built ones
|
||||
</Link>.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mt: 2.5 }}>
|
||||
<TextField placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} fullWidth sx={fieldSx(c)} />
|
||||
<TextField placeholder="Remote MCP server URL" value={url} onChange={(e) => setUrl(e.target.value)} fullWidth sx={fieldSx(c)} />
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
role="button"
|
||||
onClick={() => setAdvancedOpen((v) => !v)}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 2.5, cursor: 'pointer', userSelect: 'none', width: 'fit-content' }}
|
||||
>
|
||||
{advancedOpen ? <KeyboardArrowUpIcon sx={{ fontSize: 18, color: c.text.secondary }} /> : <KeyboardArrowDownIcon sx={{ fontSize: 18, color: c.text.secondary }} />}
|
||||
<Typography sx={{ fontSize: '0.9375rem', fontWeight: 600, color: c.text.primary }}>Advanced settings</Typography>
|
||||
</Box>
|
||||
<Collapse in={advancedOpen}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mt: 1.5 }}>
|
||||
<TextField placeholder="OAuth Client ID (optional)" value={clientId} onChange={(e) => setClientId(e.target.value)} fullWidth sx={fieldSx(c)} />
|
||||
<TextField placeholder="OAuth Client Secret (optional)" value={clientSecret} onChange={(e) => setClientSecret(e.target.value)} fullWidth sx={fieldSx(c)} />
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
<Typography sx={{ fontSize: '0.875rem', color: c.text.tertiary, mt: 2.5, lineHeight: 1.55 }}>
|
||||
Only use connectors from developers you trust. Anthropic does not control which tools developers make available and cannot verify that they will work as intended or that they won't change.
|
||||
</Typography>
|
||||
|
||||
<Typography sx={{ fontSize: '0.875rem', color: c.text.tertiary, mt: 1.5 }}>
|
||||
Building an MCP server?{' '}
|
||||
<Link href="https://github.com/modelcontextprotocol/modelcontextprotocol/issues" target="_blank" rel="noreferrer" sx={{ color: c.text.secondary, textDecorationColor: c.text.tertiary }}>
|
||||
Report issues and subscribe to updates here
|
||||
</Link>
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 3 }}>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
textTransform: 'none', fontWeight: 600, fontSize: '0.9375rem', px: 2.25, py: 0.75,
|
||||
color: c.text.primary, border: `1px solid ${c.border.medium}`, borderRadius: `${c.radius.md}px`,
|
||||
'&:hover': { bgcolor: c.bg.secondary, borderColor: c.border.strong },
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => { void handleAdd(); }}
|
||||
disabled={!name.trim() || !url.trim() || busy}
|
||||
sx={{
|
||||
textTransform: 'none', fontWeight: 600, fontSize: '0.9375rem', px: 2.5, py: 0.75,
|
||||
color: c.bg.surface, bgcolor: c.text.primary, borderRadius: `${c.radius.md}px`,
|
||||
'&:hover': { bgcolor: c.text.secondary },
|
||||
'&.Mui-disabled': { bgcolor: c.border.medium, color: c.bg.surface },
|
||||
}}
|
||||
>
|
||||
{busy ? 'Adding…' : 'Add'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddCustomConnectorDialog;
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { installCommunitySkill, CommunitySkill, InstallDisclosure } from '@/shared/state/skillRegistrySlice';
|
||||
|
||||
interface Props {
|
||||
skill: CommunitySkill | null;
|
||||
onClose: () => void;
|
||||
onInstalled: (name: string) => void;
|
||||
}
|
||||
|
||||
// The Directory's community-install gate: skills.sh code is unvetted, so the + never installs blind.
|
||||
// Same disclosure contract as the old CommunitySkillsDialog: files + scripts shown before anything lands.
|
||||
const CommunityInstallConfirm: React.FC<Props> = ({ skill, onClose, onInstalled }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [disclosure, setDisclosure] = useState<InstallDisclosure | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const previewSeq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setDisclosure(null);
|
||||
setError(null);
|
||||
if (!skill) return;
|
||||
const seq = ++previewSeq.current;
|
||||
setBusy(true);
|
||||
void installCommunitySkill(skill.source, skill.skillId, false)
|
||||
.then((res) => { if (seq === previewSeq.current) setDisclosure(res.disclosure); })
|
||||
.catch((e: unknown) => { if (seq === previewSeq.current) setError(e instanceof Error ? e.message : 'Could not load skill'); })
|
||||
.finally(() => { if (seq === previewSeq.current) setBusy(false); });
|
||||
}, [skill]);
|
||||
|
||||
const confirmInstall = async () => {
|
||||
if (!skill) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await installCommunitySkill(skill.source, skill.skillId, true);
|
||||
onInstalled(disclosure?.name || skill.name);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Install failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!skill} onClose={onClose} maxWidth="sm" fullWidth
|
||||
PaperProps={{ sx: { bgcolor: c.bg.surface, backgroundImage: 'none', borderRadius: '14px', border: `1px solid ${c.border.subtle}` } }}>
|
||||
<DialogTitle sx={{ color: c.text.primary, fontSize: '1.0625rem', fontWeight: 700, pb: 0.5 }}>
|
||||
Install community skill
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 1, minHeight: 180 }}>
|
||||
{error && <Alert severity="error" sx={{ fontSize: '0.8125rem' }}>{error}</Alert>}
|
||||
{busy && !disclosure && <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress size={22} /></Box>}
|
||||
{disclosure && (
|
||||
<>
|
||||
<Typography sx={{ fontSize: '1rem', fontWeight: 700, color: c.text.primary }}>{disclosure.name}</Typography>
|
||||
{disclosure.description && (
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: c.text.secondary }}>{disclosure.description}</Typography>
|
||||
)}
|
||||
<Box
|
||||
component="a" href={disclosure.repo_url} target="_blank" rel="noreferrer"
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, fontSize: '0.75rem', color: c.accent.primary, textDecoration: 'none', fontFamily: c.font.mono }}>
|
||||
{skill?.source} <OpenInNewIcon sx={{ fontSize: 13 }} />
|
||||
</Box>
|
||||
|
||||
<Alert severity="info" icon={<WarningAmberIcon fontSize="small" />} sx={{ fontSize: '0.75rem', py: 0 }}>
|
||||
This is an unvetted community skill. Its SKILL.md becomes instructions your agent will follow, and it can use your agent's tools (files, browser, settings). Only install from a source you trust, read it below first.
|
||||
</Alert>
|
||||
|
||||
{disclosure.secret_findings.length > 0 && (
|
||||
<Alert severity="error" icon={<WarningAmberIcon fontSize="small" />} sx={{ fontSize: '0.75rem', py: 0 }}>
|
||||
{disclosure.secret_findings.length} file{disclosure.secret_findings.length === 1 ? '' : 's'} contain secret-shaped text ({disclosure.secret_findings.slice(0, 3).join(', ')}{disclosure.secret_findings.length > 3 ? '…' : ''}). A trustworthy skill shouldn't ship credentials; treat this as a red flag.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{disclosure.has_scripts && (
|
||||
<Alert severity="warning" icon={<WarningAmberIcon fontSize="small" />} sx={{ fontSize: '0.75rem', py: 0 }}>
|
||||
Includes {disclosure.scripts.length} script file{disclosure.scripts.length === 1 ? '' : 's'} that can run code when an agent uses this skill. Installing only writes the files; nothing runs until an agent does, and that still goes through normal command approval.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography sx={{ fontSize: '0.75rem', color: c.text.tertiary, mt: 0.5 }}>
|
||||
{disclosure.files.length} file{disclosure.files.length === 1 ? '' : 's'} will be installed:
|
||||
</Typography>
|
||||
<Box sx={{ maxHeight: 120, overflow: 'auto', border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.sm}px`, p: 1 }}>
|
||||
{disclosure.files.map((f) => (
|
||||
<Typography key={f} sx={{ fontSize: '0.75rem', fontFamily: c.font.mono, color: disclosure.scripts.includes(f) ? c.status.warning : c.text.secondary }}>
|
||||
{disclosure.scripts.includes(f) ? '⚙ ' : ''}{f}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} sx={{ textTransform: 'none', color: c.text.tertiary }}>Cancel</Button>
|
||||
{disclosure && (
|
||||
<Button onClick={() => { void confirmInstall(); }} disabled={busy} variant="contained"
|
||||
sx={{ textTransform: 'none', bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.pressed } }}>
|
||||
{busy ? 'Installing…' : 'Install skill'}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommunityInstallConfirm;
|
||||
@@ -0,0 +1,190 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import VerifiedIcon from '@mui/icons-material/Verified';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { fetchTools } from '@/shared/state/toolsSlice';
|
||||
import { INTEGRATIONS, Integration } from '../Tools/integrations';
|
||||
import { installIntegration } from '../Tools/installIntegration';
|
||||
import DirectoryFilterBar from './DirectoryFilterBar';
|
||||
|
||||
interface Props {
|
||||
onOpenInstalled?: (toolId: string) => void;
|
||||
}
|
||||
|
||||
const POPULAR_IDS = ['google-workspace', 'slack', 'notion'];
|
||||
|
||||
// Vetted integrations only, per the MCP-surface rule: the Directory never lists arbitrary
|
||||
// community MCP servers, so every card here carries the verified mark honestly.
|
||||
const DirectoryConnectorsTab: React.FC<Props> = ({ onOpenInstalled }) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const tools = useAppSelector((s) => s.tools.items);
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [sort, setSort] = useState('popular');
|
||||
const [installingId, setInstallingId] = useState<string | null>(null);
|
||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ open: false, message: '', severity: 'success' });
|
||||
|
||||
useEffect(() => { dispatch(fetchTools()); }, [dispatch]);
|
||||
|
||||
const installedToolByName = useMemo(() => {
|
||||
const m: Record<string, { id: string }> = {};
|
||||
for (const t of Object.values(tools)) m[t.name] = { id: t.id };
|
||||
return m;
|
||||
}, [tools]);
|
||||
|
||||
const list = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
let out = INTEGRATIONS.filter((ig) =>
|
||||
!q || ig.name.toLowerCase().includes(q) || ig.description.toLowerCase().includes(q));
|
||||
if (filter === 'installed') out = out.filter((ig) => !!installedToolByName[ig.name]);
|
||||
else if (filter === 'not-installed') out = out.filter((ig) => !installedToolByName[ig.name]);
|
||||
if (sort === 'name') out = [...out].sort((a, b) => a.name.localeCompare(b.name));
|
||||
return out;
|
||||
}, [query, filter, sort, installedToolByName]);
|
||||
|
||||
const popular = useMemo(
|
||||
() => POPULAR_IDS.map((id) => INTEGRATIONS.find((ig) => ig.id === id)).filter((ig): ig is Integration => !!ig),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleInstall = async (ig: Integration) => {
|
||||
setInstallingId(ig.id);
|
||||
try {
|
||||
const res = await installIntegration(dispatch, ig);
|
||||
setSnackbar({ open: true, message: res.message, severity: res.severity });
|
||||
} finally {
|
||||
setInstallingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const actionFor = (ig: Integration) => {
|
||||
const installed = installedToolByName[ig.name];
|
||||
if (installingId === ig.id) return <CircularProgress size={18} sx={{ color: c.text.tertiary, m: 0.5 }} />;
|
||||
if (installed) {
|
||||
return (
|
||||
<IconButton size="small" onClick={() => onOpenInstalled?.(installed.id)} sx={{ color: c.status.success, '&:hover': { color: c.text.primary } }}>
|
||||
<CheckCircleIcon sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<IconButton size="small" onClick={() => { void handleInstall(ig); }} sx={{ color: c.text.secondary, '&:hover': { color: c.text.primary, bgcolor: c.bg.secondary } }}>
|
||||
<AddIcon sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
const iconBox = (ig: Integration, size: number) => (
|
||||
<Box sx={{
|
||||
width: size, height: size, borderRadius: `${Math.round(size * 0.28)}px`, flexShrink: 0,
|
||||
border: `1px solid ${c.border.subtle}`, bgcolor: c.bg.surface,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{ig.icon}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, height: '100%', minHeight: 0 }}>
|
||||
<DirectoryFilterBar
|
||||
searchPlaceholder="Search connectors..."
|
||||
chipLabel="Anthropic & Partners"
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
filterOptions={[
|
||||
{ value: 'all', label: 'All connectors' },
|
||||
{ value: 'installed', label: 'Installed' },
|
||||
{ value: 'not-installed', label: 'Not installed' },
|
||||
]}
|
||||
filterValue={filter}
|
||||
onFilter={setFilter}
|
||||
sortOptions={[
|
||||
{ value: 'popular', label: 'Popular' },
|
||||
{ value: 'name', label: 'Name' },
|
||||
]}
|
||||
sortValue={sort}
|
||||
onSort={setSort}
|
||||
/>
|
||||
|
||||
<Box sx={{
|
||||
flex: 1, minHeight: 0, overflow: 'auto', pr: 0.5,
|
||||
'&::-webkit-scrollbar': { width: 6 },
|
||||
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 3 },
|
||||
}}>
|
||||
{!query.trim() && filter === 'all' && (
|
||||
<>
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, letterSpacing: '0.06em', color: c.text.tertiary, textTransform: 'uppercase', mb: 1.25 }}>
|
||||
Popular
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 1.5, mb: 2.5 }}>
|
||||
{popular.map((ig) => (
|
||||
<Box key={ig.id} sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.25, px: 1.75, py: 1.25,
|
||||
border: `1px solid ${c.border.subtle}`, borderRadius: '12px', bgcolor: c.bg.surface,
|
||||
transition: 'border-color 0.12s, box-shadow 0.12s',
|
||||
'&:hover': { borderColor: c.border.medium, boxShadow: c.shadow.sm },
|
||||
}}>
|
||||
{iconBox(ig, 30)}
|
||||
<Typography noWrap sx={{ fontSize: '0.9375rem', fontWeight: 600, color: c.text.primary, flex: 1 }}>{ig.name}</Typography>
|
||||
{actionFor(ig)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.75, alignContent: 'start' }}>
|
||||
{list.length === 0 ? (
|
||||
<Box sx={{ gridColumn: '1 / -1', display: 'flex', justifyContent: 'center', pt: 8 }}>
|
||||
<Typography sx={{ fontSize: '0.875rem', color: c.text.ghost }}>No connectors match your search.</Typography>
|
||||
</Box>
|
||||
) : list.map((ig) => (
|
||||
<Box key={ig.id} sx={{
|
||||
border: `1px solid ${c.border.subtle}`, borderRadius: '14px', p: 2.25,
|
||||
bgcolor: c.bg.surface, display: 'flex', gap: 1.5,
|
||||
transition: 'border-color 0.12s, box-shadow 0.12s',
|
||||
'&:hover': { borderColor: c.border.medium, boxShadow: c.shadow.sm },
|
||||
}}>
|
||||
{iconBox(ig, 44)}
|
||||
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Typography noWrap sx={{ fontSize: '1rem', fontWeight: 700, color: c.text.primary }}>{ig.name}</Typography>
|
||||
<VerifiedIcon sx={{ fontSize: 15, color: c.text.tertiary, flexShrink: 0 }} />
|
||||
</Box>
|
||||
<Typography sx={{
|
||||
fontSize: '0.875rem', color: c.text.secondary, lineHeight: 1.5,
|
||||
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
||||
}}>
|
||||
{ig.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flexShrink: 0, alignSelf: 'flex-start' }}>{actionFor(ig)}</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackbar((p) => ({ ...p, open: false }))}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity={snackbar.severity} onClose={() => setSnackbar((p) => ({ ...p, open: false }))} sx={{ fontSize: '0.8125rem' }}>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DirectoryConnectorsTab;
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
|
||||
import GridViewOutlinedIcon from '@mui/icons-material/GridViewOutlined';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import DirectorySkillsTab from './DirectorySkillsTab';
|
||||
import DirectoryConnectorsTab from './DirectoryConnectorsTab';
|
||||
|
||||
export type DirectoryTab = 'skills' | 'connectors';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initialTab?: DirectoryTab;
|
||||
onClose: () => void;
|
||||
/** Installed-item affordance: jump to the item's management surface (Skills / Tools settings tab). */
|
||||
onOpenInstalledSkill?: (skillId: string) => void;
|
||||
onOpenInstalledConnector?: (toolId: string) => void;
|
||||
}
|
||||
|
||||
// The claude.ai Directory, one for one: serif title, left rail with Skills + Connectors (no Plugins,
|
||||
// deliberately), search + filter chrome per tab, and a two-column card grid.
|
||||
const DirectoryDialog: React.FC<Props> = ({ open, initialTab = 'skills', onClose, onOpenInstalledSkill, onOpenInstalledConnector }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [tab, setTab] = useState<DirectoryTab>(initialTab);
|
||||
useEffect(() => { if (open) setTab(initialTab); }, [open, initialTab]);
|
||||
|
||||
const railRow = (value: DirectoryTab, label: string, icon: React.ReactNode) => {
|
||||
const selected = tab === value;
|
||||
return (
|
||||
<Box
|
||||
role="button"
|
||||
onClick={() => setTab(value)}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.25, px: 1.5, py: 1,
|
||||
borderRadius: `${c.radius.md}px`, cursor: 'pointer', userSelect: 'none',
|
||||
bgcolor: selected ? c.bg.secondary : 'transparent',
|
||||
transition: 'background 0.12s',
|
||||
'&:hover': { bgcolor: selected ? c.bg.secondary : c.bg.elevated },
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<Typography sx={{ fontSize: '0.9375rem', fontWeight: 600, color: c.text.primary }}>{label}</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth={false}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: 'min(1180px, calc(100vw - 64px))', height: 'min(820px, calc(100vh - 64px))',
|
||||
bgcolor: c.bg.surface, backgroundImage: 'none', borderRadius: '16px',
|
||||
border: `1px solid ${c.border.subtle}`, boxShadow: c.shadow.lg,
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', px: 3.5, pt: 3, pb: 1.5, flexShrink: 0 }}>
|
||||
<Typography sx={{ fontSize: '1.75rem', fontWeight: 600, color: c.text.primary, fontFamily: 'Georgia, "Times New Roman", serif', lineHeight: 1.15 }}>
|
||||
Directory
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={onClose} sx={{ color: c.text.tertiary, mt: -0.5, mr: -1, '&:hover': { color: c.text.primary } }}>
|
||||
<CloseIcon sx={{ fontSize: 22 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
<Box sx={{ width: 220, minWidth: 220, px: 2, pt: 0.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{railRow('skills', 'Skills', <DescriptionOutlinedIcon sx={{ fontSize: 19, color: c.text.secondary }} />)}
|
||||
{railRow('connectors', 'Connectors', <GridViewOutlinedIcon sx={{ fontSize: 19, color: c.text.secondary }} />)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', pr: 3.5, pl: 1, pb: 3 }}>
|
||||
{tab === 'skills' ? (
|
||||
<DirectorySkillsTab onOpenInstalled={onOpenInstalledSkill} />
|
||||
) : (
|
||||
<DirectoryConnectorsTab onOpenInstalled={onOpenInstalledConnector} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DirectoryDialog;
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
export interface PickerOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface PickerProps {
|
||||
label: string;
|
||||
options: PickerOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const DropdownPill: React.FC<PickerProps> = ({ label, options, value, onChange }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [anchor, setAnchor] = useState<null | HTMLElement>(null);
|
||||
const active = options.find((o) => o.value === value);
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={(e: React.MouseEvent<HTMLElement>) => setAnchor(e.currentTarget)}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 0.75, px: 1.75, py: 0.9,
|
||||
borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.medium}`,
|
||||
cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap',
|
||||
'&:hover': { borderColor: c.border.strong, bgcolor: c.bg.elevated },
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.9375rem', color: c.text.primary }}>
|
||||
{active && active.value !== options[0].value ? active.label : label}
|
||||
</Typography>
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: 18, color: c.text.tertiary }} />
|
||||
</Box>
|
||||
<Menu
|
||||
anchorEl={anchor}
|
||||
open={!!anchor}
|
||||
onClose={() => setAnchor(null)}
|
||||
PaperProps={{ sx: { bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, mt: 0.5, minWidth: 170 } }}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<MenuItem
|
||||
key={o.value}
|
||||
onClick={() => { onChange(o.value); setAnchor(null); }}
|
||||
sx={{ fontSize: '0.875rem', color: c.text.primary, gap: 1, '&:hover': { bgcolor: c.bg.secondary } }}
|
||||
>
|
||||
<Box sx={{ width: 18, display: 'flex' }}>{o.value === value && <CheckIcon sx={{ fontSize: 16, color: c.text.secondary }} />}</Box>
|
||||
{o.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
searchPlaceholder: string;
|
||||
chipLabel: string;
|
||||
query: string;
|
||||
onQuery: (q: string) => void;
|
||||
filterOptions: PickerOption[];
|
||||
filterValue: string;
|
||||
onFilter: (v: string) => void;
|
||||
sortOptions: PickerOption[];
|
||||
sortValue: string;
|
||||
onSort: (v: string) => void;
|
||||
}
|
||||
|
||||
// The Directory's search row + chip/filter row, shared by both tabs (same chrome on claude.ai).
|
||||
const DirectoryFilterBar: React.FC<Props> = ({
|
||||
searchPlaceholder, chipLabel, query, onQuery,
|
||||
filterOptions, filterValue, onFilter, sortOptions, sortValue, onSort,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.75, flexShrink: 0 }}>
|
||||
<TextField
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={(e) => onQuery(e.target.value)}
|
||||
fullWidth
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ fontSize: 20, color: c.text.ghost }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: c.bg.surface, borderRadius: `${c.radius.md}px`, fontSize: '0.9375rem',
|
||||
'& input': { py: 1.4 },
|
||||
'& fieldset': { borderColor: c.border.medium },
|
||||
'&:hover fieldset': { borderColor: c.border.strong },
|
||||
'&.Mui-focused fieldset': { borderColor: c.border.strong, borderWidth: 1 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', px: 1.75, py: 0.9, borderRadius: 999, bgcolor: c.bg.secondary }}>
|
||||
<Typography sx={{ fontSize: '0.9375rem', fontWeight: 500, color: c.text.primary, whiteSpace: 'nowrap' }}>{chipLabel}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<DropdownPill label="Filter by" options={filterOptions} value={filterValue} onChange={onFilter} />
|
||||
<DropdownPill label="Sort by" options={sortOptions} value={sortValue} onChange={onSort} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DirectoryFilterBar;
|
||||
@@ -0,0 +1,283 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
||||
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { fetchSkills } from '@/shared/state/skillsSlice';
|
||||
import {
|
||||
fetchAllRegistrySkills,
|
||||
installCuratedSkill,
|
||||
searchCommunitySkills,
|
||||
CommunitySkill,
|
||||
RegistrySkill,
|
||||
} from '@/shared/state/skillRegistrySlice';
|
||||
import DirectoryFilterBar from './DirectoryFilterBar';
|
||||
import CommunityInstallConfirm from './CommunityInstallConfirm';
|
||||
|
||||
interface Props {
|
||||
onOpenInstalled?: (skillId: string) => void;
|
||||
}
|
||||
|
||||
interface SkillCardModel {
|
||||
key: string;
|
||||
slug: string;
|
||||
publisher: string;
|
||||
description: string;
|
||||
installs: number | null;
|
||||
isCommunity: boolean;
|
||||
curated?: RegistrySkill;
|
||||
community?: CommunitySkill;
|
||||
}
|
||||
|
||||
export function formatInstallCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
const DirectorySkillsTab: React.FC<Props> = ({ onOpenInstalled }) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const { skills: curated, loading: curatedLoading } = useAppSelector((s) => s.skillRegistry);
|
||||
const localSkills = useAppSelector((s) => s.skills.items);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
// Defaults to the Anthropic set, mirroring claude.ai's Directory landing view.
|
||||
const [filter, setFilter] = useState('anthropic');
|
||||
const [sort, setSort] = useState('popular');
|
||||
const [community, setCommunity] = useState<CommunitySkill[]>([]);
|
||||
const [communityLoading, setCommunityLoading] = useState(false);
|
||||
const [installingKey, setInstallingKey] = useState<string | null>(null);
|
||||
const [confirmTarget, setConfirmTarget] = useState<CommunitySkill | null>(null);
|
||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ open: false, message: '', severity: 'success' });
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchSeq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (curated.length === 0) dispatch(fetchAllRegistrySkills());
|
||||
dispatch(fetchSkills());
|
||||
}, [dispatch, curated.length]);
|
||||
|
||||
// skills.sh is the npx-installable community registry; its results carry install counts.
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const seq = ++searchSeq.current;
|
||||
setCommunityLoading(true);
|
||||
try {
|
||||
const res = await searchCommunitySkills(query.trim());
|
||||
if (seq === searchSeq.current) setCommunity(res);
|
||||
} catch {
|
||||
if (seq === searchSeq.current) setCommunity([]);
|
||||
} finally {
|
||||
if (seq === searchSeq.current) setCommunityLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
}, [query]);
|
||||
|
||||
const installedNames = useMemo(
|
||||
() => new Set(Object.values(localSkills).map((s) => s.name.trim().toLowerCase())),
|
||||
[localSkills],
|
||||
);
|
||||
const localIdByName = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const s of Object.values(localSkills)) m[s.name.trim().toLowerCase()] = s.id;
|
||||
return m;
|
||||
}, [localSkills]);
|
||||
|
||||
const cards = useMemo((): SkillCardModel[] => {
|
||||
const q = query.trim().toLowerCase();
|
||||
// Join curated skills to their skills.sh twin (source anthropics/skills) so Anthropic cards get real install counts.
|
||||
const communityByName = new Map(community.map((cs) => [cs.name.trim().toLowerCase(), cs]));
|
||||
const out: SkillCardModel[] = [];
|
||||
if (filter !== 'community') {
|
||||
for (const sk of curated) {
|
||||
if (q && !sk.name.toLowerCase().includes(q) && !sk.description.toLowerCase().includes(q)) continue;
|
||||
const twin = communityByName.get(sk.name.trim().toLowerCase());
|
||||
out.push({
|
||||
key: `curated:${sk.folder}`,
|
||||
slug: sk.name.toLowerCase().replace(/\s+/g, '-'),
|
||||
publisher: 'Anthropic',
|
||||
description: sk.description,
|
||||
installs: twin && /anthropic/i.test(twin.source) ? twin.installs : null,
|
||||
isCommunity: false,
|
||||
curated: sk,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (filter !== 'anthropic') {
|
||||
const curatedNames = new Set(curated.map((sk) => sk.name.trim().toLowerCase()));
|
||||
for (const cs of community) {
|
||||
// Anthropic-sourced twins already render as curated cards; listing them twice reads as duplicates.
|
||||
if (/anthropic/i.test(cs.source) && curatedNames.has(cs.name.trim().toLowerCase())) continue;
|
||||
if (q && !cs.name.toLowerCase().includes(q) && !cs.source.toLowerCase().includes(q)) continue;
|
||||
out.push({
|
||||
key: `community:${cs.source}/${cs.skillId}`,
|
||||
slug: cs.skillId.toLowerCase().replace(/\s+/g, '-'),
|
||||
publisher: cs.source,
|
||||
// skills.sh sometimes echoes the install count as the description; the meta line already carries it.
|
||||
description: /^[\d,.]+ installs?$/.test(cs.description.trim()) ? '' : cs.description,
|
||||
installs: cs.installs,
|
||||
isCommunity: true,
|
||||
community: cs,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (sort === 'name') out.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||
else out.sort((a, b) => (b.installs ?? -1) - (a.installs ?? -1));
|
||||
return out;
|
||||
}, [curated, community, query, filter, sort]);
|
||||
|
||||
const handleInstallCurated = async (card: SkillCardModel) => {
|
||||
if (!card.curated) return;
|
||||
setInstallingKey(card.key);
|
||||
try {
|
||||
await dispatch(installCuratedSkill(card.curated.folder)).unwrap();
|
||||
await dispatch(fetchSkills());
|
||||
setSnackbar({ open: true, message: `Installed "${card.curated.name}"`, severity: 'success' });
|
||||
} catch (e) {
|
||||
const msg = (e as { message?: string })?.message || 'unknown error';
|
||||
setSnackbar({ open: true, message: `Install failed: ${msg}`, severity: 'error' });
|
||||
} finally {
|
||||
setInstallingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isInstalled = (card: SkillCardModel): boolean => {
|
||||
const name = (card.curated?.name ?? card.community?.name ?? '').trim().toLowerCase();
|
||||
return installedNames.has(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, height: '100%', minHeight: 0 }}>
|
||||
<DirectoryFilterBar
|
||||
searchPlaceholder="Search skills..."
|
||||
chipLabel={filter === 'community' ? 'Community' : filter === 'all' ? 'All skills' : 'Anthropic'}
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
filterOptions={[
|
||||
{ value: 'all', label: 'All skills' },
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'community', label: 'Community' },
|
||||
]}
|
||||
filterValue={filter}
|
||||
onFilter={setFilter}
|
||||
sortOptions={[
|
||||
{ value: 'popular', label: 'Popular' },
|
||||
{ value: 'name', label: 'Name' },
|
||||
]}
|
||||
sortValue={sort}
|
||||
onSort={setSort}
|
||||
/>
|
||||
|
||||
<Box sx={{
|
||||
flex: 1, minHeight: 0, overflow: 'auto', pr: 0.5,
|
||||
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.75, alignContent: 'start',
|
||||
'&::-webkit-scrollbar': { width: 6 },
|
||||
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 3 },
|
||||
}}>
|
||||
{(curatedLoading && curated.length === 0) || (communityLoading && cards.length === 0) ? (
|
||||
<Box sx={{ gridColumn: '1 / -1', display: 'flex', justifyContent: 'center', pt: 8 }}>
|
||||
<CircularProgress size={24} sx={{ color: c.accent.primary }} />
|
||||
</Box>
|
||||
) : cards.length === 0 ? (
|
||||
<Box sx={{ gridColumn: '1 / -1', display: 'flex', justifyContent: 'center', pt: 8 }}>
|
||||
<Typography sx={{ fontSize: '0.875rem', color: c.text.ghost }}>No skills match your search.</Typography>
|
||||
</Box>
|
||||
) : cards.map((card) => {
|
||||
const installed = isInstalled(card);
|
||||
return (
|
||||
<Box
|
||||
key={card.key}
|
||||
sx={{
|
||||
border: `1px solid ${c.border.subtle}`, borderRadius: '14px', p: 2.25,
|
||||
bgcolor: c.bg.surface, display: 'flex', flexDirection: 'column', gap: 0.5,
|
||||
transition: 'border-color 0.12s, box-shadow 0.12s',
|
||||
'&:hover': { borderColor: c.border.medium, boxShadow: c.shadow.sm },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Typography noWrap sx={{ fontSize: '1rem', fontWeight: 700, color: c.text.primary }}>
|
||||
/{card.slug}
|
||||
</Typography>
|
||||
{installingKey === card.key ? (
|
||||
<CircularProgress size={18} sx={{ color: c.text.tertiary, m: 0.5 }} />
|
||||
) : installed ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const id = localIdByName[(card.curated?.name ?? card.community?.name ?? '').trim().toLowerCase()];
|
||||
if (id && onOpenInstalled) onOpenInstalled(id);
|
||||
}}
|
||||
sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<SettingsOutlinedIcon sx={{ fontSize: 19 }} />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => (card.isCommunity ? setConfirmTarget(card.community ?? null) : void handleInstallCurated(card))}
|
||||
sx={{ color: c.text.secondary, '&:hover': { color: c.text.primary, bgcolor: c.bg.secondary } }}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: -0.5 }}>
|
||||
<Typography noWrap sx={{ fontSize: '0.8125rem', color: c.text.tertiary }}>{card.publisher}</Typography>
|
||||
{card.installs !== null && (
|
||||
<>
|
||||
<Box sx={{ width: 3, height: 3, borderRadius: '50%', bgcolor: c.text.ghost, flexShrink: 0 }} />
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25, color: c.text.tertiary }}>
|
||||
<FileDownloadOutlinedIcon sx={{ fontSize: 14 }} />
|
||||
<Typography sx={{ fontSize: '0.8125rem' }}>{formatInstallCount(card.installs)}</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{card.description && (
|
||||
<Typography sx={{
|
||||
fontSize: '0.875rem', color: c.text.secondary, lineHeight: 1.5,
|
||||
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
||||
}}>
|
||||
{card.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<CommunityInstallConfirm
|
||||
skill={confirmTarget}
|
||||
onClose={() => setConfirmTarget(null)}
|
||||
onInstalled={(name) => {
|
||||
setConfirmTarget(null);
|
||||
void dispatch(fetchSkills());
|
||||
setSnackbar({ open: true, message: `Installed "${name}"`, severity: 'success' });
|
||||
}}
|
||||
/>
|
||||
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackbar((p) => ({ ...p, open: false }))}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity={snackbar.severity} onClose={() => setSnackbar((p) => ({ ...p, open: false }))} sx={{ fontSize: '0.8125rem' }}>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DirectorySkillsTab;
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import CreateNewFolderOutlinedIcon from '@mui/icons-material/CreateNewFolderOutlined';
|
||||
import Link from '@mui/material/Link';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { fetchSkills } from '@/shared/state/skillsSlice';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onUploaded?: (name: string) => void;
|
||||
}
|
||||
|
||||
// claude.ai's Upload skill modal, phrasing kept identical: a drop zone plus the two file rules.
|
||||
const UploadSkillDialog: React.FC<Props> = ({ open, onClose, onUploaded }) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const upload = async (file: File) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buf);
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/skills/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename: file.name, content_b64: btoa(binary) }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError((data as { detail?: string }).detail || `Upload failed: ${res.status}`);
|
||||
return;
|
||||
}
|
||||
await dispatch(fetchSkills());
|
||||
onUploaded?.((data as { skill?: { name?: string } }).skill?.name || file.name);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Upload failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFiles = (files: FileList | null) => {
|
||||
const file = files?.[0];
|
||||
if (file) void upload(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{ sx: { bgcolor: c.bg.surface, backgroundImage: 'none', borderRadius: '16px', border: `1px solid ${c.border.subtle}`, boxShadow: c.shadow.lg, p: 3.5 } }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 2.5 }}>
|
||||
<Typography sx={{ fontSize: '1.5rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.2 }}>
|
||||
Upload skill
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={onClose} sx={{ color: c.text.tertiary, mt: -0.5, mr: -1, '&:hover': { color: c.text.primary } }}>
|
||||
<CloseIcon sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ fontSize: '0.8125rem', mb: 1.5 }}>{error}</Alert>}
|
||||
|
||||
<Box
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e: React.DragEvent) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e: React.DragEvent) => { e.preventDefault(); setDragOver(false); handleFiles(e.dataTransfer.files); }}
|
||||
sx={{
|
||||
border: `1.5px dashed ${dragOver ? c.accent.primary : c.border.medium}`,
|
||||
borderRadius: '12px', py: 6, px: 3,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5,
|
||||
cursor: 'pointer', bgcolor: dragOver ? `${c.accent.primary}08` : 'transparent',
|
||||
transition: 'border-color 0.12s, background 0.12s',
|
||||
'&:hover': { borderColor: c.border.strong },
|
||||
}}
|
||||
>
|
||||
{busy ? (
|
||||
<CircularProgress size={28} sx={{ color: c.accent.primary }} />
|
||||
) : (
|
||||
<CreateNewFolderOutlinedIcon sx={{ fontSize: 34, color: c.text.secondary }} />
|
||||
)}
|
||||
<Typography sx={{ fontSize: '1rem', color: c.text.secondary }}>
|
||||
Drag and drop or click to upload
|
||||
</Typography>
|
||||
<Box
|
||||
component="input"
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".md,.zip,.skill"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => { handleFiles(e.target.files); e.target.value = ''; }}
|
||||
sx={{ display: 'none' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography sx={{ fontSize: '0.875rem', color: c.text.tertiary, mt: 2.5 }}>File requirements</Typography>
|
||||
<Box component="ul" sx={{ m: 0, mt: 0.75, pl: 2.5, color: c.text.tertiary }}>
|
||||
<Typography component="li" sx={{ fontSize: '0.875rem', lineHeight: 1.6 }}>
|
||||
.md file must contain skill name and description formatted in YAML
|
||||
</Typography>
|
||||
<Typography component="li" sx={{ fontSize: '0.875rem', lineHeight: 1.6 }}>
|
||||
.zip or .skill file must include a SKILL.md file
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography sx={{ fontSize: '0.875rem', mt: 2 }}>
|
||||
<Link href="https://docs.claude.com/en/docs/agents-and-tools/agent-skills" target="_blank" rel="noreferrer" sx={{ color: c.text.secondary, textDecorationColor: c.text.tertiary }}>
|
||||
Read more about creating skills
|
||||
</Link>{' '}
|
||||
<Typography component="span" sx={{ fontSize: '0.875rem', color: c.text.tertiary }}>or</Typography>{' '}
|
||||
<Link href="https://github.com/anthropics/skills" target="_blank" rel="noreferrer" sx={{ color: c.text.secondary, textDecorationColor: c.text.tertiary }}>
|
||||
see an example
|
||||
</Link>
|
||||
</Typography>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadSkillDialog;
|
||||
@@ -58,8 +58,10 @@ import ShareButton from '@/app/components/share/ShareButton';
|
||||
import { IMPORT_OPEN_EVENT } from '@/app/components/share/ImportEntryPoint';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import SkillBuilderChat, { SkillPreviewData } from './SkillBuilderChat';
|
||||
import CommunitySkillsDialog from './CommunitySkillsDialog';
|
||||
import PublicIcon from '@mui/icons-material/Public';
|
||||
import DirectoryDialog from '../Directory/DirectoryDialog';
|
||||
import UploadSkillDialog from '../Directory/UploadSkillDialog';
|
||||
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
|
||||
import DriveFolderUploadOutlinedIcon from '@mui/icons-material/DriveFolderUploadOutlined';
|
||||
|
||||
interface SkillForm {
|
||||
name: string;
|
||||
@@ -103,7 +105,8 @@ const Skills: React.FC = () => {
|
||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({ open: false, message: '' });
|
||||
const [builderPreview, setBuilderPreview] = useState<SkillPreviewData | null>(null);
|
||||
const [builderOpen, setBuilderOpen] = useState(false);
|
||||
const [communityOpen, setCommunityOpen] = useState(false);
|
||||
const [directoryOpen, setDirectoryOpen] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
|
||||
const handleBuilderPreview = useCallback((data: SkillPreviewData | null) => {
|
||||
setBuilderPreview(data);
|
||||
@@ -368,13 +371,13 @@ const Skills: React.FC = () => {
|
||||
<SearchIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Browse community skills (skills.sh)">
|
||||
<Tooltip title="Upload skill">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setCommunityOpen(true)}
|
||||
onClick={() => setUploadOpen(true)}
|
||||
sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<PublicIcon sx={{ fontSize: 18 }} />
|
||||
<DriveFolderUploadOutlinedIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Create skill">
|
||||
@@ -407,6 +410,29 @@ const Skills: React.FC = () => {
|
||||
Build with AI
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ px: 1.5, pb: 0.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<StorefrontOutlinedIcon sx={{ fontSize: 14 }} />}
|
||||
onClick={() => setDirectoryOpen(true)}
|
||||
fullWidth
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
color: c.bg.surface,
|
||||
bgcolor: c.text.primary,
|
||||
justifyContent: 'center',
|
||||
gap: 0.5,
|
||||
py: 0.8,
|
||||
px: 1.5,
|
||||
borderRadius: 999,
|
||||
'&:hover': { bgcolor: c.text.secondary },
|
||||
}}
|
||||
>
|
||||
Browse directory
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Collapse in={searchFilter !== ''} timeout={0} unmountOnExit>
|
||||
<Box sx={{ px: 1.5, pb: 1 }}>
|
||||
@@ -811,13 +837,23 @@ const Skills: React.FC = () => {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<CommunitySkillsDialog
|
||||
open={communityOpen}
|
||||
onClose={() => setCommunityOpen(false)}
|
||||
onInstalled={(name) => {
|
||||
dispatch(fetchSkills());
|
||||
<DirectoryDialog
|
||||
open={directoryOpen}
|
||||
initialTab="skills"
|
||||
onClose={() => setDirectoryOpen(false)}
|
||||
onOpenInstalledSkill={(skillId) => {
|
||||
setDirectoryOpen(false);
|
||||
onboardingBus.emit('skill:installed');
|
||||
setSnackbar({ open: true, message: `Installed "${name}" from skills.sh` });
|
||||
setSelection({ type: 'local', id: skillId });
|
||||
}}
|
||||
/>
|
||||
|
||||
<UploadSkillDialog
|
||||
open={uploadOpen}
|
||||
onClose={() => setUploadOpen(false)}
|
||||
onUploaded={(name) => {
|
||||
onboardingBus.emit('skill:installed');
|
||||
setSnackbar({ open: true, message: `Uploaded "${name}"` });
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ import BrowserPermissionCard from './cards/BrowserPermissionCard';
|
||||
import AgentWorkflowsSection from './cards/AgentWorkflowsSection';
|
||||
import RegistryBrowserDialog from './dialogs/RegistryBrowserDialog';
|
||||
import ToolDialogs from './dialogs/ToolDialogs';
|
||||
import DirectoryDialog from '../Directory/DirectoryDialog';
|
||||
import AddCustomConnectorDialog from '../Directory/AddCustomConnectorDialog';
|
||||
import AddLinkIcon from '@mui/icons-material/AddLink';
|
||||
import CustomToolCard from './cards/CustomToolCard';
|
||||
import IntegrationGalleryCard from './cards/IntegrationGalleryCard';
|
||||
import { useToolsActions } from './hooks/useToolsActions';
|
||||
@@ -70,6 +73,8 @@ const Tools: React.FC = () => {
|
||||
const [browserCollapsed, setBrowserCollapsed] = useState<Record<string, boolean>>({ browser_delegation: true, browser_action: true });
|
||||
const [builtinSectionOpen, setBuiltinSectionOpen] = useState(true);
|
||||
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const [directoryOpen, setDirectoryOpen] = useState(false);
|
||||
const [customConnectorOpen, setCustomConnectorOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchTools());
|
||||
@@ -124,14 +129,24 @@ const Tools: React.FC = () => {
|
||||
onClose={handleMenuClose}
|
||||
PaperProps={{ sx: { bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: 2, mt: 0.5, minWidth: 200 } }}
|
||||
>
|
||||
<MenuItem onClick={() => { handleMenuClose(); setDirectoryOpen(true); }} sx={{ color: c.text.primary, fontSize: '0.875rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}>
|
||||
<StorefrontIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
|
||||
Browse connectors
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { handleMenuClose(); setCustomConnectorOpen(true); }} sx={{ color: c.text.primary, fontSize: '0.875rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}>
|
||||
<AddLinkIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
|
||||
Add custom connector
|
||||
</MenuItem>
|
||||
<MenuItem onClick={a.openCreate} sx={{ color: c.text.primary, fontSize: '0.875rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}>
|
||||
<BuildIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
|
||||
Create Custom
|
||||
</MenuItem>
|
||||
<MenuItem onClick={a.openRegistryBrowser} sx={{ color: c.text.primary, fontSize: '0.875rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}>
|
||||
<StorefrontIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
|
||||
Browse MCP Registry
|
||||
</MenuItem>
|
||||
{devMode && (
|
||||
<MenuItem onClick={a.openRegistryBrowser} sx={{ color: c.text.primary, fontSize: '0.875rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}>
|
||||
<StorefrontIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
|
||||
Browse MCP Registry
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -252,6 +267,23 @@ const Tools: React.FC = () => {
|
||||
onCredentialsSave={a.handleCredentialsSave}
|
||||
/>
|
||||
|
||||
<DirectoryDialog
|
||||
open={directoryOpen}
|
||||
initialTab="connectors"
|
||||
onClose={() => setDirectoryOpen(false)}
|
||||
onOpenInstalledConnector={(toolId) => {
|
||||
setDirectoryOpen(false);
|
||||
a.setExpandedToolId(toolId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AddCustomConnectorDialog
|
||||
open={customConnectorOpen}
|
||||
onClose={() => setCustomConnectorOpen(false)}
|
||||
onBrowsePrebuilt={() => setDirectoryOpen(true)}
|
||||
onAdded={(message, severity) => a.setSnackbar({ open: true, message, severity: severity === 'error' ? 'error' : undefined })}
|
||||
/>
|
||||
|
||||
<RegistryBrowserDialog
|
||||
open={a.registryOpen}
|
||||
onClose={() => a.setRegistryOpen(false)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { McpServer } from '@/shared/state/mcpRegistrySlice';
|
||||
import { ToolForm, emptyForm } from '../toolsHelpers';
|
||||
import { Integration } from '../integrations';
|
||||
import { installIntegration } from '../installIntegration';
|
||||
import { useToolConnections } from './useToolConnections';
|
||||
import { useRegistryBrowser } from './useRegistryBrowser';
|
||||
|
||||
@@ -68,31 +69,9 @@ export function useToolsActions({ items, allTools, regServersRaw, closeMenu }: T
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const result = await dispatch(createTool({
|
||||
name: integration.name,
|
||||
description: integration.description,
|
||||
command: '',
|
||||
mcp_config: integration.mcp_config,
|
||||
credentials: {},
|
||||
auth_type: integration.authType || 'none',
|
||||
auth_status: 'configured',
|
||||
}));
|
||||
if (createTool.fulfilled.match(result)) {
|
||||
const newTool = result.payload;
|
||||
if (integration.authType === 'oauth2' || integration.authType === 'device_code') {
|
||||
setSnackbar({ open: true, message: `Enabled ${integration.name}, connect your account to discover tools` });
|
||||
} else {
|
||||
setSnackbar({ open: true, message: `Enabled ${integration.name}, discovering tools…` });
|
||||
const discoverResult = await dispatch(discoverTools(newTool.id));
|
||||
if (discoverTools.fulfilled.match(discoverResult)) {
|
||||
setSnackbar({ open: true, message: `${integration.name} ready, tools discovered` });
|
||||
} else {
|
||||
const detail = (discoverResult as any).error?.message
|
||||
|| `discovery failed; is ${integration.mcp_config.command || 'the server'} installed?`;
|
||||
setSnackbar({ open: true, message: `${integration.name}: ${detail}`, severity: 'error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
setSnackbar({ open: true, message: `Enabled ${integration.name}, discovering tools…` });
|
||||
const res = await installIntegration(dispatch, integration);
|
||||
setSnackbar({ open: true, message: res.message, severity: res.severity === 'error' ? 'error' : undefined });
|
||||
}
|
||||
} finally {
|
||||
setIntegrationLoading((p) => ({ ...p, [integration.id]: false }));
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createTool, discoverTools } from '@/shared/state/toolsSlice';
|
||||
import type { AppDispatch } from '@/shared/state/store';
|
||||
import type { Integration } from './integrations';
|
||||
|
||||
export interface InstallIntegrationResult {
|
||||
toolId: string | null;
|
||||
message: string;
|
||||
severity: 'success' | 'error';
|
||||
}
|
||||
|
||||
// The one install path for a vetted integration (Tools page card AND the Directory's + button):
|
||||
// create the tool, then discover immediately unless the auth flow has to run first.
|
||||
export async function installIntegration(dispatch: AppDispatch, integration: Integration): Promise<InstallIntegrationResult> {
|
||||
const result = await dispatch(createTool({
|
||||
name: integration.name,
|
||||
description: integration.description,
|
||||
command: '',
|
||||
mcp_config: integration.mcp_config,
|
||||
credentials: {},
|
||||
auth_type: integration.authType || 'none',
|
||||
auth_status: 'configured',
|
||||
}));
|
||||
if (!createTool.fulfilled.match(result)) {
|
||||
return { toolId: null, message: `Could not enable ${integration.name}`, severity: 'error' };
|
||||
}
|
||||
const newTool = result.payload;
|
||||
if (integration.authType === 'oauth2' || integration.authType === 'device_code') {
|
||||
return { toolId: newTool.id, message: `Enabled ${integration.name}, connect your account to discover tools`, severity: 'success' };
|
||||
}
|
||||
const discoverResult = await dispatch(discoverTools(newTool.id));
|
||||
if (discoverTools.fulfilled.match(discoverResult)) {
|
||||
return { toolId: newTool.id, message: `${integration.name} ready, tools discovered`, severity: 'success' };
|
||||
}
|
||||
const detail = (discoverResult as { error?: { message?: string } }).error?.message
|
||||
|| `discovery failed; is ${integration.mcp_config.command || 'the server'} installed?`;
|
||||
return { toolId: newTool.id, message: `${integration.name}: ${detail}`, severity: 'error' };
|
||||
}
|
||||
Reference in New Issue
Block a user