[eric] marketplace: claude.ai-style Directory for skills + connectors, with upload-skill and custom-connector modals

This commit is contained in:
ciregenz
2026-08-05 01:20:17 -07:00
parent 99fd1ff82e
commit 55749f54d0
11 changed files with 1243 additions and 41 deletions
@@ -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;
+48 -12
View File
@@ -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 -4
View File
@@ -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' };
}