diff --git a/frontend/src/app/pages/Directory/AddCustomConnectorDialog.tsx b/frontend/src/app/pages/Directory/AddCustomConnectorDialog.tsx new file mode 100644 index 00000000..5a352527 --- /dev/null +++ b/frontend/src/app/pages/Directory/AddCustomConnectorDialog.tsx @@ -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) => ({ + '& .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 = ({ 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 = {}; + 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 ( + + + + Add custom connector + + + + + + + + Connect Claude to your data and tools.{' '} + + Learn more about connectors + {' '} + or get started with{' '} + { onClose(); onBrowsePrebuilt?.(); }} sx={{ color: c.text.secondary, textDecorationColor: c.text.tertiary, verticalAlign: 'baseline' }}> + pre-built ones + . + + + + setName(e.target.value)} fullWidth sx={fieldSx(c)} /> + setUrl(e.target.value)} fullWidth sx={fieldSx(c)} /> + + + setAdvancedOpen((v) => !v)} + sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 2.5, cursor: 'pointer', userSelect: 'none', width: 'fit-content' }} + > + {advancedOpen ? : } + Advanced settings + + + + setClientId(e.target.value)} fullWidth sx={fieldSx(c)} /> + setClientSecret(e.target.value)} fullWidth sx={fieldSx(c)} /> + + + + + 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. + + + + Building an MCP server?{' '} + + Report issues and subscribe to updates here + + + + + + + + + ); +}; + +export default AddCustomConnectorDialog; diff --git a/frontend/src/app/pages/Directory/CommunityInstallConfirm.tsx b/frontend/src/app/pages/Directory/CommunityInstallConfirm.tsx new file mode 100644 index 00000000..1eacceed --- /dev/null +++ b/frontend/src/app/pages/Directory/CommunityInstallConfirm.tsx @@ -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 = ({ skill, onClose, onInstalled }) => { + const c = useClaudeTokens(); + const [disclosure, setDisclosure] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( + + + Install community skill + + + {error && {error}} + {busy && !disclosure && } + {disclosure && ( + <> + {disclosure.name} + {disclosure.description && ( + {disclosure.description} + )} + + {skill?.source} + + + } 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. + + + {disclosure.secret_findings.length > 0 && ( + } 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. + + )} + + {disclosure.has_scripts && ( + } 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. + + )} + + + {disclosure.files.length} file{disclosure.files.length === 1 ? '' : 's'} will be installed: + + + {disclosure.files.map((f) => ( + + {disclosure.scripts.includes(f) ? '⚙ ' : ''}{f} + + ))} + + + )} + + + + {disclosure && ( + + )} + + + ); +}; + +export default CommunityInstallConfirm; diff --git a/frontend/src/app/pages/Directory/DirectoryConnectorsTab.tsx b/frontend/src/app/pages/Directory/DirectoryConnectorsTab.tsx new file mode 100644 index 00000000..ddedebdc --- /dev/null +++ b/frontend/src/app/pages/Directory/DirectoryConnectorsTab.tsx @@ -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 = ({ 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(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 = {}; + 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 ; + if (installed) { + return ( + onOpenInstalled?.(installed.id)} sx={{ color: c.status.success, '&:hover': { color: c.text.primary } }}> + + + ); + } + return ( + { void handleInstall(ig); }} sx={{ color: c.text.secondary, '&:hover': { color: c.text.primary, bgcolor: c.bg.secondary } }}> + + + ); + }; + + const iconBox = (ig: Integration, size: number) => ( + + {ig.icon} + + ); + + return ( + + + + + {!query.trim() && filter === 'all' && ( + <> + + Popular + + + {popular.map((ig) => ( + + {iconBox(ig, 30)} + {ig.name} + {actionFor(ig)} + + ))} + + + )} + + + {list.length === 0 ? ( + + No connectors match your search. + + ) : list.map((ig) => ( + + {iconBox(ig, 44)} + + + {ig.name} + + + + {ig.description} + + + {actionFor(ig)} + + ))} + + + + setSnackbar((p) => ({ ...p, open: false }))} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSnackbar((p) => ({ ...p, open: false }))} sx={{ fontSize: '0.8125rem' }}> + {snackbar.message} + + + + ); +}; + +export default DirectoryConnectorsTab; diff --git a/frontend/src/app/pages/Directory/DirectoryDialog.tsx b/frontend/src/app/pages/Directory/DirectoryDialog.tsx new file mode 100644 index 00000000..a2a64472 --- /dev/null +++ b/frontend/src/app/pages/Directory/DirectoryDialog.tsx @@ -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 = ({ open, initialTab = 'skills', onClose, onOpenInstalledSkill, onOpenInstalledConnector }) => { + const c = useClaudeTokens(); + const [tab, setTab] = useState(initialTab); + useEffect(() => { if (open) setTab(initialTab); }, [open, initialTab]); + + const railRow = (value: DirectoryTab, label: string, icon: React.ReactNode) => { + const selected = tab === value; + return ( + 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} + {label} + + ); + }; + + return ( + + + + Directory + + + + + + + + + {railRow('skills', 'Skills', )} + {railRow('connectors', 'Connectors', )} + + + + {tab === 'skills' ? ( + + ) : ( + + )} + + + + ); +}; + +export default DirectoryDialog; diff --git a/frontend/src/app/pages/Directory/DirectoryFilterBar.tsx b/frontend/src/app/pages/Directory/DirectoryFilterBar.tsx new file mode 100644 index 00000000..a7fd3f9b --- /dev/null +++ b/frontend/src/app/pages/Directory/DirectoryFilterBar.tsx @@ -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 = ({ label, options, value, onChange }) => { + const c = useClaudeTokens(); + const [anchor, setAnchor] = useState(null); + const active = options.find((o) => o.value === value); + return ( + <> + ) => 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 }, + }} + > + + {active && active.value !== options[0].value ? active.label : label} + + + + 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) => ( + { onChange(o.value); setAnchor(null); }} + sx={{ fontSize: '0.875rem', color: c.text.primary, gap: 1, '&:hover': { bgcolor: c.bg.secondary } }} + > + {o.value === value && } + {o.label} + + ))} + + + ); +}; + +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 = ({ + searchPlaceholder, chipLabel, query, onQuery, + filterOptions, filterValue, onFilter, sortOptions, sortValue, onSort, +}) => { + const c = useClaudeTokens(); + return ( + + onQuery(e.target.value)} + fullWidth + InputProps={{ + startAdornment: ( + + + + ), + }} + 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 }, + }, + }} + /> + + + {chipLabel} + + + + + + + + ); +}; + +export default DirectoryFilterBar; diff --git a/frontend/src/app/pages/Directory/DirectorySkillsTab.tsx b/frontend/src/app/pages/Directory/DirectorySkillsTab.tsx new file mode 100644 index 00000000..6e29314d --- /dev/null +++ b/frontend/src/app/pages/Directory/DirectorySkillsTab.tsx @@ -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 = ({ 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([]); + const [communityLoading, setCommunityLoading] = useState(false); + const [installingKey, setInstallingKey] = useState(null); + const [confirmTarget, setConfirmTarget] = useState(null); + const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ open: false, message: '', severity: 'success' }); + const debounceRef = useRef | 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 = {}; + 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 ( + + + + + {(curatedLoading && curated.length === 0) || (communityLoading && cards.length === 0) ? ( + + + + ) : cards.length === 0 ? ( + + No skills match your search. + + ) : cards.map((card) => { + const installed = isInstalled(card); + return ( + + + + /{card.slug} + + {installingKey === card.key ? ( + + ) : installed ? ( + { + 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 } }} + > + + + ) : ( + (card.isCommunity ? setConfirmTarget(card.community ?? null) : void handleInstallCurated(card))} + sx={{ color: c.text.secondary, '&:hover': { color: c.text.primary, bgcolor: c.bg.secondary } }} + > + + + )} + + + {card.publisher} + {card.installs !== null && ( + <> + + + + {formatInstallCount(card.installs)} + + + )} + + {card.description && ( + + {card.description} + + )} + + ); + })} + + + setConfirmTarget(null)} + onInstalled={(name) => { + setConfirmTarget(null); + void dispatch(fetchSkills()); + setSnackbar({ open: true, message: `Installed "${name}"`, severity: 'success' }); + }} + /> + + setSnackbar((p) => ({ ...p, open: false }))} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSnackbar((p) => ({ ...p, open: false }))} sx={{ fontSize: '0.8125rem' }}> + {snackbar.message} + + + + ); +}; + +export default DirectorySkillsTab; diff --git a/frontend/src/app/pages/Directory/UploadSkillDialog.tsx b/frontend/src/app/pages/Directory/UploadSkillDialog.tsx new file mode 100644 index 00000000..98d984e8 --- /dev/null +++ b/frontend/src/app/pages/Directory/UploadSkillDialog.tsx @@ -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 = ({ open, onClose, onUploaded }) => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const inputRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( + + + + Upload skill + + + + + + + {error && {error}} + + 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 ? ( + + ) : ( + + )} + + Drag and drop or click to upload + + ) => { handleFiles(e.target.files); e.target.value = ''; }} + sx={{ display: 'none' }} + /> + + + File requirements + + + .md file must contain skill name and description formatted in YAML + + + .zip or .skill file must include a SKILL.md file + + + + + + Read more about creating skills + {' '} + or{' '} + + see an example + + + + ); +}; + +export default UploadSkillDialog; diff --git a/frontend/src/app/pages/Skills/Skills.tsx b/frontend/src/app/pages/Skills/Skills.tsx index 25fc69ef..d2f691e8 100644 --- a/frontend/src/app/pages/Skills/Skills.tsx +++ b/frontend/src/app/pages/Skills/Skills.tsx @@ -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(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 = () => { - + setCommunityOpen(true)} + onClick={() => setUploadOpen(true)} sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }} > - + @@ -407,6 +410,29 @@ const Skills: React.FC = () => { Build with AI + + + @@ -811,13 +837,23 @@ const Skills: React.FC = () => { - setCommunityOpen(false)} - onInstalled={(name) => { - dispatch(fetchSkills()); + setDirectoryOpen(false)} + onOpenInstalledSkill={(skillId) => { + setDirectoryOpen(false); onboardingBus.emit('skill:installed'); - setSnackbar({ open: true, message: `Installed "${name}" from skills.sh` }); + setSelection({ type: 'local', id: skillId }); + }} + /> + + setUploadOpen(false)} + onUploaded={(name) => { + onboardingBus.emit('skill:installed'); + setSnackbar({ open: true, message: `Uploaded "${name}"` }); }} /> diff --git a/frontend/src/app/pages/Tools/Tools.tsx b/frontend/src/app/pages/Tools/Tools.tsx index 3d8fdd95..59fb6734 100644 --- a/frontend/src/app/pages/Tools/Tools.tsx +++ b/frontend/src/app/pages/Tools/Tools.tsx @@ -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>({ browser_delegation: true, browser_action: true }); const [builtinSectionOpen, setBuiltinSectionOpen] = useState(true); const [menuAnchor, setMenuAnchor] = useState(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 } }} > + { handleMenuClose(); setDirectoryOpen(true); }} sx={{ color: c.text.primary, fontSize: '0.875rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> + + Browse connectors + + { handleMenuClose(); setCustomConnectorOpen(true); }} sx={{ color: c.text.primary, fontSize: '0.875rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> + + Add custom connector + Create Custom - - - Browse MCP Registry - + {devMode && ( + + + Browse MCP Registry + + )} @@ -252,6 +267,23 @@ const Tools: React.FC = () => { onCredentialsSave={a.handleCredentialsSave} /> + setDirectoryOpen(false)} + onOpenInstalledConnector={(toolId) => { + setDirectoryOpen(false); + a.setExpandedToolId(toolId); + }} + /> + + setCustomConnectorOpen(false)} + onBrowsePrebuilt={() => setDirectoryOpen(true)} + onAdded={(message, severity) => a.setSnackbar({ open: true, message, severity: severity === 'error' ? 'error' : undefined })} + /> + a.setRegistryOpen(false)} diff --git a/frontend/src/app/pages/Tools/hooks/useToolsActions.ts b/frontend/src/app/pages/Tools/hooks/useToolsActions.ts index 9a3c6916..21ce74be 100644 --- a/frontend/src/app/pages/Tools/hooks/useToolsActions.ts +++ b/frontend/src/app/pages/Tools/hooks/useToolsActions.ts @@ -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 })); diff --git a/frontend/src/app/pages/Tools/installIntegration.ts b/frontend/src/app/pages/Tools/installIntegration.ts new file mode 100644 index 00000000..cf793eab --- /dev/null +++ b/frontend/src/app/pages/Tools/installIntegration.ts @@ -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 { + 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' }; +}