feat(skills): curated GitHub catalog redesign (#3016)

* feat(skills): remove ClawHub catalog integration

Drop the ClawHub registry as a skills catalog source across web server,
shared UI, VS Code, docs, and locales. The catalog now serves git-based
sources only: the curated Anthropic repo and user-defined repositories.
Also removes the now-unused adm-zip dependency.

* feat(skills): redesign catalog around curated GitHub repositories

Replace the single-source dropdown with a card grid of curated GitHub
repositories (Anthropic, OpenAI, Cursor pstack/skills, Matt Pocock) plus
user-defined sources. Source cards show skill counts, GitHub stars, and
last-updated time; a global search covers all loaded sources.

Server: curated sources gain GitHub repo metadata (stars, pushed_at)
fetched best-effort with a 3-hour in-memory and on-disk cache; scans
run through a concurrency-limited, deduplicated cache with 3-hour TTL
persisted across restarts. Refresh still bypasses the cache.

Shared UI: source cards, global search with clear button, per-skill
GitHub links, install/installed states. VS Code curated list updated
to match. All new copy translated across 12 locales.

* fix(skills): address catalog review findings

- GitHub metadata fetch timeout drops to 1.5s (under the catalog
  client's 3s deadline) and failed lookups cache briefly (5 min) so
  repeated catalog loads do not re-hit a failing API.
- Disk cache files are written with owner-only permissions (0o600);
  rename preserves the mode.
- loadSource deduplicates concurrent in-flight requests per source and
  the shared isLoadingSource flag now clears only when the last active
  source load finishes.
This commit is contained in:
Bohdan Triapitsyn
2026-08-20 01:40:10 +03:00
committed by GitHub
parent 90d8868bfc
commit 1ed3f1f575
45 changed files with 1143 additions and 1286 deletions
@@ -127,24 +127,13 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
directoryOverride?: string | null;
conflictDecisions?: Record<string, ConflictDecision>;
}) => {
// Build selection with clawdhub metadata if present
const selection: { skillDir: string; clawdhub?: { slug: string; version: string } } = {
skillDir: request.skillDir,
};
if (item?.clawdhub) {
selection.clawdhub = {
slug: item.clawdhub.slug,
version: item.clawdhub.version,
};
}
const result = await installSkills({
source: request.source,
subpath: request.subpath,
gitIdentityId: item?.gitIdentityId,
scope: request.scope,
targetSource: request.targetSource,
selections: [selection],
selections: [{ skillDir: request.skillDir }],
conflictPolicy: 'prompt',
conflictDecisions: request.conflictDecisions,
}, { directory: request.directoryOverride ?? null });
@@ -4,11 +4,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SettingsSection,
SETTINGS_SELECT_SIZE,
SETTINGS_SELECT_TRIGGER_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import {
Dialog,
@@ -18,24 +14,16 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon";
import { Icon } from '@/components/icon/Icon';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
import type { SkillsCatalogItem } from '@/lib/api/types';
import type { SkillsCatalogItem, SkillsCatalogSource } from '@/lib/api/types';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { updateDesktopSettings } from '@/lib/persistence';
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { AddCatalogDialog } from './AddCatalogDialog';
import { InstallSkillDialog } from './InstallSkillDialog';
@@ -48,6 +36,71 @@ interface SkillsCatalogPageProps {
showModeTabs?: boolean;
}
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const getRepoUrl = (source: string): string | null => {
const trimmed = source.trim();
if (!GITHUB_REPO_PATTERN.test(trimmed)) {
return null;
}
return `https://github.com/${trimmed}`;
};
const getSkillUrl = (item: SkillsCatalogItem): string | null => {
const repoUrl = getRepoUrl(item.repoSource);
if (!repoUrl) {
return null;
}
const skillPath = [item.repoSubpath, item.skillDir].filter(Boolean).join('/');
return skillPath ? `${repoUrl}/tree/HEAD/${skillPath}` : repoUrl;
};
let cachedStarsFormatter: { locale: string; formatter: Intl.NumberFormat } | null = null;
const formatStars = (stars: number): string => {
const locale = getCurrentIntlLocale();
if (!cachedStarsFormatter || cachedStarsFormatter.locale !== locale) {
cachedStarsFormatter = { locale, formatter: new Intl.NumberFormat(locale, { notation: 'compact' }) };
}
return cachedStarsFormatter.formatter.format(stars);
};
type RelativeTimeKey =
| 'common.relative.justNow'
| 'common.relative.minutesAgoShort'
| 'common.relative.hoursAgoShort'
| 'common.relative.daysAgoShort'
| 'common.relative.weeksAgoShort'
| 'common.relative.yearsAgoShort';
const formatRelativeShort = (isoDate: string): { key: RelativeTimeKey; count: number } | null => {
const timestamp = Date.parse(isoDate);
if (Number.isNaN(timestamp)) {
return null;
}
const diffMs = Date.now() - timestamp;
if (diffMs < 60_000) {
return { key: 'common.relative.justNow', count: 0 };
}
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 60) {
return { key: 'common.relative.minutesAgoShort', count: minutes };
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return { key: 'common.relative.hoursAgoShort', count: hours };
}
const days = Math.floor(hours / 24);
if (days < 7) {
return { key: 'common.relative.daysAgoShort', count: days };
}
const weeks = Math.floor(days / 7);
if (weeks < 52) {
return { key: 'common.relative.weeksAgoShort', count: weeks };
}
return { key: 'common.relative.yearsAgoShort', count: Math.floor(days / 365) };
};
const loadSettings = async (): Promise<DesktopSettings | null> => {
try {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
@@ -71,6 +124,67 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
}
};
const SourceCard: React.FC<{
source: SkillsCatalogSource;
isActive: boolean;
isLoading: boolean;
skillsCount: number | null;
onSelect: () => void;
t: ReturnType<typeof useI18n>['t'];
}> = ({ source, isActive, isLoading, skillsCount, onSelect, t }) => {
const stars = source.stars ?? null;
const updated = source.repoUpdatedAt ? formatRelativeShort(source.repoUpdatedAt) : null;
return (
<button
type="button"
onClick={onSelect}
aria-pressed={isActive}
className={cn(
'w-full min-h-24 text-left rounded-lg border bg-[var(--surface-elevated)] p-3.5 flex gap-3 items-start transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive
? 'border-primary'
: 'border-[var(--surface-subtle)] hover:border-[var(--interactive-border-hover)]'
)}
>
<span className="min-w-0 flex-1 block">
<span className="flex items-center gap-2">
<span className="typography-ui-label font-medium text-foreground truncate">{source.label}</span>
{isLoading ? (
<Icon name="refresh" className="h-3 w-3 animate-spin text-muted-foreground shrink-0" />
) : (
skillsCount !== null && (
<span className="typography-micro text-muted-foreground shrink-0">
{t('settings.skills.catalog.page.source.skillsCount', { count: skillsCount })}
</span>
)
)}
</span>
<span className="typography-micro font-mono text-muted-foreground block mt-0.5 truncate">{source.source}</span>
<span className="flex items-center gap-3 mt-1">
{stars !== null && (
<span
className="typography-micro text-muted-foreground flex items-center gap-1"
title={t('settings.skills.catalog.page.source.stars', { count: stars })}
>
<Icon name="star" className="h-3 w-3" />
{formatStars(stars)}
</span>
)}
{updated && (
<span className="typography-micro text-muted-foreground">
{updated.key === 'common.relative.justNow'
? t(updated.key)
: t('settings.skills.catalog.page.source.updated', { time: t(updated.key, { count: updated.count }) })}
</span>
)}
</span>
</span>
</button>
);
};
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange, showModeTabs = true }) => {
const { t } = useI18n();
const {
@@ -80,12 +194,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
setSelectedSource,
loadCatalog,
loadSource,
loadMoreClawdHub,
isLoadingCatalog,
isLoadingSource,
isLoadingMore,
loadedSourceIds,
clawdhubHasMoreBySource,
lastCatalogError,
} = useSkillsCatalogStore(useShallow((s) => ({
sources: s.sources,
@@ -94,12 +205,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
setSelectedSource: s.setSelectedSource,
loadCatalog: s.loadCatalog,
loadSource: s.loadSource,
loadMoreClawdHub: s.loadMoreClawdHub,
isLoadingCatalog: s.isLoadingCatalog,
isLoadingSource: s.isLoadingSource,
isLoadingMore: s.isLoadingMore,
loadedSourceIds: s.loadedSourceIds,
clawdhubHasMoreBySource: s.clawdhubHasMoreBySource,
lastCatalogError: s.lastCatalogError,
})));
@@ -109,43 +217,72 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
const [installItem, setInstallItem] = React.useState<SkillsCatalogItem | null>(null);
const [isRemovingCatalog, setIsRemovingCatalog] = React.useState(false);
const [isRemoveCatalogDialogOpen, setIsRemoveCatalogDialogOpen] = React.useState(false);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
React.useEffect(() => {
void loadCatalog();
}, [loadCatalog]);
// Load every source in the background so global search covers all of them.
React.useEffect(() => {
if (!selectedSourceId) {
const unloaded = sources.filter((src) => !loadedSourceIds[src.id]);
if (unloaded.length === 0) {
return;
}
if (!loadedSourceIds[selectedSourceId]) {
void loadSource(selectedSourceId);
let cancelled = false;
const loadRest = async () => {
for (const src of unloaded) {
if (cancelled) {
return;
}
await loadSource(src.id);
}
};
void loadRest();
return () => {
cancelled = true;
};
}, [sources, loadedSourceIds, loadSource]);
React.useEffect(() => {
if (!selectedSourceId || loadedSourceIds[selectedSourceId]) {
return;
}
void loadSource(selectedSourceId);
}, [selectedSourceId, loadedSourceIds, loadSource]);
const items = React.useMemo(() => {
if (!selectedSourceId) return [];
return itemsBySource[selectedSourceId] || [];
}, [itemsBySource, selectedSourceId]);
React.useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
searchInputRef.current?.focus();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
const isSearching = search.trim().length > 0;
const filtered = React.useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return items;
return items.filter((item) => {
const name = item.skillName.toLowerCase();
const desc = (item.description || '').toLowerCase();
const fm = (item.frontmatterName || '').toLowerCase();
return name.includes(q) || desc.includes(q) || fm.includes(q);
});
}, [items, search]);
const matches = (item: SkillsCatalogItem) =>
item.skillName.toLowerCase().includes(q)
|| (item.description || '').toLowerCase().includes(q)
|| (item.frontmatterName || '').toLowerCase().includes(q);
if (isSearching) {
return sources.flatMap((src) => (itemsBySource[src.id] || []).filter(matches));
}
if (!selectedSourceId) {
return [];
}
return itemsBySource[selectedSourceId] || [];
}, [sources, itemsBySource, selectedSourceId, search, isSearching]);
const selectedSource = React.useMemo(() => sources.find((s) => s.id === selectedSourceId) || null, [sources, selectedSourceId]);
const isCustomSource = Boolean(selectedSourceId && selectedSourceId.startsWith('custom:'));
const isClawdHubSource = selectedSource?.source === 'clawdhub:registry' || selectedSource?.sourceType === 'clawdhub';
const hasMoreClawdHub = Boolean(
selectedSourceId && (clawdhubHasMoreBySource[selectedSourceId] ?? true)
);
const removeSelectedCatalog = async () => {
if (!selectedSourceId || !isCustomSource) {
@@ -165,6 +302,17 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
}
};
const listTitle = isSearching
? t('settings.skills.catalog.page.list.searchTitle')
: (selectedSource?.label ?? '');
// The selected source has no items yet and a load is in flight — show the
// loading state instead of a stale list from the previously selected source.
const isSelectedSourceLoading = !isSearching
&& selectedSourceId !== null
&& !loadedSourceIds[selectedSourceId]
&& (isLoadingSource || isLoadingCatalog);
return (
<>
<SettingsPageLayout
@@ -190,90 +338,74 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
</div>
)}
<p className="typography-meta text-muted-foreground mb-4">
{t('settings.skills.catalog.page.subtitle')}
</p>
<div data-settings-item="skills.catalog.search" className="mb-5">
<div className="relative max-w-md">
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('settings.skills.catalog.page.searchAllPlaceholder')}
className={cn('h-8 pl-8 w-full', search && 'pr-8')}
/>
{search && (
<button
type="button"
onClick={() => {
setSearch('');
searchInputRef.current?.focus();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-4 w-4 rounded text-muted-foreground hover:text-foreground transition-colors"
title={t('settings.skills.catalog.page.search.clear')}
>
<Icon name="close" className="h-3 w-3" />
</button>
)}
</div>
</div>
<SettingsSection
title={t('settings.skills.catalog.page.section.sourceRepository')}
title={t('settings.skills.catalog.page.section.sources')}
divider={false}
settingsItem="skills.catalog.source"
contentClassName="space-y-0"
>
<div className="flex flex-wrap items-center gap-2 py-1.5">
<Select
value={selectedSourceId || ''}
onValueChange={(v) => setSelectedSource(v)}
>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'w-fit')}>
<SelectValue placeholder={t('settings.skills.catalog.page.field.selectSourcePlaceholder')}>
{selectedSource?.label}
</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{sources.map((src) => (
<SelectItem key={src.id} value={src.id}>
{src.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 py-1.5">
{sources.map((src) => (
<SourceCard
key={src.id}
source={src}
isActive={src.id === selectedSourceId}
isLoading={isLoadingSource && !loadedSourceIds[src.id]}
skillsCount={loadedSourceIds[src.id] ? (itemsBySource[src.id] || []).length : null}
onSelect={() => setSelectedSource(src.id)}
t={t}
/>
))}
<Button
variant="outline"
size="xs"
className="!font-normal h-6 w-6 px-0"
onClick={() => {
if (selectedSourceId) {
void loadSource(selectedSourceId, { refresh: true });
} else {
void loadCatalog({ refresh: true });
}
}}
disabled={isLoadingCatalog || isLoadingSource}
title={t('settings.skills.catalog.page.actions.refreshTitle')}
>
<Icon name="refresh" className={cn("h-3.5 w-3.5", (isLoadingCatalog || isLoadingSource) && "animate-spin")} />
</Button>
{isCustomSource && (
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => setIsRemoveCatalogDialogOpen(true)}
disabled={isRemovingCatalog}
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</Button>
)}
<Button
data-settings-item="skills.catalog.add-catalog"
size="xs"
className="!font-normal gap-1"
onClick={() => setAddCatalogOpen(true)}
>
<Icon name="add" className="h-3.5 w-3.5" /> {t('settings.skills.catalog.page.actions.addCatalog')}
</Button>
</div>
<div data-settings-item="skills.catalog.search" className="py-1.5">
<div className="relative">
<Icon name="search" className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')}
className="h-7 pl-8 w-full sm:w-64"
/>
</div>
<span className="typography-meta text-muted-foreground mt-1 block">
{isLoadingCatalog
? t('settings.skills.catalog.page.loading.catalog')
: t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
<button
type="button"
data-settings-item="skills.catalog.add-catalog"
onClick={() => setAddCatalogOpen(true)}
className="min-h-24 text-left rounded-lg border border-dashed border-[var(--surface-subtle)] hover:border-[var(--interactive-border-hover)] hover:bg-[var(--surface-muted)] p-3.5 flex gap-3 items-start transition-colors"
>
<span className="flex items-center justify-center rounded-md bg-transparent text-muted-foreground w-8 h-8 shrink-0">
<Icon name="add" className="h-4 w-4" />
</span>
</div>
<span className="min-w-0">
<span className="typography-ui-label text-muted-foreground block">
{t('settings.skills.catalog.page.source.addOwnTitle')}
</span>
<span className="typography-micro text-muted-foreground/70 block mt-0.5">
{t('settings.skills.catalog.page.source.addOwnDescription')}
</span>
</span>
</button>
</div>
</SettingsSection>
{lastCatalogError && (
@@ -286,21 +418,63 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
)}
<SettingsSection>
{filtered.length === 0 && !isLoadingSource ? (
<div className="py-8 text-center text-muted-foreground">
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
</div>
) : isLoadingSource ? (
<div className="flex items-center justify-between gap-2 pb-2">
<div className="flex items-center gap-2 min-w-0">
<span className="typography-micro font-medium uppercase tracking-wide text-muted-foreground truncate">
{listTitle}
</span>
<span className="typography-micro text-muted-foreground/70 shrink-0">
{t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0"
onClick={() => {
if (selectedSourceId && !isSearching) {
void loadSource(selectedSourceId, { refresh: true });
} else {
void loadCatalog({ refresh: true });
}
}}
disabled={isLoadingCatalog || isLoadingSource}
title={t('settings.skills.catalog.page.actions.refreshTitle')}
>
<Icon name="refresh" className={cn('h-3.5 w-3.5', (isLoadingCatalog || isLoadingSource) && 'animate-spin')} />
</Button>
{isCustomSource && !isSearching && (
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => setIsRemoveCatalogDialogOpen(true)}
disabled={isRemovingCatalog}
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{isSelectedSourceLoading || (isLoadingSource && filtered.length === 0) ? (
<div className="py-8 text-center text-muted-foreground">
<Icon name="refresh" className="mx-auto mb-3 h-5 w-5 animate-spin opacity-50" />
<p className="typography-meta">{t('settings.skills.catalog.page.loading.skills')}</p>
</div>
) : filtered.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
</div>
) : (
<div className="divide-y divide-[var(--surface-subtle)]">
{filtered.map((item) => {
const installed = item.installed?.isInstalled;
const installedScope = item.installed?.scope;
const skillUrl = getSkillUrl(item);
return (
<div key={`${item.sourceId}:${item.skillDir}`} className="py-2">
@@ -326,24 +500,28 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
<div className="typography-meta text-muted-foreground/50 mt-0.5 italic">{t('settings.skills.catalog.shared.noDescription')}</div>
)}
{item.clawdhub && (
<div className="typography-micro text-muted-foreground mt-1.5 flex items-center gap-3">
{item.clawdhub.owner && (
<span>{t('settings.skills.catalog.page.byOwnerPrefix')} <span className="font-medium text-foreground/80">{item.clawdhub.owner}</span></span>
)}
<span className="flex items-center gap-1">
<Icon name="download" className="h-3 w-3" />
{item.clawdhub.downloads?.toLocaleString() ?? 0}
</span>
{(item.clawdhub.stars ?? 0) > 0 && (
<span className="flex items-center gap-1">
<Icon name="star" className="h-3 w-3" />
{item.clawdhub.stars}
</span>
)}
<span className="bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">v{item.clawdhub.version}</span>
</div>
)}
<div className="typography-micro text-muted-foreground/80 mt-1 flex items-center gap-2 min-w-0">
{skillUrl ? (
<a
href={skillUrl}
target="_blank"
rel="noreferrer"
className="font-mono hover:underline truncate inline-flex items-center gap-1"
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
>
<Icon name="github" className="h-3 w-3 shrink-0" />
{item.repoSource}
</a>
) : (
<span className="font-mono truncate">{item.repoSource}</span>
)}
{item.skillDir && (
<>
<span className="opacity-40">·</span>
<span className="truncate">{item.skillDir}</span>
</>
)}
</div>
{item.warnings?.length ? (
<div className="typography-micro text-[var(--status-warning)] mt-1.5 bg-[var(--status-warning)]/10 px-2 py-1 rounded w-fit">
@@ -352,37 +530,43 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
) : null}
</div>
<Button
variant="outline"
size="xs"
className="!font-normal shrink-0"
disabled={!item.installable}
onClick={() => {
setInstallItem(item);
setInstallDialogOpen(true);
}}
>
{t('settings.skills.catalog.shared.actions.install')}
</Button>
<div className="flex items-center gap-1.5 shrink-0">
{skillUrl && (
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0"
onClick={() => window.open(skillUrl, '_blank', 'noreferrer')}
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
>
<Icon name="external-link" className="h-3.5 w-3.5" />
</Button>
)}
{installed ? (
<span className="text-[var(--status-success)] flex items-center justify-center w-7 h-7" title={t('settings.skills.catalog.page.badge.installed', { scope: installedScope || '' })}>
<Icon name="check" className="h-4 w-4" />
</span>
) : (
<Button
variant="outline"
size="xs"
className="!font-normal"
disabled={!item.installable}
onClick={() => {
setInstallItem(item);
setInstallDialogOpen(true);
}}
>
{t('settings.skills.catalog.shared.actions.install')}
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
<div className="flex justify-center mt-2">
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void loadMoreClawdHub()}
disabled={isLoadingMore}
>
{isLoadingMore ? t('settings.skills.catalog.page.loading.more') : t('settings.skills.catalog.page.actions.loadMoreSkills')}
</Button>
</div>
)}
</SettingsSection>
</SettingsPageLayout>