From 1ed3f1f5752480f09d17980b39bf1d0ce3c28efc Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 20 Aug 2026 01:40:10 +0300 Subject: [PATCH] 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. --- bun.lock | 10 +- .../docs/content/docs/de/skills-catalog.mdx | 2 +- .../docs/content/docs/es/skills-catalog.mdx | 2 +- .../docs/content/docs/fr/skills-catalog.mdx | 2 +- .../docs/content/docs/ja/skills-catalog.mdx | 2 +- .../docs/content/docs/ko/skills-catalog.mdx | 2 +- .../docs/content/docs/pl/skills-catalog.mdx | 2 +- .../content/docs/pt-br/skills-catalog.mdx | 2 +- packages/docs/content/docs/skills-catalog.mdx | 2 +- .../docs/content/docs/uk/skills-catalog.mdx | 2 +- .../content/docs/zh-cn/skills-catalog.mdx | 2 +- .../skills/catalog/InstallSkillDialog.tsx | 13 +- .../skills/catalog/SkillsCatalogPage.tsx | 514 ++++++++++++------ packages/ui/src/lib/api/types.ts | 27 +- .../ui/src/lib/i18n/messages/de.settings.ts | 15 +- .../ui/src/lib/i18n/messages/en.settings.ts | 15 +- .../ui/src/lib/i18n/messages/es.settings.ts | 15 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 15 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 15 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 15 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 15 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 15 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 15 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 15 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 15 +- ...seSkillsCatalogStore.clawhub-label.test.ts | 49 -- .../ui/src/stores/useSkillsCatalogStore.ts | 220 +++----- packages/vscode/src/skillsCatalog.ts | 197 +------ packages/web/package.json | 4 +- .../lib/opencode/feature-routes-runtime.js | 14 +- .../web/server/lib/opencode/skill-routes.js | 102 +--- .../server/lib/opencode/skill-routes.test.js | 7 +- .../lib/skills-catalog/DOCUMENTATION.md | 63 +-- .../web/server/lib/skills-catalog/cache.js | 121 ++++- .../server/lib/skills-catalog/cache.test.js | 77 +++ .../server/lib/skills-catalog/clawdhub/api.js | 126 ----- .../lib/skills-catalog/clawdhub/install.js | 238 -------- .../skills-catalog/clawdhub/install.test.js | 100 ---- .../lib/skills-catalog/clawdhub/scan.js | 61 --- .../lib/skills-catalog/curated-sources.js | 26 +- .../skills-catalog/curated-sources.test.js | 8 +- .../server/lib/skills-catalog/disk-cache.js | 52 ++ .../server/lib/skills-catalog/github-meta.js | 139 +++++ .../lib/skills-catalog/github-meta.test.js | 71 +++ .../web/server/lib/skills-catalog/source.js | 5 - 45 files changed, 1143 insertions(+), 1286 deletions(-) delete mode 100644 packages/ui/src/stores/useSkillsCatalogStore.clawhub-label.test.ts create mode 100644 packages/web/server/lib/skills-catalog/cache.test.js delete mode 100644 packages/web/server/lib/skills-catalog/clawdhub/api.js delete mode 100644 packages/web/server/lib/skills-catalog/clawdhub/install.js delete mode 100644 packages/web/server/lib/skills-catalog/clawdhub/install.test.js delete mode 100644 packages/web/server/lib/skills-catalog/clawdhub/scan.js create mode 100644 packages/web/server/lib/skills-catalog/disk-cache.js create mode 100644 packages/web/server/lib/skills-catalog/github-meta.js create mode 100644 packages/web/server/lib/skills-catalog/github-meta.test.js diff --git a/bun.lock b/bun.lock index dc2f7923..753e10c4 100644 --- a/bun.lock +++ b/bun.lock @@ -97,7 +97,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.18.4", + "version": "1.19.0", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -134,7 +134,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.18.4", + "version": "1.19.0", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -238,7 +238,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.18.4", + "version": "1.19.0", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "1.18.18", @@ -261,7 +261,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.18.4", + "version": "1.19.0", "bin": { "openchamber": "./bin/cli.js", }, @@ -270,7 +270,6 @@ "@octokit/rest": "^22.0.1", "@opencode-ai/sdk": "1.18.18", "@simplewebauthn/server": "13.3.1", - "adm-zip": "^0.6.0", "bun-pty": "^0.4.5", "compression": "^1.8.1", "cron-parser": "^4.9.0", @@ -306,7 +305,6 @@ "@remixicon/react": "^4.7.0", "@simplewebauthn/browser": "13.3.0", "@tailwindcss/postcss": "^4.0.0", - "@types/adm-zip": "^0.5.7", "@types/node": "^24.3.1", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", diff --git a/packages/docs/content/docs/de/skills-catalog.mdx b/packages/docs/content/docs/de/skills-catalog.mdx index afb5f31e..7026837b 100644 --- a/packages/docs/content/docs/de/skills-catalog.mdx +++ b/packages/docs/content/docs/de/skills-catalog.mdx @@ -12,7 +12,7 @@ Zum Schreiben eigener Skills siehe [Skills](/skills/). ## Einen Skill installieren 1. Öffne den Katalog. -2. Durchsuche die eingebauten Quellen — das Anthropic-Skills-Repo und die ClawHub-Community-Registry — oder nutze die Suche. +2. Durchsuche die eingebauten Quellen — wie das Anthropic-Skills-Repo — oder nutze die Suche. 3. Wähle einen Skill aus und installiere ihn. 4. Entscheide, wo er installiert werden soll: für alles, was du tust, oder nur für das aktuelle Projekt. diff --git a/packages/docs/content/docs/es/skills-catalog.mdx b/packages/docs/content/docs/es/skills-catalog.mdx index eadec080..c29ffb71 100644 --- a/packages/docs/content/docs/es/skills-catalog.mdx +++ b/packages/docs/content/docs/es/skills-catalog.mdx @@ -12,7 +12,7 @@ Para escribir tus propias skills, consulta [Skills](/es/skills/). ## Instala una skill 1. Abre el catálogo. -2. Explora las fuentes integradas —el repositorio de skills de Anthropic y el registro comunitario de ClawHub— o busca. +2. Explora las fuentes integradas —como el repositorio de skills de Anthropic— o busca. 3. Elige una skill e instálala. 4. Elige dónde instalarla: para todo lo que hagas, o solo en el proyecto actual. diff --git a/packages/docs/content/docs/fr/skills-catalog.mdx b/packages/docs/content/docs/fr/skills-catalog.mdx index 72123162..e6eb2323 100644 --- a/packages/docs/content/docs/fr/skills-catalog.mdx +++ b/packages/docs/content/docs/fr/skills-catalog.mdx @@ -12,7 +12,7 @@ Pour écrire vos propres skills, voir [Skills](/skills/). ## Installer un skill 1. Ouvrez le catalogue. -2. Parcourez les sources intégrées — le dépôt de skills Anthropic et le registre communautaire ClawHub — ou lancez une recherche. +2. Parcourez les sources intégrées — comme le dépôt de skills Anthropic — ou lancez une recherche. 3. Choisissez un skill et installez-le. 4. Choisissez où l’installer : pour tout ce que vous faites, ou seulement pour le projet actuel. diff --git a/packages/docs/content/docs/ja/skills-catalog.mdx b/packages/docs/content/docs/ja/skills-catalog.mdx index 8991f05f..029a0882 100644 --- a/packages/docs/content/docs/ja/skills-catalog.mdx +++ b/packages/docs/content/docs/ja/skills-catalog.mdx @@ -12,7 +12,7 @@ Skills Catalog では、自分で書く代わりに、他の人が公開した ## スキルをインストールする 1. カタログを開きます。 -2. 組み込みソース(Anthropic skills repo と ClawHub community registry)を閲覧するか、検索します。 +2. 組み込みソース(Anthropic skills repo など)を閲覧するか、検索します。 3. スキルを選び、インストールします。 4. インストール先を選びます。すべての作業で使うか、現在のプロジェクトだけで使うかです。 diff --git a/packages/docs/content/docs/ko/skills-catalog.mdx b/packages/docs/content/docs/ko/skills-catalog.mdx index ba16461a..d2cbb407 100644 --- a/packages/docs/content/docs/ko/skills-catalog.mdx +++ b/packages/docs/content/docs/ko/skills-catalog.mdx @@ -12,7 +12,7 @@ Skills Catalog를 사용하면 직접 작성하는 대신 다른 사람이 게 ## 스킬 설치하기 1. 카탈로그를 엽니다. -2. 내장된 소스(Anthropic 스킬 저장소와 ClawHub 커뮤니티 레지스트리)를 둘러보거나 검색합니다. +2. 내장된 소스(예: Anthropic 스킬 저장소)를 둘러보거나 검색합니다. 3. 스킬을 선택하고 설치합니다. 4. 설치 위치를 선택합니다. 모든 작업에 적용할지, 현재 프로젝트에만 적용할지 선택합니다. diff --git a/packages/docs/content/docs/pl/skills-catalog.mdx b/packages/docs/content/docs/pl/skills-catalog.mdx index d3544fa1..d38a842f 100644 --- a/packages/docs/content/docs/pl/skills-catalog.mdx +++ b/packages/docs/content/docs/pl/skills-catalog.mdx @@ -12,7 +12,7 @@ Aby pisać własne skille, zobacz [Skille](/pl/skills/). ## Zainstaluj skill 1. Otwórz katalog. -2. Przeglądaj wbudowane źródła — repozytorium skilli Anthropic oraz rejestr społeczności ClawHub — albo wyszukaj. +2. Przeglądaj wbudowane źródła — na przykład repozytorium skilli Anthropic — albo wyszukaj. 3. Wybierz skill i zainstaluj go. 4. Wybierz, gdzie go zainstalować: dla wszystkiego, co robisz, albo tylko dla bieżącego projektu. diff --git a/packages/docs/content/docs/pt-br/skills-catalog.mdx b/packages/docs/content/docs/pt-br/skills-catalog.mdx index eea841f4..b28c44f3 100644 --- a/packages/docs/content/docs/pt-br/skills-catalog.mdx +++ b/packages/docs/content/docs/pt-br/skills-catalog.mdx @@ -12,7 +12,7 @@ Para escrever suas próprias skills, veja [Skills](/pt-br/skills/). ## Instalar uma skill 1. Abra o catálogo. -2. Navegue pelas fontes integradas — o repositório de skills da Anthropic e o registro comunitário ClawHub — ou pesquise. +2. Navegue pelas fontes integradas — como o repositório de skills da Anthropic — ou pesquise. 3. Escolha uma skill e instale-a. 4. Escolha onde instalá-la: para tudo o que você faz, ou apenas no projeto atual. diff --git a/packages/docs/content/docs/skills-catalog.mdx b/packages/docs/content/docs/skills-catalog.mdx index 9ddcb131..e223a459 100644 --- a/packages/docs/content/docs/skills-catalog.mdx +++ b/packages/docs/content/docs/skills-catalog.mdx @@ -12,7 +12,7 @@ For writing your own skills, see [Skills](/skills/). ## Install a skill 1. Open the catalog. -2. Browse the built-in sources — the Anthropic skills repo and the ClawHub community registry — or search. +2. Browse the built-in sources — like the Anthropic skills repo — or search. 3. Pick a skill and install it. 4. Choose where to install it: for everything you do, or just the current project. diff --git a/packages/docs/content/docs/uk/skills-catalog.mdx b/packages/docs/content/docs/uk/skills-catalog.mdx index 78c0c1d6..585ad77a 100644 --- a/packages/docs/content/docs/uk/skills-catalog.mdx +++ b/packages/docs/content/docs/uk/skills-catalog.mdx @@ -12,7 +12,7 @@ description: Переглядайте та встановлюйте готові ## Встановлення навички 1. Відкрийте каталог. -2. Перегляньте вбудовані джерела — репозиторій навичок Anthropic та спільнотний реєстр ClawHub — або скористайтеся пошуком. +2. Перегляньте вбудовані джерела — наприклад репозиторій навичок Anthropic — або скористайтеся пошуком. 3. Оберіть навичку й установіть її. 4. Виберіть, куди встановити: для всього, що ви робите, чи лише для поточного проєкту. diff --git a/packages/docs/content/docs/zh-cn/skills-catalog.mdx b/packages/docs/content/docs/zh-cn/skills-catalog.mdx index f393944e..a3d982b1 100644 --- a/packages/docs/content/docs/zh-cn/skills-catalog.mdx +++ b/packages/docs/content/docs/zh-cn/skills-catalog.mdx @@ -12,7 +12,7 @@ Skills 目录让你能够安装其他人发布的 skill,而不必自己编写 ## 安装 skill 1. 打开目录。 -2. 浏览内置来源 — Anthropic skills 仓库和 ClawHub 社区注册表 — 或进行搜索。 +2. 浏览内置来源 — 例如 Anthropic skills 仓库 — 或进行搜索。 3. 选择一个 skill 并安装它。 4. 选择安装位置:用于你的所有工作,或仅用于当前项目。 diff --git a/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx b/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx index 60b7a0cf..802bd3f3 100644 --- a/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx +++ b/packages/ui/src/components/sections/skills/catalog/InstallSkillDialog.tsx @@ -127,24 +127,13 @@ export const InstallSkillDialog: React.FC = ({ open, on directoryOverride?: string | null; conflictDecisions?: Record; }) => { - // 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 }); diff --git a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx index 26218d76..d4fdd3e5 100644 --- a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx +++ b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx @@ -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 => { try { const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; @@ -71,6 +124,67 @@ const loadSettings = async (): Promise => { } }; +const SourceCard: React.FC<{ + source: SkillsCatalogSource; + isActive: boolean; + isLoading: boolean; + skillsCount: number | null; + onSelect: () => void; + t: ReturnType['t']; +}> = ({ source, isActive, isLoading, skillsCount, onSelect, t }) => { + const stars = source.stars ?? null; + const updated = source.repoUpdatedAt ? formatRelativeShort(source.repoUpdatedAt) : null; + + return ( + + ); +}; + export const SkillsCatalogPage: React.FC = ({ mode, onModeChange, showModeTabs = true }) => { const { t } = useI18n(); const { @@ -80,12 +194,9 @@ export const SkillsCatalogPage: React.FC = ({ 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 = ({ 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 = ({ mode, onMo const [installItem, setInstallItem] = React.useState(null); const [isRemovingCatalog, setIsRemovingCatalog] = React.useState(false); const [isRemoveCatalogDialogOpen, setIsRemoveCatalogDialogOpen] = React.useState(false); + const searchInputRef = React.useRef(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 = ({ 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 ( <> = ({ mode, onMo )} +

+ {t('settings.skills.catalog.page.subtitle')} +

- +
+
+ + setSearch(e.target.value)} + placeholder={t('settings.skills.catalog.page.searchAllPlaceholder')} + className={cn('h-8 pl-8 w-full', search && 'pr-8')} + /> + {search && ( + + )} +
+
-
- +
+ {sources.map((src) => ( + setSelectedSource(src.id)} + t={t} + /> + ))} - - - {isCustomSource && ( - - )} - - -
- -
-
- - setSearch(e.target.value)} - placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')} - className="h-7 pl-8 w-full sm:w-64" - /> -
- - {isLoadingCatalog - ? t('settings.skills.catalog.page.loading.catalog') - : t('settings.skills.catalog.page.foundCount', { count: filtered.length })} +
+ + + {t('settings.skills.catalog.page.source.addOwnTitle')} + + + {t('settings.skills.catalog.page.source.addOwnDescription')} + + + +
{lastCatalogError && ( @@ -286,21 +418,63 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo )} - {filtered.length === 0 && !isLoadingSource ? ( -
-

{t('settings.skills.catalog.page.empty.noSkillsTitle')}

-

{t('settings.skills.catalog.page.empty.noSkillsDescription')}

-
- ) : isLoadingSource ? ( +
+
+ + {listTitle} + + + {t('settings.skills.catalog.page.foundCount', { count: filtered.length })} + +
+
+ + {isCustomSource && !isSearching && ( + + )} +
+
+ + {isSelectedSourceLoading || (isLoadingSource && filtered.length === 0) ? (

{t('settings.skills.catalog.page.loading.skills')}

+ ) : filtered.length === 0 ? ( +
+

{t('settings.skills.catalog.page.empty.noSkillsTitle')}

+

{t('settings.skills.catalog.page.empty.noSkillsDescription')}

+
) : (
{filtered.map((item) => { const installed = item.installed?.isInstalled; const installedScope = item.installed?.scope; + const skillUrl = getSkillUrl(item); return (
@@ -326,24 +500,28 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo
{t('settings.skills.catalog.shared.noDescription')}
)} - {item.clawdhub && ( -
- {item.clawdhub.owner && ( - {t('settings.skills.catalog.page.byOwnerPrefix')} {item.clawdhub.owner} - )} - - - {item.clawdhub.downloads?.toLocaleString() ?? 0} - - {(item.clawdhub.stars ?? 0) > 0 && ( - - - {item.clawdhub.stars} - - )} - v{item.clawdhub.version} -
- )} +
+ {skillUrl ? ( + + + {item.repoSource} + + ) : ( + {item.repoSource} + )} + {item.skillDir && ( + <> + · + {item.skillDir} + + )} +
{item.warnings?.length ? (
@@ -352,37 +530,43 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo ) : null}
- +
+ {skillUrl && ( + + )} + {installed ? ( + + + + ) : ( + + )} +
); })} )} - {isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && ( -
- -
- )}
diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 58f126cb..98944c6b 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -1247,7 +1247,7 @@ export type RuntimeAPISelector = (apis: RuntimeAPIs) => TValue; type SkillsCatalogSourceId = string; -type SkillsCatalogSourceType = 'github' | 'clawdhub'; +type SkillsCatalogSourceType = 'github'; export interface SkillsCatalogSource { id: SkillsCatalogSourceId; @@ -1256,6 +1256,10 @@ export interface SkillsCatalogSource { source: string; defaultSubpath?: string; sourceType?: SkillsCatalogSourceType; + /** GitHub repository star count (null when unavailable) */ + stars?: number | null; + /** GitHub repository last-push timestamp, ISO (null when unavailable) */ + repoUpdatedAt?: string | null; } interface SkillsCatalogItemInstalledBadge { @@ -1264,18 +1268,6 @@ interface SkillsCatalogItemInstalledBadge { source?: 'opencode' | 'agents' | 'claude'; } -interface ClawdHubSkillMetadata { - slug: string; - version: string; - displayName?: string; - owner?: string; - downloads?: number; - stars?: number; - versionsCount?: number; - createdAt?: number; - updatedAt?: number; -} - export interface SkillsCatalogItem { sourceId: SkillsCatalogSourceId; repoSource: string; @@ -1288,22 +1280,18 @@ export interface SkillsCatalogItem { installable: boolean; warnings?: string[]; installed?: SkillsCatalogItemInstalledBadge; - /** ClawdHub-specific metadata (present only for ClawdHub sources) */ - clawdhub?: ClawdHubSkillMetadata; } export interface SkillsCatalogResponse { ok: boolean; sources?: SkillsCatalogSource[]; itemsBySource?: Record; - pageInfoBySource?: Record; error?: { kind: string; message: string }; } export interface SkillsCatalogSourceResponse { ok: boolean; items?: SkillsCatalogItem[]; - nextCursor?: string | null; error?: { kind: string; message: string }; } @@ -1328,11 +1316,6 @@ export interface SkillsRepoScanResponse { interface SkillsInstallSelection { skillDir: string; - /** ClawdHub-specific metadata for installation */ - clawdhub?: { - slug: string; - version: string; - }; } export interface SkillsInstallRequest { diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 2d771338..02ee5eeb 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -845,16 +845,26 @@ export const settingsDict = { 'settings.skills.catalog.page.mode.manual': 'Manuell', 'settings.skills.catalog.page.mode.external': 'Extern', 'settings.skills.catalog.page.title': 'Fähigkeitskatalog', + 'settings.skills.catalog.page.subtitle': 'Installiere fertige Skills aus kuratierten Repositories oder füge eine eigene Quelle hinzu.', + 'settings.skills.catalog.page.section.sources': 'Quellen', + 'settings.skills.catalog.page.searchAllPlaceholder': 'Skills in allen Quellen suchen…', + 'settings.skills.catalog.page.search.clear': 'Suche löschen', + 'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}', + 'settings.skills.catalog.page.source.stars': 'Sterne: {count}', + 'settings.skills.catalog.page.source.updated': 'Aktualisiert {time}', + 'settings.skills.catalog.page.source.addOwnTitle': 'Eigene Quelle hinzufügen', + 'settings.skills.catalog.page.source.addOwnDescription': 'Beliebiges Git-Repository mit Skills', + 'settings.skills.catalog.page.source.viewRepo': 'Repository auf GitHub öffnen', + 'settings.skills.catalog.page.skill.viewOnGithub': 'Skill auf GitHub ansehen', + 'settings.skills.catalog.page.list.searchTitle': 'Suchergebnisse', 'settings.skills.catalog.page.section.sourceRepository': 'Quell-Repository', 'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Quelle auswählen', 'settings.skills.catalog.page.actions.refreshTitle': 'Aktualisieren', 'settings.skills.catalog.page.actions.removeCatalogTitle': 'Katalog entfernen', 'settings.skills.catalog.page.actions.addCatalog': 'Katalog hinzufügen', 'settings.skills.catalog.page.actions.removeCatalog': 'Katalog entfernen', - 'settings.skills.catalog.page.actions.loadMoreSkills': 'Weitere Fähigkeiten laden', 'settings.skills.catalog.page.loading.catalog': 'Wird geladen...', 'settings.skills.catalog.page.loading.skills': 'Fähigkeiten werden geladen...', - 'settings.skills.catalog.page.loading.more': 'Wird geladen...', 'settings.skills.catalog.page.foundCount': '{count} Fähigkeit(en) gefunden', 'settings.skills.catalog.page.error.catalogTitle': 'Katalogfehler', 'settings.skills.catalog.page.empty.noSkillsTitle': 'Keine Fähigkeiten gefunden', @@ -862,7 +872,6 @@ export const settingsDict = { 'settings.skills.catalog.page.badge.installed': 'installiert ({scope})', 'settings.skills.catalog.page.badge.notInstallable': 'nicht installierbar', 'settings.skills.catalog.page.badge.unknown': 'unbekannt', - 'settings.skills.catalog.page.byOwnerPrefix': 'von', 'settings.skills.catalog.page.removeDialog.title': 'Katalog entfernen', 'settings.skills.catalog.page.removeDialog.description': 'Sind Sie sicher, dass Sie diesen Katalog entfernen möchten?', 'settings.openchamber.passkeys.title': 'Passkeys', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 2853e0b7..5e58a605 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -897,16 +897,26 @@ export const settingsDict = { 'settings.skills.catalog.page.mode.manual': 'Manual', 'settings.skills.catalog.page.mode.external': 'External', 'settings.skills.catalog.page.title': 'Skills Catalog', + 'settings.skills.catalog.page.subtitle': 'Install ready-made skills from curated repositories, or add your own source.', + 'settings.skills.catalog.page.section.sources': 'Sources', + 'settings.skills.catalog.page.searchAllPlaceholder': 'Search skills across all sources…', + 'settings.skills.catalog.page.search.clear': 'Clear search', + 'settings.skills.catalog.page.source.skillsCount': '{count} skills', + 'settings.skills.catalog.page.source.stars': '{count} stars', + 'settings.skills.catalog.page.source.updated': 'Updated {time}', + 'settings.skills.catalog.page.source.addOwnTitle': 'Add your own source', + 'settings.skills.catalog.page.source.addOwnDescription': 'Any Git repository with skills', + 'settings.skills.catalog.page.source.viewRepo': 'Open repository on GitHub', + 'settings.skills.catalog.page.skill.viewOnGithub': 'View skill on GitHub', + 'settings.skills.catalog.page.list.searchTitle': 'Search results', 'settings.skills.catalog.page.section.sourceRepository': 'Source Repository', 'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Select source', 'settings.skills.catalog.page.actions.refreshTitle': 'Refresh', 'settings.skills.catalog.page.actions.removeCatalogTitle': 'Remove Catalog', 'settings.skills.catalog.page.actions.addCatalog': 'Add Catalog', 'settings.skills.catalog.page.actions.removeCatalog': 'Remove Catalog', - 'settings.skills.catalog.page.actions.loadMoreSkills': 'Load More Skills', 'settings.skills.catalog.page.loading.catalog': 'Loading...', 'settings.skills.catalog.page.loading.skills': 'Loading skills...', - 'settings.skills.catalog.page.loading.more': 'Loading...', 'settings.skills.catalog.page.foundCount': '{count} skill(s) found', 'settings.skills.catalog.page.error.catalogTitle': 'Catalog error', 'settings.skills.catalog.page.empty.noSkillsTitle': 'No skills found', @@ -914,7 +924,6 @@ export const settingsDict = { 'settings.skills.catalog.page.badge.installed': 'installed ({scope})', 'settings.skills.catalog.page.badge.notInstallable': 'not installable', 'settings.skills.catalog.page.badge.unknown': 'unknown', - 'settings.skills.catalog.page.byOwnerPrefix': 'by', 'settings.skills.catalog.page.removeDialog.title': 'Remove Catalog', 'settings.skills.catalog.page.removeDialog.description': 'Are you sure you want to remove this catalog?', 'settings.openchamber.passkeys.title': 'Passkeys', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 85629ae7..bd06384c 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -865,16 +865,26 @@ export const settingsDict = { "settings.skills.catalog.page.mode.manual": "Manual", "settings.skills.catalog.page.mode.external": "Externo", "settings.skills.catalog.page.title": "Catálogo de habilidades", + 'settings.skills.catalog.page.subtitle': 'Instala skills listos desde repositorios curados o añade tu propia fuente.', + 'settings.skills.catalog.page.section.sources': 'Fuentes', + 'settings.skills.catalog.page.searchAllPlaceholder': 'Buscar skills en todas las fuentes…', + 'settings.skills.catalog.page.search.clear': 'Borrar búsqueda', + 'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}', + 'settings.skills.catalog.page.source.stars': 'Estrellas: {count}', + 'settings.skills.catalog.page.source.updated': 'Actualizado {time}', + 'settings.skills.catalog.page.source.addOwnTitle': 'Añadir tu propia fuente', + 'settings.skills.catalog.page.source.addOwnDescription': 'Cualquier repositorio Git con skills', + 'settings.skills.catalog.page.source.viewRepo': 'Abrir repositorio en GitHub', + 'settings.skills.catalog.page.skill.viewOnGithub': 'Ver skill en GitHub', + 'settings.skills.catalog.page.list.searchTitle': 'Resultados de búsqueda', "settings.skills.catalog.page.section.sourceRepository": "Repositorio de origen", "settings.skills.catalog.page.field.selectSourcePlaceholder": "Seleccionar origen", "settings.skills.catalog.page.actions.refreshTitle": "Actualizar", "settings.skills.catalog.page.actions.removeCatalogTitle": "Eliminar catálogo", "settings.skills.catalog.page.actions.addCatalog": "Añadir catálogo", "settings.skills.catalog.page.actions.removeCatalog": "Eliminar catálogo", - "settings.skills.catalog.page.actions.loadMoreSkills": "Cargar más habilidades", "settings.skills.catalog.page.loading.catalog": "Cargando...", "settings.skills.catalog.page.loading.skills": "Cargando habilidades...", - "settings.skills.catalog.page.loading.more": "Cargando...", "settings.skills.catalog.page.foundCount": "{count} habilidad(es) encontrada(s)", "settings.skills.catalog.page.error.catalogTitle": "Error del catálogo", "settings.skills.catalog.page.empty.noSkillsTitle": "No se encontraron habilidades", @@ -882,7 +892,6 @@ export const settingsDict = { "settings.skills.catalog.page.badge.installed": "instalado ({scope})", "settings.skills.catalog.page.badge.notInstallable": "no instalable", "settings.skills.catalog.page.badge.unknown": "desconocido", - "settings.skills.catalog.page.byOwnerPrefix": "por", "settings.skills.catalog.page.removeDialog.title": "Eliminar catálogo", "settings.skills.catalog.page.removeDialog.description": "¿Estás seguro de que quieres eliminar este catálogo?", "settings.openchamber.passkeys.title": "Claves de paso", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 635db3e1..b75e1c35 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -783,16 +783,26 @@ export const settingsDict = { 'settings.skills.catalog.page.mode.manual': 'Manuel', 'settings.skills.catalog.page.mode.external': 'Externe', 'settings.skills.catalog.page.title': 'Catalogue de skills', + 'settings.skills.catalog.page.subtitle': "Installez des skills prêts à l'emploi depuis des dépôts curatés ou ajoutez votre propre source.", + 'settings.skills.catalog.page.section.sources': 'Sources', + 'settings.skills.catalog.page.searchAllPlaceholder': 'Rechercher des skills dans toutes les sources…', + 'settings.skills.catalog.page.search.clear': 'Effacer la recherche', + 'settings.skills.catalog.page.source.skillsCount': 'Skills : {count}', + 'settings.skills.catalog.page.source.stars': 'Étoiles : {count}', + 'settings.skills.catalog.page.source.updated': 'Mis à jour {time}', + 'settings.skills.catalog.page.source.addOwnTitle': 'Ajouter votre propre source', + 'settings.skills.catalog.page.source.addOwnDescription': "N'importe quel dépôt Git avec des skills", + 'settings.skills.catalog.page.source.viewRepo': 'Ouvrir le dépôt sur GitHub', + 'settings.skills.catalog.page.skill.viewOnGithub': 'Voir le skill sur GitHub', + 'settings.skills.catalog.page.list.searchTitle': 'Résultats de recherche', 'settings.skills.catalog.page.section.sourceRepository': 'Dépôt source', 'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Sélectionnez la source', 'settings.skills.catalog.page.actions.refreshTitle': 'Rafraîchir', 'settings.skills.catalog.page.actions.removeCatalogTitle': 'Supprimer le catalogue', 'settings.skills.catalog.page.actions.addCatalog': 'Ajouter un catalogue', 'settings.skills.catalog.page.actions.removeCatalog': 'Supprimer le catalogue', - 'settings.skills.catalog.page.actions.loadMoreSkills': 'Charger plus de skills', 'settings.skills.catalog.page.loading.catalog': 'Chargement...', 'settings.skills.catalog.page.loading.skills': 'Chargement des skills...', - 'settings.skills.catalog.page.loading.more': 'Chargement...', 'settings.skills.catalog.page.foundCount': '{count} skill(s) trouvé(s)', 'settings.skills.catalog.page.error.catalogTitle': 'Erreur de catalogue', 'settings.skills.catalog.page.empty.noSkillsTitle': 'Aucun skill trouvé', @@ -800,7 +810,6 @@ export const settingsDict = { 'settings.skills.catalog.page.badge.installed': 'installé ({scope})', 'settings.skills.catalog.page.badge.notInstallable': 'non installable', 'settings.skills.catalog.page.badge.unknown': 'inconnu', - 'settings.skills.catalog.page.byOwnerPrefix': 'par', 'settings.skills.catalog.page.removeDialog.title': 'Supprimer le catalogue', 'settings.skills.catalog.page.removeDialog.description': 'Êtes-vous sûr de vouloir supprimer ce catalogue ?', 'settings.openchamber.passkeys.title': 'Mots-clés', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 96b21151..00319089 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -898,16 +898,26 @@ export const settingsDict = { 'settings.skills.catalog.page.mode.manual': '手動', 'settings.skills.catalog.page.mode.external': '外部', 'settings.skills.catalog.page.title': 'スキルカタログ', + 'settings.skills.catalog.page.subtitle': 'キュレーションされたリポジトリからすぐ使えるスキルをインストール、または独自のソースを追加。', + 'settings.skills.catalog.page.section.sources': 'ソース', + 'settings.skills.catalog.page.searchAllPlaceholder': 'すべてのソースのスキルを検索…', + 'settings.skills.catalog.page.search.clear': '検索をクリア', + 'settings.skills.catalog.page.source.skillsCount': 'スキル数: {count}', + 'settings.skills.catalog.page.source.stars': 'スター: {count}', + 'settings.skills.catalog.page.source.updated': '更新: {time}', + 'settings.skills.catalog.page.source.addOwnTitle': '独自のソースを追加', + 'settings.skills.catalog.page.source.addOwnDescription': 'スキルを含む任意の Git リポジトリ', + 'settings.skills.catalog.page.source.viewRepo': 'GitHub でリポジトリを開く', + 'settings.skills.catalog.page.skill.viewOnGithub': 'GitHub でスキルを表示', + 'settings.skills.catalog.page.list.searchTitle': '検索結果', 'settings.skills.catalog.page.section.sourceRepository': 'ソースリポジトリ', 'settings.skills.catalog.page.field.selectSourcePlaceholder': 'ソースを選択', 'settings.skills.catalog.page.actions.refreshTitle': '更新', 'settings.skills.catalog.page.actions.removeCatalogTitle': 'カタログを削除', 'settings.skills.catalog.page.actions.addCatalog': 'カタログを追加', 'settings.skills.catalog.page.actions.removeCatalog': 'カタログを削除', - 'settings.skills.catalog.page.actions.loadMoreSkills': 'さらに Skill を読み込む', 'settings.skills.catalog.page.loading.catalog': '読み込み中...', 'settings.skills.catalog.page.loading.skills': 'Skill を読み込み中...', - 'settings.skills.catalog.page.loading.more': '読み込み中...', 'settings.skills.catalog.page.foundCount': '{count} 個の Skill が見つかりました', 'settings.skills.catalog.page.error.catalogTitle': 'カタログエラー', 'settings.skills.catalog.page.empty.noSkillsTitle': 'Skill が見つかりません', @@ -915,7 +925,6 @@ export const settingsDict = { 'settings.skills.catalog.page.badge.installed': 'インストール済み ({scope})', 'settings.skills.catalog.page.badge.notInstallable': 'インストール不可', 'settings.skills.catalog.page.badge.unknown': '不明', - 'settings.skills.catalog.page.byOwnerPrefix': '提供', 'settings.skills.catalog.page.removeDialog.title': 'カタログを削除', 'settings.skills.catalog.page.removeDialog.description': 'このカタログを削除してもよろしいですか?', 'settings.openchamber.passkeys.title': 'パスキー', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index b76b0098..74c6bc98 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -865,16 +865,26 @@ export const settingsDict = { 'settings.skills.catalog.page.mode.manual': '수동', 'settings.skills.catalog.page.mode.external': 'External', 'settings.skills.catalog.page.title': '스킬 카탈로그', + 'settings.skills.catalog.page.subtitle': '선별된 저장소에서 바로 사용 가능한 스킬을 설치하거나 직접 소스를 추가하세요.', + 'settings.skills.catalog.page.section.sources': '소스', + 'settings.skills.catalog.page.searchAllPlaceholder': '모든 소스에서 스킬 검색…', + 'settings.skills.catalog.page.search.clear': '검색 지우기', + 'settings.skills.catalog.page.source.skillsCount': '스킬: {count}개', + 'settings.skills.catalog.page.source.stars': '스타: {count}', + 'settings.skills.catalog.page.source.updated': '업데이트: {time}', + 'settings.skills.catalog.page.source.addOwnTitle': '직접 소스 추가', + 'settings.skills.catalog.page.source.addOwnDescription': '스킬이 있는 아무 Git 저장소', + 'settings.skills.catalog.page.source.viewRepo': 'GitHub에서 저장소 열기', + 'settings.skills.catalog.page.skill.viewOnGithub': 'GitHub에서 스킬 보기', + 'settings.skills.catalog.page.list.searchTitle': '검색 결과', 'settings.skills.catalog.page.section.sourceRepository': '카탈로그 저장소', 'settings.skills.catalog.page.field.selectSourcePlaceholder': '저장소 선택', 'settings.skills.catalog.page.actions.refreshTitle': '새로고침', 'settings.skills.catalog.page.actions.removeCatalogTitle': 'Catalog 제거', 'settings.skills.catalog.page.actions.addCatalog': 'Catalog 추가', 'settings.skills.catalog.page.actions.removeCatalog': 'Catalog 제거', - 'settings.skills.catalog.page.actions.loadMoreSkills': '스킬 더 불러오기', 'settings.skills.catalog.page.loading.catalog': '로딩 중...', 'settings.skills.catalog.page.loading.skills': '스킬 불러오는 중...', - 'settings.skills.catalog.page.loading.more': '로딩 중...', 'settings.skills.catalog.page.foundCount': '스킬 {count}개 발견', 'settings.skills.catalog.page.error.catalogTitle': 'Catalog 오류', 'settings.skills.catalog.page.empty.noSkillsTitle': '스킬을 찾을 수 없습니다', @@ -882,7 +892,6 @@ export const settingsDict = { 'settings.skills.catalog.page.badge.installed': '설치됨({scope})', 'settings.skills.catalog.page.badge.notInstallable': '설치할 수 없음', 'settings.skills.catalog.page.badge.unknown': '알 수 없음', - 'settings.skills.catalog.page.byOwnerPrefix': '작성자', 'settings.skills.catalog.page.removeDialog.title': 'Catalog 제거', 'settings.skills.catalog.page.removeDialog.description': '이 카탈로그를 제거하시겠습니까?', 'settings.openchamber.passkeys.title': 'Passkeys', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 7e352832..9092163f 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1825,21 +1825,18 @@ export const settingsDict = { 'settings.skills.catalog.installSkill.toast.installFailed': 'Nie udało się zainstalować umiejętności', 'settings.skills.catalog.installSkill.toast.installed': 'Umiejętność została zainstalowana', 'settings.skills.catalog.page.actions.addCatalog': 'Dodaj katalog', - 'settings.skills.catalog.page.actions.loadMoreSkills': 'Załaduj więcej umiejętności', 'settings.skills.catalog.page.actions.refreshTitle': 'Odśwież', 'settings.skills.catalog.page.actions.removeCatalog': 'Usuń katalog', 'settings.skills.catalog.page.actions.removeCatalogTitle': 'Usuń katalog', 'settings.skills.catalog.page.badge.installed': 'zainstalowano ({scope})', 'settings.skills.catalog.page.badge.notInstallable': 'nie można zainstalować', 'settings.skills.catalog.page.badge.unknown': 'nieznane', - 'settings.skills.catalog.page.byOwnerPrefix': 'autor:', 'settings.skills.catalog.page.empty.noSkillsDescription': 'Spróbuj innego wyszukiwania lub odśwież katalog', 'settings.skills.catalog.page.empty.noSkillsTitle': 'Nie znaleziono umiejętności', 'settings.skills.catalog.page.error.catalogTitle': 'Błąd katalogu', 'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Wybierz źródło', 'settings.skills.catalog.page.foundCount': 'Znaleziono {count} umiejętności', 'settings.skills.catalog.page.loading.catalog': 'Ładowanie...', - 'settings.skills.catalog.page.loading.more': 'Ładowanie...', 'settings.skills.catalog.page.loading.skills': 'Ładowanie umiejętności...', 'settings.skills.catalog.page.mode.external': 'Zewnętrzny', 'settings.skills.catalog.page.mode.manual': 'Ręczny', @@ -1847,6 +1844,18 @@ export const settingsDict = { 'settings.skills.catalog.page.removeDialog.title': 'Usuń katalog', 'settings.skills.catalog.page.section.sourceRepository': 'Repozytorium źródłowe', 'settings.skills.catalog.page.title': 'Katalog umiejętności', + 'settings.skills.catalog.page.subtitle': 'Instaluj gotowe umiejętności z kuratorowanych repozytoriów lub dodaj własne źródło.', + 'settings.skills.catalog.page.section.sources': 'Źródła', + 'settings.skills.catalog.page.searchAllPlaceholder': 'Szukaj umiejętności we wszystkich źródłach…', + 'settings.skills.catalog.page.search.clear': 'Wyczyść wyszukiwanie', + 'settings.skills.catalog.page.source.skillsCount': 'Umiejętności: {count}', + 'settings.skills.catalog.page.source.stars': 'Gwiazdki: {count}', + 'settings.skills.catalog.page.source.updated': 'Zaktualizowano {time}', + 'settings.skills.catalog.page.source.addOwnTitle': 'Dodaj własne źródło', + 'settings.skills.catalog.page.source.addOwnDescription': 'Dowolne repozytorium Git z umiejętnościami', + 'settings.skills.catalog.page.source.viewRepo': 'Otwórz repozytorium na GitHubie', + 'settings.skills.catalog.page.skill.viewOnGithub': 'Zobacz umiejętność na GitHubie', + 'settings.skills.catalog.page.list.searchTitle': 'Wyniki wyszukiwania', 'settings.skills.catalog.shared.actions.install': 'Zainstaluj', 'settings.skills.catalog.shared.actions.installing': 'Instalowanie...', 'settings.skills.catalog.shared.actions.scan': 'Skanuj', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 8059055c..d85f354c 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -865,16 +865,26 @@ export const settingsDict = { "settings.skills.catalog.page.mode.manual": "Manual", "settings.skills.catalog.page.mode.external": "Externo", "settings.skills.catalog.page.title": "Catálogo de habilidades", + 'settings.skills.catalog.page.subtitle': 'Instale skills prontas de repositórios curados ou adicione sua própria fonte.', + 'settings.skills.catalog.page.section.sources': 'Fontes', + 'settings.skills.catalog.page.searchAllPlaceholder': 'Pesquisar skills em todas as fontes…', + 'settings.skills.catalog.page.search.clear': 'Limpar pesquisa', + 'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}', + 'settings.skills.catalog.page.source.stars': 'Estrelas: {count}', + 'settings.skills.catalog.page.source.updated': 'Atualizado {time}', + 'settings.skills.catalog.page.source.addOwnTitle': 'Adicionar sua própria fonte', + 'settings.skills.catalog.page.source.addOwnDescription': 'Qualquer repositório Git com skills', + 'settings.skills.catalog.page.source.viewRepo': 'Abrir repositório no GitHub', + 'settings.skills.catalog.page.skill.viewOnGithub': 'Ver skill no GitHub', + 'settings.skills.catalog.page.list.searchTitle': 'Resultados da pesquisa', "settings.skills.catalog.page.section.sourceRepository": "Repositório de origem", "settings.skills.catalog.page.field.selectSourcePlaceholder": "Selecionar origem", "settings.skills.catalog.page.actions.refreshTitle": "Atualizar", "settings.skills.catalog.page.actions.removeCatalogTitle": "Excluir catálogo", "settings.skills.catalog.page.actions.addCatalog": "Adicionar catálogo", "settings.skills.catalog.page.actions.removeCatalog": "Excluir catálogo", - "settings.skills.catalog.page.actions.loadMoreSkills": "Carregar mais habilidades", "settings.skills.catalog.page.loading.catalog": "Carregando...", "settings.skills.catalog.page.loading.skills": "Carregando habilidades...", - "settings.skills.catalog.page.loading.more": "Carregando...", "settings.skills.catalog.page.foundCount": "{count} habilidade(es) encontrada(s)", "settings.skills.catalog.page.error.catalogTitle": "Erro do catálogo", "settings.skills.catalog.page.empty.noSkillsTitle": "Nenhuma habilidade encontrada", @@ -882,7 +892,6 @@ export const settingsDict = { "settings.skills.catalog.page.badge.installed": "instalado ({scope})", "settings.skills.catalog.page.badge.notInstallable": "não instalável", "settings.skills.catalog.page.badge.unknown": "desconhecido", - "settings.skills.catalog.page.byOwnerPrefix": "por", "settings.skills.catalog.page.removeDialog.title": "Excluir catálogo", "settings.skills.catalog.page.removeDialog.description": "Tem certeza de que deseja excluir este catálogo?", "settings.openchamber.passkeys.title": "Chaves de acesso", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index f6c91f0a..efb4bfbe 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -865,16 +865,26 @@ export const settingsDict = { "settings.skills.catalog.page.mode.manual": "Вручну", "settings.skills.catalog.page.mode.external": "зовнішній", "settings.skills.catalog.page.title": "Каталог навичок", + 'settings.skills.catalog.page.subtitle': 'Встановлюйте готові скіли з курованих репозиторіїв або додайте власне джерело.', + 'settings.skills.catalog.page.section.sources': 'Джерела', + 'settings.skills.catalog.page.searchAllPlaceholder': 'Пошук скілів у всіх джерелах…', + 'settings.skills.catalog.page.search.clear': 'Очистити пошук', + 'settings.skills.catalog.page.source.skillsCount': 'Скілів: {count}', + 'settings.skills.catalog.page.source.stars': 'Зірок: {count}', + 'settings.skills.catalog.page.source.updated': 'Оновлено {time}', + 'settings.skills.catalog.page.source.addOwnTitle': 'Додати власне джерело', + 'settings.skills.catalog.page.source.addOwnDescription': 'Будь-який git-репозиторій зі скілами', + 'settings.skills.catalog.page.source.viewRepo': 'Відкрити репозиторій на GitHub', + 'settings.skills.catalog.page.skill.viewOnGithub': 'Переглянути скіл на GitHub', + 'settings.skills.catalog.page.list.searchTitle': 'Результати пошуку', "settings.skills.catalog.page.section.sourceRepository": "Репозиторій вихідного коду", "settings.skills.catalog.page.field.selectSourcePlaceholder": "Виберіть джерело", "settings.skills.catalog.page.actions.refreshTitle": "Оновити", "settings.skills.catalog.page.actions.removeCatalogTitle": "Видалити каталог", "settings.skills.catalog.page.actions.addCatalog": "Додати каталог", "settings.skills.catalog.page.actions.removeCatalog": "Видалити каталог", - "settings.skills.catalog.page.actions.loadMoreSkills": "Завантажити додаткові навички", "settings.skills.catalog.page.loading.catalog": "Завантаження...", "settings.skills.catalog.page.loading.skills": "Завантаження навичок...", - "settings.skills.catalog.page.loading.more": "Завантаження...", "settings.skills.catalog.page.foundCount": "Знайдено навички {count}", "settings.skills.catalog.page.error.catalogTitle": "Помилка каталогу", "settings.skills.catalog.page.empty.noSkillsTitle": "Навички не знайдено", @@ -882,7 +892,6 @@ export const settingsDict = { "settings.skills.catalog.page.badge.installed": "встановлено ({scope})", "settings.skills.catalog.page.badge.notInstallable": "не встановлюється", "settings.skills.catalog.page.badge.unknown": "невідомий", - "settings.skills.catalog.page.byOwnerPrefix": "за", "settings.skills.catalog.page.removeDialog.title": "Видалити каталог", "settings.skills.catalog.page.removeDialog.description": "Ви впевнені, що хочете видалити цей каталог?", "settings.openchamber.passkeys.title": "Ключі доступу", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index f8a7d85c..ee4a87bc 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -865,16 +865,26 @@ export const settingsDict = { 'settings.skills.catalog.page.mode.manual': '手动', 'settings.skills.catalog.page.mode.external': '外部', 'settings.skills.catalog.page.title': '技能目录', + 'settings.skills.catalog.page.subtitle': '从精选仓库安装现成技能,或添加你自己的来源。', + 'settings.skills.catalog.page.section.sources': '来源', + 'settings.skills.catalog.page.searchAllPlaceholder': '在所有来源中搜索技能…', + 'settings.skills.catalog.page.search.clear': '清除搜索', + 'settings.skills.catalog.page.source.skillsCount': '技能数:{count}', + 'settings.skills.catalog.page.source.stars': '星标:{count}', + 'settings.skills.catalog.page.source.updated': '更新于 {time}', + 'settings.skills.catalog.page.source.addOwnTitle': '添加自己的来源', + 'settings.skills.catalog.page.source.addOwnDescription': '任何包含技能的 Git 仓库', + 'settings.skills.catalog.page.source.viewRepo': '在 GitHub 上打开仓库', + 'settings.skills.catalog.page.skill.viewOnGithub': '在 GitHub 上查看技能', + 'settings.skills.catalog.page.list.searchTitle': '搜索结果', 'settings.skills.catalog.page.section.sourceRepository': '来源仓库', 'settings.skills.catalog.page.field.selectSourcePlaceholder': '选择来源', 'settings.skills.catalog.page.actions.refreshTitle': '刷新', 'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目录', 'settings.skills.catalog.page.actions.addCatalog': '添加目录', 'settings.skills.catalog.page.actions.removeCatalog': '移除目录', - 'settings.skills.catalog.page.actions.loadMoreSkills': '加载更多技能', 'settings.skills.catalog.page.loading.catalog': '加载中...', 'settings.skills.catalog.page.loading.skills': '正在加载技能...', - 'settings.skills.catalog.page.loading.more': '加载中...', 'settings.skills.catalog.page.foundCount': '找到 {count} 个技能', 'settings.skills.catalog.page.error.catalogTitle': '目录错误', 'settings.skills.catalog.page.empty.noSkillsTitle': '未找到技能', @@ -882,7 +892,6 @@ export const settingsDict = { 'settings.skills.catalog.page.badge.installed': '已安装({scope})', 'settings.skills.catalog.page.badge.notInstallable': '不可安装', 'settings.skills.catalog.page.badge.unknown': '未知', - 'settings.skills.catalog.page.byOwnerPrefix': '作者', 'settings.skills.catalog.page.removeDialog.title': '移除目录', 'settings.skills.catalog.page.removeDialog.description': '确定要移除此目录吗?', 'settings.openchamber.passkeys.title': 'Passkeys', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 8d7ab828..8a7241ae 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -862,16 +862,26 @@ export const settingsDict = { 'settings.skills.catalog.page.mode.manual': '手動', 'settings.skills.catalog.page.mode.external': '外部', 'settings.skills.catalog.page.title': 'Skills 目錄', + 'settings.skills.catalog.page.subtitle': '從精選儲存庫安裝現成技能,或新增你自己的來源。', + 'settings.skills.catalog.page.section.sources': '來源', + 'settings.skills.catalog.page.searchAllPlaceholder': '在所有來源中搜尋技能…', + 'settings.skills.catalog.page.search.clear': '清除搜尋', + 'settings.skills.catalog.page.source.skillsCount': '技能數:{count}', + 'settings.skills.catalog.page.source.stars': '星標:{count}', + 'settings.skills.catalog.page.source.updated': '更新於 {time}', + 'settings.skills.catalog.page.source.addOwnTitle': '新增自己的來源', + 'settings.skills.catalog.page.source.addOwnDescription': '任何包含技能的 Git 儲存庫', + 'settings.skills.catalog.page.source.viewRepo': '在 GitHub 上開啟儲存庫', + 'settings.skills.catalog.page.skill.viewOnGithub': '在 GitHub 上檢視技能', + 'settings.skills.catalog.page.list.searchTitle': '搜尋結果', 'settings.skills.catalog.page.section.sourceRepository': '來源儲存庫', 'settings.skills.catalog.page.field.selectSourcePlaceholder': '選擇來源', 'settings.skills.catalog.page.actions.refreshTitle': '重新整理', 'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目錄', 'settings.skills.catalog.page.actions.addCatalog': '新增目錄', 'settings.skills.catalog.page.actions.removeCatalog': '移除目錄', - 'settings.skills.catalog.page.actions.loadMoreSkills': '載入更多 Skills', 'settings.skills.catalog.page.loading.catalog': '載入中...', 'settings.skills.catalog.page.loading.skills': '正在載入 skills...', - 'settings.skills.catalog.page.loading.more': '載入中...', 'settings.skills.catalog.page.foundCount': '找到 {count} 個 skill(s)', 'settings.skills.catalog.page.error.catalogTitle': '目錄錯誤', 'settings.skills.catalog.page.empty.noSkillsTitle': '找不到 skills', @@ -879,7 +889,6 @@ export const settingsDict = { 'settings.skills.catalog.page.badge.installed': '已安裝({scope})', 'settings.skills.catalog.page.badge.notInstallable': '不可安裝', 'settings.skills.catalog.page.badge.unknown': '未知', - 'settings.skills.catalog.page.byOwnerPrefix': '作者', 'settings.skills.catalog.page.removeDialog.title': '移除目錄', 'settings.skills.catalog.page.removeDialog.description': '確定要移除此目錄嗎?', 'settings.openchamber.passkeys.title': 'Passkeys', diff --git a/packages/ui/src/stores/useSkillsCatalogStore.clawhub-label.test.ts b/packages/ui/src/stores/useSkillsCatalogStore.clawhub-label.test.ts deleted file mode 100644 index 5fbd101a..00000000 --- a/packages/ui/src/stores/useSkillsCatalogStore.clawhub-label.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from 'bun:test'; - -mock.module('@/lib/opencode/client', () => ({ - opencodeClient: { - getDirectory: () => undefined, - }, -})); - -mock.module('@/stores/useProjectsStore', () => ({ - useProjectsStore: { - getState: () => ({ - getActiveProject: () => null, - }), - }, -})); - -mock.module('@/lib/runtime-fetch', () => ({ - runtimeFetch: async () => new Response('{}', { status: 500 }), -})); - -mock.module('@/stores/useSkillsStore', () => ({ - invalidateSkillsLoadCache: () => undefined, - refreshSkillsAfterOpenCodeRestart: async () => undefined, - useSkillsStore: { - getState: () => ({}), - }, -})); - -mock.module('@/lib/configUpdate', () => ({ - startConfigUpdate: () => undefined, - finishConfigUpdate: () => undefined, - updateConfigUpdateMessage: () => undefined, -})); - -const { useSkillsCatalogStore } = await import('./useSkillsCatalogStore'); - -describe('skills catalog ClawHub label', () => { - beforeEach(() => { - useSkillsCatalogStore.setState({ - sources: useSkillsCatalogStore.getState().sources, - }); - }); - - test('fallback sources label ClawHub correctly', () => { - const clawhub = useSkillsCatalogStore.getState().sources.find((source) => source.id === 'clawdhub'); - expect(clawhub).toBeDefined(); - expect(clawhub?.label).toBe('ClawHub'); - }); -}); diff --git a/packages/ui/src/stores/useSkillsCatalogStore.ts b/packages/ui/src/stores/useSkillsCatalogStore.ts index 17100aae..2107676d 100644 --- a/packages/ui/src/stores/useSkillsCatalogStore.ts +++ b/packages/ui/src/stores/useSkillsCatalogStore.ts @@ -30,11 +30,27 @@ const FALLBACK_SOURCES: SkillsCatalogSource[] = [ sourceType: 'github', }, { - id: 'clawdhub', - label: 'ClawHub', - description: 'Community skill registry with vector search', - source: 'clawdhub:registry', - sourceType: 'clawdhub', + id: 'openai', + label: 'OpenAI', + description: "OpenAI's curated skills", + source: 'openai/skills', + defaultSubpath: 'skills/.curated', + sourceType: 'github', + }, + { + id: 'cursor', + label: 'Cursor', + description: "Cursor's plugin skills", + source: 'cursor/plugins', + defaultSubpath: 'pstack/skills', + sourceType: 'github', + }, + { + id: 'mattpocock', + label: 'Matt Pocock', + description: 'Matt Pocock skills collection', + source: 'mattpocock/skills', + sourceType: 'github', }, ]; @@ -42,6 +58,8 @@ const SKILLS_CATALOG_LOAD_CACHE_TTL_MS = 5000; const DEFAULT_SKILLS_CATALOG_CACHE_KEY = '__default__'; const skillsCatalogLastLoadedAt = new Map(); const skillsCatalogLoadInFlight = new Map>(); +const sourceLoadInFlight = new Map>(); +let activeSourceLoads = 0; const getSkillsCatalogCacheKey = (directory: string | null): string => { return directory?.trim() || DEFAULT_SKILLS_CATALOG_CACHE_KEY; @@ -71,13 +89,10 @@ export interface SkillsCatalogState { sources: SkillsCatalogSource[]; itemsBySource: Record; selectedSourceId: string | null; - pageInfoBySource: Record; loadedSourceIds: Record; - clawdhubHasMoreBySource: Record; isLoadingCatalog: boolean; isLoadingSource: boolean; - isLoadingMore: boolean; isScanning: boolean; isInstalling: boolean; @@ -91,7 +106,6 @@ export interface SkillsCatalogState { loadCatalog: (options?: { refresh?: boolean }) => Promise; loadSource: (sourceId: string, options?: { refresh?: boolean }) => Promise; - loadMoreClawdHub: () => Promise; scanRepo: (request: SkillsRepoScanRequest) => Promise; installSkills: (request: SkillsInstallRequest, options?: { directory?: string | null }) => Promise; } @@ -102,13 +116,10 @@ export const useSkillsCatalogStore = create()( sources: FALLBACK_SOURCES, itemsBySource: {}, selectedSourceId: FALLBACK_SOURCES[0]?.id ?? null, - pageInfoBySource: {}, loadedSourceIds: {}, - clawdhubHasMoreBySource: {}, isLoadingCatalog: false, isLoadingSource: false, - isLoadingMore: false, isScanning: false, isInstalling: false, @@ -141,9 +152,7 @@ export const useSkillsCatalogStore = create()( const previous = { sources: get().sources, itemsBySource: get().itemsBySource, - pageInfoBySource: get().pageInfoBySource, loadedSourceIds: get().loadedSourceIds, - clawdhubHasMoreBySource: get().clawdhubHasMoreBySource, }; let lastError: SkillsCatalogResponse['error'] | null = null; @@ -168,9 +177,7 @@ export const useSkillsCatalogStore = create()( const sources = (payload.sources && payload.sources.length > 0) ? payload.sources : previous.sources; const itemsBySource = options?.refresh ? {} : (get().itemsBySource || {}); - const pageInfoBySource = options?.refresh ? {} : (get().pageInfoBySource || {}); const loadedSourceIds = options?.refresh ? {} : (get().loadedSourceIds || {}); - const clawdhubHasMoreBySource = options?.refresh ? {} : (get().clawdhubHasMoreBySource || {}); const currentSelected = get().selectedSourceId; const selectedSourceId = (currentSelected && sources.some((s) => s.id === currentSelected)) @@ -180,9 +187,7 @@ export const useSkillsCatalogStore = create()( set({ sources, itemsBySource, - pageInfoBySource, loadedSourceIds, - clawdhubHasMoreBySource, selectedSourceId, }); @@ -197,9 +202,7 @@ export const useSkillsCatalogStore = create()( set({ sources: previous.sources, itemsBySource: previous.itemsBySource, - pageInfoBySource: previous.pageInfoBySource, loadedSourceIds: previous.loadedSourceIds, - clawdhubHasMoreBySource: previous.clawdhubHasMoreBySource, lastCatalogError: lastError || { kind: 'unknown', message: 'Failed to load catalog' }, }); @@ -222,136 +225,83 @@ export const useSkillsCatalogStore = create()( return false; } + // Deduplicate concurrent loads of the same source: the background + // loader effect can restart while a request for this source is + // already in flight. + if (!options?.refresh) { + const inFlight = sourceLoadInFlight.get(sourceId); + if (inFlight) { + return inFlight; + } + } + + activeSourceLoads += 1; set({ isLoadingSource: true, lastCatalogError: null }); - try { - const currentDirectory = getRequestDirectory(); - const refresh = options?.refresh ? '&refresh=true' : ''; - const queryParams = currentDirectory - ? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}` - : `?sourceId=${encodeURIComponent(sourceId)}${refresh}`; + const request = (async () => { + try { + const currentDirectory = getRequestDirectory(); + const refresh = options?.refresh ? '&refresh=true' : ''; + const queryParams = currentDirectory + ? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}` + : `?sourceId=${encodeURIComponent(sourceId)}${refresh}`; - const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null; - const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items); - if (!response.ok || (!payload?.ok && !hasItems)) { - const fallback = await runtimeFetch(`/api/config/skills/catalog${queryParams}`, { + const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, { method: 'GET', headers: { Accept: 'application/json' }, }); - const fallbackPayload = (await fallback.json().catch(() => null)) as SkillsCatalogResponse | null; - const fallbackItems = fallbackPayload?.itemsBySource?.[sourceId]; - if (fallback.ok && fallbackPayload?.ok && Array.isArray(fallbackItems)) { - set((state) => ({ - itemsBySource: { ...state.itemsBySource, [sourceId]: fallbackItems }, - pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor: null } }, - loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true }, - clawdhubHasMoreBySource: { ...state.clawdhubHasMoreBySource, [sourceId]: false }, - })); - return true; + + const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null; + const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items); + if (!response.ok || (!payload?.ok && !hasItems)) { + const fallback = await runtimeFetch(`/api/config/skills/catalog${queryParams}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const fallbackPayload = (await fallback.json().catch(() => null)) as SkillsCatalogResponse | null; + const fallbackItems = fallbackPayload?.itemsBySource?.[sourceId]; + if (fallback.ok && fallbackPayload?.ok && Array.isArray(fallbackItems)) { + set((state) => ({ + itemsBySource: { ...state.itemsBySource, [sourceId]: fallbackItems }, + loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true }, + })); + return true; + } + + set({ + lastCatalogError: payload?.error || { kind: 'unknown', message: `Failed to load source (${response.status})` }, + }); + return false; } + const items = payload?.items || []; + + set((state) => ({ + itemsBySource: { ...state.itemsBySource, [sourceId]: items }, + loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true }, + })); + + return true; + } catch (error) { set({ - lastCatalogError: payload?.error || { kind: 'unknown', message: `Failed to load source (${response.status})` }, + lastCatalogError: { kind: 'unknown', message: error instanceof Error ? error.message : String(error) }, }); return false; - } - - const items = payload?.items || []; - const nextCursor = payload?.nextCursor ?? null; - - set((state) => ({ - itemsBySource: { ...state.itemsBySource, [sourceId]: items }, - pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor } }, - loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true }, - clawdhubHasMoreBySource: { - ...state.clawdhubHasMoreBySource, - [sourceId]: items.length > 0, - }, - })); - - return true; - } catch (error) { - set({ - lastCatalogError: { kind: 'unknown', message: error instanceof Error ? error.message : String(error) }, - }); - return false; - } finally { - set({ isLoadingSource: false }); - } - }, - - loadMoreClawdHub: async () => { - const selectedSourceId = get().selectedSourceId; - if (!selectedSourceId) { - return false; - } - - const pageInfo = get().pageInfoBySource[selectedSourceId]; - const cursor = pageInfo?.nextCursor || null; - - set({ isLoadingMore: true }); - try { - const currentDirectory = getRequestDirectory(); - const parts = [`sourceId=${encodeURIComponent(selectedSourceId)}`]; - if (currentDirectory) { - parts.push(`directory=${encodeURIComponent(currentDirectory)}`); - } - if (cursor) { - parts.push(`cursor=${encodeURIComponent(cursor)}`); - } - const queryParams = `?${parts.join('&')}`; - - const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - - const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null; - if (!response.ok || !payload?.ok) { - return false; - } - - const nextCursor = payload.nextCursor ?? null; - const currentItems = get().itemsBySource[selectedSourceId] || []; - const items = payload.items || []; - const merged = new Map(currentItems.map((item) => [`${item.sourceId}:${item.skillDir}`, item])); - let newCount = 0; - - for (const item of items) { - const key = `${item.sourceId}:${item.skillDir}`; - if (!merged.has(key)) { - newCount += 1; + } finally { + activeSourceLoads -= 1; + if (activeSourceLoads === 0) { + set({ isLoadingSource: false }); } - merged.set(key, item); } + })(); - const noMore = items.length === 0 || newCount === 0; - - set((state) => ({ - itemsBySource: { - ...state.itemsBySource, - [selectedSourceId]: Array.from(merged.values()), - }, - pageInfoBySource: { - ...state.pageInfoBySource, - [selectedSourceId]: { nextCursor }, - }, - clawdhubHasMoreBySource: { - ...state.clawdhubHasMoreBySource, - [selectedSourceId]: !noMore, - }, - })); - - return true; - } catch { - return false; + sourceLoadInFlight.set(sourceId, request); + try { + return await request; } finally { - set({ isLoadingMore: false }); + if (sourceLoadInFlight.get(sourceId) === request) { + sourceLoadInFlight.delete(sourceId); + } } }, diff --git a/packages/vscode/src/skillsCatalog.ts b/packages/vscode/src/skillsCatalog.ts index cefd3512..f9a171f6 100644 --- a/packages/vscode/src/skillsCatalog.ts +++ b/packages/vscode/src/skillsCatalog.ts @@ -33,15 +33,6 @@ type SkillFrontmatter = { [key: string]: unknown; }; -type ClawdHubSkillMetadata = { - slug: string; - version: string; - displayName?: string; - owner?: string; - downloads?: number; - stars?: number; -}; - type SkillsCatalogItem = { repoSource: string; repoSubpath?: string; @@ -51,9 +42,7 @@ type SkillsCatalogItem = { description?: string; installable: boolean; warnings?: string[]; - clawdhub?: ClawdHubSkillMetadata; }; - type SkillsCatalogItemWithBadge = SkillsCatalogItem & { sourceId: string; installed: { isInstalled: boolean; scope?: SkillScope; source?: SkillInstallSource }; @@ -84,143 +73,27 @@ const CURATED_SOURCES: CuratedSource[] = [ defaultSubpath: 'skills', }, { - id: 'clawdhub', - label: 'ClawHub', - description: 'Community skill registry with vector search', - source: 'clawdhub:registry', + id: 'openai', + label: 'OpenAI', + description: "OpenAI's curated skills", + source: 'openai/skills', + defaultSubpath: 'skills/.curated', + }, + { + id: 'cursor', + label: 'Cursor', + description: "Cursor's plugin skills", + source: 'cursor/plugins', + defaultSubpath: 'pstack/skills', + }, + { + id: 'mattpocock', + label: 'Matt Pocock', + description: 'Matt Pocock skills collection', + source: 'mattpocock/skills', }, ]; -// ============== ClawdHub API ============== - -const CLAWDHUB_API_BASE = 'https://clawdhub.com/api/v1'; -const CLAWDHUB_PAGE_LIMIT = 25; -const CLAWDHUB_RATE_LIMIT_MS = 100; -let clawdhubLastRequest = 0; - -function isClawdHubSource(source: string): boolean { - return typeof source === 'string' && source.startsWith('clawdhub:'); -} - -async function clawdhubFetch(url: string, options?: RequestInit): Promise { - const maxAttempts = 10; - let lastResponse: Response | null = null; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const now = Date.now(); - const elapsed = now - clawdhubLastRequest; - if (elapsed < CLAWDHUB_RATE_LIMIT_MS) { - await new Promise((resolve) => setTimeout(resolve, CLAWDHUB_RATE_LIMIT_MS - elapsed)); - } - clawdhubLastRequest = Date.now(); - - const response = await fetch(url, { - ...options, - headers: { - Accept: 'application/json', - 'User-Agent': 'OpenChamber-VSCode/1.0', - ...options?.headers, - }, - }); - - lastResponse = response; - - if (response.status === 429 || response.status >= 500) { - if (attempt < maxAttempts - 1) { - const waitMs = 50 * (attempt + 1); - await new Promise((resolve) => setTimeout(resolve, waitMs)); - continue; - } - } - - return response; - } - - return lastResponse as Response; -} - -type ClawdHubSkillListItem = { - slug: string; - displayName?: string; - summary?: string; - tags?: { latest?: string }; - latestVersion?: { version?: string }; - stats?: { downloads?: number; stars?: number }; - owner?: { handle?: string }; -}; - -type ClawdHubSkillsResponse = { - items: ClawdHubSkillListItem[]; - nextCursor?: string; -}; - -async function scanClawdHub(): Promise { - try { - const allItems: SkillsCatalogItem[] = []; - let cursor: string | null = null; - const maxPages = 20; - - for (let page = 0; page < maxPages; page++) { - const url = cursor - ? `${CLAWDHUB_API_BASE}/skills?cursor=${encodeURIComponent(cursor)}&limit=${CLAWDHUB_PAGE_LIMIT}` - : `${CLAWDHUB_API_BASE}/skills?limit=${CLAWDHUB_PAGE_LIMIT}`; - - let data: ClawdHubSkillsResponse; - - try { - const response = await clawdhubFetch(url); - if (!response.ok) { - throw new Error(`ClawdHub API error: ${response.status}`); - } - - data = (await response.json()) as ClawdHubSkillsResponse; - } catch (error) { - if (page > 0 && allItems.length > 0) { - break; - } - throw error; - } - - for (const item of data.items || []) { - const latestVersion = item.tags?.latest || item.latestVersion?.version || '1.0.0'; - - allItems.push({ - repoSource: 'clawdhub:registry', - skillDir: item.slug, - skillName: item.slug, - frontmatterName: item.displayName || item.slug, - description: item.summary || undefined, - installable: true, - clawdhub: { - slug: item.slug, - version: latestVersion, - displayName: item.displayName, - owner: item.owner?.handle, - downloads: item.stats?.downloads || 0, - stars: item.stats?.stars || 0, - }, - }); - } - - if (!data.nextCursor) break; - cursor = data.nextCursor; - } - - // Sort by downloads (most popular first) - allItems.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0)); - - return { ok: true, items: allItems }; - } catch (error) { - return { - ok: false, - error: { - kind: 'networkError', - message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub', - }, - }; - } -} - function validateSkillName(skillName: string): boolean { if (skillName.length < 1 || skillName.length > 64) return false; return SKILL_NAME_PATTERN.test(skillName); @@ -716,40 +589,6 @@ export async function getSkillsCatalog( const itemsBySource: Record = {}; for (const src of sources) { - // Handle ClawdHub sources separately (API-based, not git-based) - if (isClawdHubSource(src.source)) { - const cacheKey = 'clawdhub:registry'; - let cached = !refresh ? catalogCache.get(cacheKey) : null; - if (cached && Date.now() >= cached.expiresAt) { - catalogCache.delete(cacheKey); - cached = null; - } - - let items: SkillsCatalogItem[] = []; - if (cached) { - items = cached.items; - } else { - const scanned = await scanClawdHub(); - if (!scanned.ok) { - itemsBySource[src.id] = []; - continue; - } - items = scanned.items || []; - catalogCache.set(cacheKey, { expiresAt: Date.now() + CATALOG_TTL_MS, items }); - } - - itemsBySource[src.id] = items.map((item) => { - const installed = installedByName.get(item.skillName); - return { - sourceId: src.id, - ...item, - installed: installed ? { isInstalled: true, scope: installed.scope, source: installed.source === 'agents' ? 'agents' : 'opencode' } : { isInstalled: false }, - }; - }); - continue; - } - - // Handle GitHub sources (git clone based) const parsed = parseSkillRepoSource(src.source); if (!parsed.ok) { itemsBySource[src.id] = []; diff --git a/packages/web/package.json b/packages/web/package.json index 5c7b84f1..156e0b7f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -27,7 +27,6 @@ "@octokit/rest": "^22.0.1", "@opencode-ai/sdk": "1.18.18", "@simplewebauthn/server": "13.3.1", - "adm-zip": "^0.6.0", "bun-pty": "^0.4.5", "compression": "^1.8.1", "cron-parser": "^4.9.0", @@ -63,7 +62,6 @@ "@remixicon/react": "^4.7.0", "@simplewebauthn/browser": "13.3.0", "@tailwindcss/postcss": "^4.0.0", - "@types/adm-zip": "^0.5.7", "@types/node": "^24.3.1", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", @@ -89,8 +87,8 @@ "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "strip-json-comments": "^5.0.3", - "tailwind-merge": "^3.3.1", "supertest": "^7.2.2", + "tailwind-merge": "^3.3.1", "tailwindcss": "^4.0.0", "tsx": "^4.20.6", "tw-animate-css": "^1.3.8", diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index 5e33fb09..f36f0c45 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -45,12 +45,11 @@ import { import { SKILL_DIR, SKILL_SCOPE, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile } from './shared.js'; import { getSkillSources, discoverSkills, mergeDiscoveredSkills, createSkill, updateSkill, deleteSkill, renameSkill, isManagedSkillPath } from './skills.js'; import { getCuratedSkillsSources } from '../skills-catalog/curated-sources.js'; -import { getCacheKey, getCachedScan, setCachedScan } from '../skills-catalog/cache.js'; -import { isClawdHubSource, parseSkillRepoSource } from '../skills-catalog/source.js'; +import { getCacheKey, scanWithCache } from '../skills-catalog/cache.js'; +import { parseSkillRepoSource } from '../skills-catalog/source.js'; import { scanSkillsRepository } from '../skills-catalog/scan.js'; import { installSkillsFromRepository } from '../skills-catalog/install.js'; -import { scanClawdHubPage } from '../skills-catalog/clawdhub/scan.js'; -import { installSkillsFromClawdHub } from '../skills-catalog/clawdhub/install.js'; +import { fetchGitHubRepoMetas } from '../skills-catalog/github-meta.js'; export const createFeatureRoutesRuntime = (dependencies) => { const { @@ -287,14 +286,11 @@ export const createFeatureRoutesRuntime = (dependencies) => { SKILL_DIR, getCuratedSkillsSources, getCacheKey, - getCachedScan, - setCachedScan, + scanWithCache, parseSkillRepoSource, scanSkillsRepository, installSkillsFromRepository, - scanClawdHubPage, - installSkillsFromClawdHub, - isClawdHubSource, + fetchGitHubRepoMetas, getProfiles, getProfile, }); diff --git a/packages/web/server/lib/opencode/skill-routes.js b/packages/web/server/lib/opencode/skill-routes.js index ba366ddc..0abe7f37 100644 --- a/packages/web/server/lib/opencode/skill-routes.js +++ b/packages/web/server/lib/opencode/skill-routes.js @@ -40,14 +40,11 @@ export const registerSkillRoutes = (app, dependencies) => { SKILL_DIR, getCuratedSkillsSources, getCacheKey, - getCachedScan, - setCachedScan, + scanWithCache, parseSkillRepoSource, scanSkillsRepository, installSkillsFromRepository, - scanClawdHubPage, - installSkillsFromClawdHub, - isClawdHubSource, + fetchGitHubRepoMetas, getProfiles, getProfile, } = dependencies; @@ -305,9 +302,26 @@ export const registerSkillRoutes = (app, dependencies) => { })); const sources = [...curatedSources, ...customSources]; - const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => rest); - res.json({ ok: true, sources: sourcesForUi, itemsBySource: {}, pageInfoBySource: {} }); + const githubRepos = sources + .map((src) => parseSkillRepoSource(src.source)) + .filter((parsed) => parsed.ok && parsed.host === 'github.com') + .map((parsed) => parsed.normalizedRepo); + const repoMetas = await fetchGitHubRepoMetas(githubRepos); + + const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => { + const parsed = parseSkillRepoSource(rest.source); + const meta = parsed.ok && parsed.host === 'github.com' + ? repoMetas[parsed.normalizedRepo] || {} + : {}; + return { + ...rest, + stars: typeof meta.stars === 'number' ? meta.stars : null, + repoUpdatedAt: typeof meta.repoUpdatedAt === 'string' ? meta.repoUpdatedAt : null, + }; + }); + + res.json({ ok: true, sources: sourcesForUi, itemsBySource: {} }); } catch (error) { console.error('Failed to load skills catalog:', error); res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to load catalog' } }); @@ -327,7 +341,6 @@ export const registerSkillRoutes = (app, dependencies) => { } const refresh = String(req.query.refresh || '').toLowerCase() === 'true'; - const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : null; const curatedSources = getCuratedSkillsSources(); const settings = await readSettingsFromDisk(); @@ -355,26 +368,6 @@ export const registerSkillRoutes = (app, dependencies) => { ); const installedByName = new Map(resolvedDiscovered.map((s) => [s.name, s])); - if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) { - const scanned = await scanClawdHubPage({ cursor: cursor || null }); - if (!scanned.ok) { - return res.status(500).json({ ok: false, error: scanned.error }); - } - - const items = (scanned.items || []).map((item) => { - const installed = installedByName.get(item.skillName); - return { - ...item, - sourceId: src.id, - installed: installed - ? { isInstalled: true, scope: installed.scope, source: installed.source } - : { isInstalled: false }, - }; - }); - - return res.json({ ok: true, items, nextCursor: scanned.nextCursor || null }); - } - const parsed = parseSkillRepoSource(src.source); if (!parsed.ok) { return res.status(400).json({ ok: false, error: parsed.error }); @@ -387,21 +380,19 @@ export const registerSkillRoutes = (app, dependencies) => { identityId: src.gitIdentityId || '', }); - let scanResult = !refresh ? getCachedScan(cacheKey) : null; - if (!scanResult) { - const scanned = await scanSkillsRepository({ + const scanResult = await scanWithCache( + cacheKey, + () => scanSkillsRepository({ source: src.source, subpath: src.defaultSubpath, defaultSubpath: src.defaultSubpath, identity: resolveGitIdentity(src.gitIdentityId), - }); + }), + { refresh }, + ); - if (!scanned.ok) { - return res.status(500).json({ ok: false, error: scanned.error }); - } - - scanResult = scanned; - setCachedScan(cacheKey, scanResult); + if (!scanResult.ok) { + return res.status(500).json({ ok: false, error: scanResult.error }); } const items = (scanResult.items || []).map((item) => { @@ -483,41 +474,6 @@ export const registerSkillRoutes = (app, dependencies) => { workingDirectory = resolved.directory; } - if (isClawdHubSource(source)) { - const result = await installSkillsFromClawdHub({ - scope, - targetSource, - workingDirectory, - userSkillDir: SKILL_DIR, - selections, - conflictPolicy, - conflictDecisions, - }); - - if (!result.ok) { - if (result.error?.kind === 'conflicts') { - return res.status(409).json({ ok: false, error: result.error }); - } - return res.status(400).json({ ok: false, error: result.error }); - } - - const installed = result.installed || []; - const skipped = result.skipped || []; - const requiresRestart = installed.length > 0; - - return res.json({ - ok: true, - installed, - skipped, - ...(requiresRestart - ? buildDeferredRestartResponse('Skills installed successfully. Restart OpenCode to apply.') - : { - requiresReload: false, - message: 'No skills were installed', - }), - }); - } - const identity = resolveGitIdentity(gitIdentityId); const result = await installSkillsFromRepository({ diff --git a/packages/web/server/lib/opencode/skill-routes.test.js b/packages/web/server/lib/opencode/skill-routes.test.js index 3ba8526e..6212d76f 100644 --- a/packages/web/server/lib/opencode/skill-routes.test.js +++ b/packages/web/server/lib/opencode/skill-routes.test.js @@ -69,14 +69,11 @@ const startSkillsApp = ({ projectRoot }) => { SKILL_DIR, getCuratedSkillsSources: () => [], getCacheKey: () => 'k', - getCachedScan: () => null, - setCachedScan: () => {}, + scanWithCache: async (_key, loader) => loader(), parseSkillRepoSource: () => ({ ok: false }), scanSkillsRepository: async () => ({ ok: false }), installSkillsFromRepository: async () => ({ ok: false }), - scanClawdHubPage: async () => ({ ok: false }), - installSkillsFromClawdHub: async () => ({ ok: false }), - isClawdHubSource: () => false, + fetchGitHubRepoMetas: async () => ({}), getProfiles: () => [], getProfile: () => null, }); diff --git a/packages/web/server/lib/skills-catalog/DOCUMENTATION.md b/packages/web/server/lib/skills-catalog/DOCUMENTATION.md index 199a0271..5c0b020e 100644 --- a/packages/web/server/lib/skills-catalog/DOCUMENTATION.md +++ b/packages/web/server/lib/skills-catalog/DOCUMENTATION.md @@ -1,21 +1,17 @@ # Skills Catalog Module Documentation ## Purpose -This module provides skill discovery, scanning, and installation capabilities for OpenCode. It supports multiple skill sources including git repositories and the ClawHub registry, with caching and conflict resolution for skill installation. +This module provides skill discovery, scanning, and installation capabilities for OpenCode. It supports skill sources backed by git repositories, with caching and conflict resolution for skill installation. ## Entrypoints and structure - `packages/web/server/lib/skills-catalog/`: Skills catalog module directory containing all skill-related functionality. - `cache.js`: In-memory cache for scan results with TTL support. - - `curated-sources.js`: Predefined skill sources (Anthropic, ClawHub). + - `curated-sources.js`: Predefined skill sources (Anthropic, OpenAI, Cursor, Matt Pocock). + - `github-meta.js`: Best-effort GitHub repository metadata (stars, last push) with in-memory TTL cache. - `git.js`: Git operations helpers for cloning and auth error detection. - `install.js`: Skills installation from git repositories. - `scan.js`: Skills scanning from git repositories. - `source.js`: Source string parsing for git repositories. - - `clawdhub/`: ClawHub registry integration. - - `index.js`: Public API exports for ClawHub. - - `scan.js`: Scanning ClawHub registry with pagination. - - `install.js`: Installation from ClawHub (ZIP download). - - `api.js`: ClawHub API client with rate limiting. ## Public API @@ -24,13 +20,19 @@ The following functions are exported and used by the web server: ### Cache (`cache.js`) - `getCacheKey({ normalizedRepo, subpath, identityId })`: Generate cache key for scan results. - `getCachedScan(key)`: Retrieve cached scan result if not expired. -- `setCachedScan(key, value, ttlMs)`: Store scan result with TTL (default 30 minutes). +- `setCachedScan(key, value, ttlMs)`: Store scan result with TTL (default 3 hours). +- `scanWithCache(key, loader, { refresh })`: Run a scan loader with cache lookup, in-flight deduplication, and a global concurrency limit (2 concurrent scans); only `ok: true` results are cached. - `clearCache()`: Clear all cached scan results. +- Scan results persist to `skills-catalog-cache.json` in the OpenChamber data dir (debounced, atomic rename) and survive server restarts within the TTL. ### Curated Sources (`curated-sources.js`) -- `getCuratedSkillsSources()`: Return list of curated skill sources (Anthropic, ClawHub). +- `getCuratedSkillsSources()`: Return list of curated skill sources (Anthropic, OpenAI, Cursor, Matt Pocock). - `CURATED_SKILLS_SOURCES`: Constant array of predefined sources. +### GitHub Repository Metadata (`github-meta.js`) +- `fetchGitHubRepoMetas(normalizedRepos)`: Fetch `{ stars, repoUpdatedAt }` for GitHub `owner/repo` strings. Best-effort: failures resolve to `null`; in-flight requests deduplicate; results cached in memory and on disk (`skills-github-meta.json`) for three hours. +- `clearGitHubMetaCache()`: Test-only cache reset. + ### Source Parsing (`source.js`) - `parseSkillRepoSource(source, { subpath })`: Parse git repository source string into structured object with SSH/HTTPS clone URLs, normalized repo, and effective subpath. Supports SSH URLs, HTTPS URLs, and shorthand `owner/repo[/subpath]` format. @@ -40,20 +42,6 @@ The following functions are exported and used by the web server: ### Git Repository Installation (`install.js`) - `installSkillsFromRepository({ source, subpath, defaultSubpath, identity, scope, targetSource, workingDirectory, userSkillDir, selections, conflictPolicy, conflictDecisions })`: Install skills from git repository. Supports user/project scopes, opencode/agents targets, conflict resolution (prompt/skipAll/overwriteAll), and sparse checkout for efficiency. -### ClawHub Integration (`clawdhub/index.js`) -- `isClawdHubSource(source)`: Check if source string refers to ClawHub. -- `scanClawdHub()`: Scan entire ClawHub registry for all skills (paginated, max 20 pages). -- `scanClawdHubPage({ cursor })`: Scan a single page of ClawHub results with cursor-based pagination. -- `installSkillsFromClawdHub({ scope, targetSource, workingDirectory, userSkillDir, selections, conflictPolicy, conflictDecisions })`: Install skills from ClawHub by downloading ZIP files. -- `fetchClawdHubSkills({ cursor })`: Fetch paginated skills list from ClawHub API. -- `fetchClawdHubSkillVersion(slug, version)`: Fetch specific skill version details. -- `fetchClawdHubSkillInfo(slug)`: Fetch skill metadata without version details. -- `downloadClawdHubSkill(slug, version)`: Download skill package as ZIP buffer. - -### ClawHub Constants (`clawdhub/index.js`) -- `CLAWDHUB_SOURCE_ID`: Source identifier for curated sources. -- `CLAWDHUB_SOURCE_STRING`: Source string format. - ## Internal Helpers The following functions are internal helpers used by exported functions: @@ -63,10 +51,10 @@ The following functions are internal helpers used by exported functions: - `looksLikeAuthError(message)`: Detect if error message indicates authentication failure (permission denied, publickey, etc.). - `assertGitAvailable()`: Check if git is available in PATH. -### Skill Name Validation (used in `install.js`, `scan.js`, `clawdhub/install.js`) +### Skill Name Validation (used in `install.js`, `scan.js`) - `validateSkillName(skillName)`: Validate skill name against pattern `/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/` (1-64 chars, lowercase alphanumeric with hyphens). -### File System Helpers (`install.js`, `scan.js`, `clawdhub/install.js`) +### File System Helpers (`install.js`, `scan.js`) - `safeRm(dir)`: Safely remove directory recursively (ignores errors). - `ensureDir(dirPath)`: Ensure directory exists with recursive creation. - `copyDirectoryNoSymlinks(srcDir, dstDir)`: Copy directory contents without symlinks, with path traversal protection. @@ -82,10 +70,6 @@ The following functions are internal helpers used by exported functions: - `toFsPath(repoDir, repoRelPosixPath)`: Convert POSIX path to filesystem path. - `getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName })`: Determine target installation directory based on scope (user/project), targetSource (opencode/agents), and skill name. -### ClawHub API Helpers (`clawdhub/api.js`) -- `rateLimitedFetch(url, options)`: Fetch with rate limiting (120 req/min limit, 100ms delay between requests, exponential backoff on 429/500 errors). -- `mapClawdHubItem(item)`: Transform ClawHub API response to SkillsCatalogItem format. - ## Response Contracts ### Scan Skills Repository Response @@ -101,12 +85,6 @@ The following functions are internal helpers used by exported functions: - `skipped`: Array of skipped skills with `{ skillName, reason }`. - `error`: Error object with `{ kind, message, conflicts? }` on failure. Kinds: `authRequired`, `networkError`, `conflicts`, `invalidSource`, `unknown`. -### ClawHub Scan Response -- `ok`: Boolean indicating success. -- `items`: Array of skill items with ClawHub-specific metadata in `clawdhub` property. -- `nextCursor`: Pagination cursor for next page (only for `scanClawdHubPage`). -- `error`: Error object with `{ kind, message }` on failure. - ### Parse Source Response - `ok`: Boolean indicating success. - `host`: Git host (e.g., `github.com`, `gitlab.com`). @@ -129,7 +107,7 @@ The following functions are internal helpers used by exported functions: ### Skill Name Validation - All skill names must match `/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/` (1-64 chars). -- Skill names are derived from directory basenames for git repos and slugs for ClawHub. +- Skill names are derived from directory basenames for git repos. - Invalid names result in non-installable skills with appropriate warnings. ### Git Cloning Strategy @@ -144,17 +122,12 @@ The following functions are internal helpers used by exported functions: - Per-skill decisions override global policy via `conflictDecisions` map. - Conflict response includes `{ skillName, scope, source }` for each conflict. -### ClawHub Integration -- ClawHub API base URL: `https://clawdhub.com/api/v1`. -- Pagination uses cursor-based approach with `MAX_PAGES=20` safety limit. -- Rate limiting: 120 req/min with 100ms delay between requests. -- Downloaded skills are extracted from ZIP files using `adm-zip`. -- Always validate `SKILL.md` exists before installation. - ### Cache Management - Cache keys include `normalizedRepo`, `subpath`, and `identityId` for isolation. -- Default TTL is 30 minutes; can be overridden via `ttlMs` parameter. -- Cache is in-memory (not persisted across restarts). +- Default TTL is 3 hours for both scan results and GitHub repository metadata. +- Scan and GitHub metadata caches persist to JSON files in the OpenChamber data dir, so app restarts and page refreshes reuse previous results instead of re-hitting GitHub. +- Scans run through a global concurrency limiter (2 at a time) with per-key in-flight deduplication. +- The refresh button passes `refresh: true` and bypasses the cache. ### Security Considerations - Path traversal protection in `copyDirectoryNoSymlinks`: resolves real paths and checks containment. diff --git a/packages/web/server/lib/skills-catalog/cache.js b/packages/web/server/lib/skills-catalog/cache.js index 3fbbae5e..8e80b968 100644 --- a/packages/web/server/lib/skills-catalog/cache.js +++ b/packages/web/server/lib/skills-catalog/cache.js @@ -1,6 +1,58 @@ -const DEFAULT_TTL_MS = 30 * 60 * 1000; +import { readDiskCache, writeDiskCache } from './disk-cache.js'; + +const DEFAULT_TTL_MS = 3 * 60 * 60 * 1000; +const DISK_CACHE_FILE = 'skills-catalog-cache.json'; +const MAX_CONCURRENT_SCANS = 2; const cache = new Map(); +const inFlight = new Map(); + +let diskLoaded = false; +let diskWriteTimer = null; + +const loadDiskEntries = () => { + if (diskLoaded) { + return; + } + diskLoaded = true; + const persisted = readDiskCache(DISK_CACHE_FILE); + if (!persisted) { + return; + } + const now = Date.now(); + for (const [key, entry] of Object.entries(persisted)) { + if ( + entry + && typeof entry === 'object' + && typeof entry.expiresAt === 'number' + && entry.expiresAt > now + && entry.value + && typeof entry.value === 'object' + ) { + cache.set(key, entry); + } + } +}; + +const scheduleDiskWrite = () => { + if (diskWriteTimer) { + return; + } + diskWriteTimer = setTimeout(() => { + diskWriteTimer = null; + const now = Date.now(); + const persisted = {}; + for (const [key, entry] of cache.entries()) { + if (entry.expiresAt > now) { + persisted[key] = entry; + } + } + writeDiskCache(DISK_CACHE_FILE, persisted); + }, 1000); + if (typeof diskWriteTimer.unref === 'function') { + diskWriteTimer.unref(); + } +}; export function getCacheKey({ normalizedRepo, subpath, identityId }) { const safeRepo = String(normalizedRepo || '').trim(); @@ -10,6 +62,7 @@ export function getCacheKey({ normalizedRepo, subpath, identityId }) { } export function getCachedScan(key) { + loadDiskEntries(); const entry = cache.get(key); if (!entry) return null; if (Date.now() >= entry.expiresAt) { @@ -22,4 +75,70 @@ export function getCachedScan(key) { export function setCachedScan(key, value, ttlMs = DEFAULT_TTL_MS) { const ttl = Number.isFinite(ttlMs) ? ttlMs : DEFAULT_TTL_MS; cache.set(key, { expiresAt: Date.now() + ttl, value }); + scheduleDiskWrite(); +} + +export function clearCache() { + cache.clear(); + inFlight.clear(); +} + +// ─── Concurrency-limited scan orchestration ─── + +let activeScans = 0; +const scanQueue = []; + +const acquireScanSlot = () => new Promise((resolve) => { + scanQueue.push(resolve); + pumpScanQueue(); +}); + +const releaseScanSlot = () => { + activeScans -= 1; + pumpScanQueue(); +}; + +const pumpScanQueue = () => { + while (activeScans < MAX_CONCURRENT_SCANS && scanQueue.length > 0) { + const resolve = scanQueue.shift(); + activeScans += 1; + resolve(); + } +}; + +/** + * Run `loader` for a scan cache key with deduplication and a global + * concurrency limit. Concurrent callers for the same key share one loader + * run; at most MAX_CONCURRENT_SCANS loaders run at once. Only successful + * (`ok: true`) results are cached. + */ +export async function scanWithCache(key, loader, { refresh = false } = {}) { + if (!refresh) { + const cached = getCachedScan(key); + if (cached) { + return cached; + } + } + + const existing = inFlight.get(key); + if (existing) { + return existing; + } + + const run = (async () => { + await acquireScanSlot(); + try { + const result = await loader(); + if (result && result.ok) { + setCachedScan(key, result); + } + return result; + } finally { + releaseScanSlot(); + inFlight.delete(key); + } + })(); + + inFlight.set(key, run); + return run; } diff --git a/packages/web/server/lib/skills-catalog/cache.test.js b/packages/web/server/lib/skills-catalog/cache.test.js new file mode 100644 index 00000000..43306548 --- /dev/null +++ b/packages/web/server/lib/skills-catalog/cache.test.js @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { clearCache, scanWithCache, setCachedScan, getCachedScan } from './cache.js'; + +let tempDataDir; + +beforeEach(() => { + tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-cache-test-')); + process.env.OPENCHAMBER_DATA_DIR = tempDataDir; +}); + +afterEach(() => { + delete process.env.OPENCHAMBER_DATA_DIR; + clearCache(); + vi.restoreAllMocks(); + fs.rmSync(tempDataDir, { recursive: true, force: true }); +}); + +const flushDiskWrites = async () => new Promise((resolve) => setTimeout(resolve, 1200)); + +describe('scanWithCache', () => { + it('deduplicates concurrent loaders for the same key', async () => { + const loader = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + return { ok: true, items: [] }; + }); + + const [a, b] = await Promise.all([ + scanWithCache('k', loader), + scanWithCache('k', loader), + ]); + + expect(loader).toHaveBeenCalledTimes(1); + expect(a).toEqual(b); + }); + + it('limits concurrent scans across different keys', async () => { + let running = 0; + let peak = 0; + const loader = async () => { + running += 1; + peak = Math.max(peak, running); + await new Promise((resolve) => setTimeout(resolve, 20)); + running -= 1; + return { ok: true, items: [] }; + }; + + await Promise.all(Array.from({ length: 6 }, (_, i) => scanWithCache(`key-${i}`, loader))); + + expect(peak).toBeLessThanOrEqual(2); + }); + + it('does not cache failed scans', async () => { + await scanWithCache('bad', async () => ({ ok: false, error: { kind: 'networkError', message: 'x' } })); + + expect(getCachedScan('bad')).toBeNull(); + }); + + it('refresh bypasses the cache', async () => { + setCachedScan('fresh', { ok: true, items: ['cached'] }); + + const result = await scanWithCache('fresh', async () => ({ ok: true, items: ['reloaded'] }), { refresh: true }); + + expect(result.items).toEqual(['reloaded']); + expect(getCachedScan('fresh').items).toEqual(['reloaded']); + }); + + it('persists successful scans to disk for later processes', async () => { + await scanWithCache('persisted', async () => ({ ok: true, items: [{ skillName: 'x' }] })); + await flushDiskWrites(); + + const onDisk = JSON.parse(fs.readFileSync(path.join(tempDataDir, 'skills-catalog-cache.json'), 'utf8')); + expect(onDisk.persisted.value.items).toEqual([{ skillName: 'x' }]); + }); +}); diff --git a/packages/web/server/lib/skills-catalog/clawdhub/api.js b/packages/web/server/lib/skills-catalog/clawdhub/api.js deleted file mode 100644 index b0f23986..00000000 --- a/packages/web/server/lib/skills-catalog/clawdhub/api.js +++ /dev/null @@ -1,126 +0,0 @@ -/** - * ClawdHub API client - * - * ClawdHub is a public skill registry at https://clawdhub.com - * This client provides methods to fetch skills list and download skill packages. - */ - -const CLAWDHUB_API_BASE = 'https://clawdhub.com/api/v1'; -const CLAWDHUB_PAGE_LIMIT = 25; - -// Rate limiting: ClawdHub allows 120 requests/minute -const RATE_LIMIT_DELAY_MS = 100; -let lastRequestTime = 0; - -async function rateLimitedFetch(url, options = {}) { - const maxAttempts = 10; - - let lastResponse = null; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const now = Date.now(); - const elapsed = now - lastRequestTime; - if (elapsed < RATE_LIMIT_DELAY_MS) { - await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY_MS - elapsed)); - } - lastRequestTime = Date.now(); - - const response = await fetch(url, { - ...options, - headers: { - Accept: 'application/json', - 'User-Agent': 'OpenChamber/1.0', - ...options.headers, - }, - }); - - lastResponse = response; - - if (response.status === 429 || response.status >= 500) { - if (attempt < maxAttempts - 1) { - const waitMs = 50 * (attempt + 1); - await new Promise((resolve) => setTimeout(resolve, waitMs)); - continue; - } - } - - return response; - } - - return lastResponse; -} - -/** - * Fetch paginated list of skills from ClawdHub - * @param {Object} options - * @param {string} [options.cursor] - Pagination cursor from previous response - * @returns {Promise<{ items: Array, nextCursor?: string }>} - */ -export async function fetchClawdHubSkills({ cursor } = {}) { - const url = cursor - ? `${CLAWDHUB_API_BASE}/skills?cursor=${encodeURIComponent(cursor)}&limit=${CLAWDHUB_PAGE_LIMIT}` - : `${CLAWDHUB_API_BASE}/skills?limit=${CLAWDHUB_PAGE_LIMIT}`; - - const response = await rateLimitedFetch(url); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error(`ClawdHub API error (${response.status}): ${text || response.statusText}`); - } - - const data = await response.json(); - const nextCursor = - (typeof data.nextCursor === 'string' && data.nextCursor) || - (typeof data.next_cursor === 'string' && data.next_cursor) || - (typeof data.next === 'string' && data.next) || - (typeof data.cursor === 'string' && data.cursor) || - null; - - return { - items: data.items || [], - nextCursor, - }; -} - -/** - * Download a skill package as a ZIP buffer - * @param {string} slug - Skill slug/identifier - * @param {string} version - Specific version string - * @returns {Promise} - ZIP file contents - */ -export async function downloadClawdHubSkill(slug, version) { - const versionParam = typeof version === 'string' && version !== 'latest' - ? `&version=${encodeURIComponent(version)}` - : '&tag=latest'; - const url = `${CLAWDHUB_API_BASE}/download?slug=${encodeURIComponent(slug)}${versionParam}`; - - const response = await rateLimitedFetch(url, { - headers: { - Accept: 'application/zip', - }, - }); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error(`ClawdHub download error (${response.status}): ${text || response.statusText}`); - } - - return response.arrayBuffer(); -} - -/** - * Get skill metadata without version details - * @param {string} slug - Skill slug/identifier - * @returns {Promise} - */ -export async function fetchClawdHubSkillInfo(slug) { - const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`; - const response = await rateLimitedFetch(url); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error(`ClawdHub skill error (${response.status}): ${text || response.statusText}`); - } - - return response.json(); -} diff --git a/packages/web/server/lib/skills-catalog/clawdhub/install.js b/packages/web/server/lib/skills-catalog/clawdhub/install.js deleted file mode 100644 index 753d1da4..00000000 --- a/packages/web/server/lib/skills-catalog/clawdhub/install.js +++ /dev/null @@ -1,238 +0,0 @@ -/** - * ClawdHub skill installation - * - * Downloads skills from ClawdHub as ZIP files and extracts them - * to the appropriate skill directory. - */ - -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import AdmZip from 'adm-zip'; - -import { downloadClawdHubSkill, fetchClawdHubSkillInfo } from './api.js'; - -const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; - -function normalizeUserSkillDir(userSkillDir) { - if (!userSkillDir) return null; - const legacySkillDir = path.join(os.homedir(), '.config', 'opencode', 'skill'); - const pluralSkillDir = path.join(os.homedir(), '.config', 'opencode', 'skills'); - if (userSkillDir === legacySkillDir) { - if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir; - return pluralSkillDir; - } - return userSkillDir; -} - -function validateSkillName(skillName) { - if (typeof skillName !== 'string') return false; - if (skillName.length < 1 || skillName.length > 64) return false; - return SKILL_NAME_PATTERN.test(skillName); -} - -async function safeRm(dir) { - try { - await fs.promises.rm(dir, { recursive: true, force: true }); - } catch { - // ignore - } -} - -async function ensureDir(dirPath) { - await fs.promises.mkdir(dirPath, { recursive: true }); -} - -function getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName }) { - const source = targetSource === 'agents' ? 'agents' : 'opencode'; - - if (scope === 'user') { - if (source === 'agents') { - return path.join(os.homedir(), '.agents', 'skills', skillName); - } - return path.join(userSkillDir, skillName); - } - - if (!workingDirectory) { - throw new Error('workingDirectory is required for project installs'); - } - - if (source === 'agents') { - return path.join(workingDirectory, '.agents', 'skills', skillName); - } - - return path.join(workingDirectory, '.opencode', 'skills', skillName); -} - -/** - * Install skills from ClawdHub registry - * @param {Object} options - * @param {string} options.scope - 'user' or 'project' - * @param {string} [options.targetSource] - 'opencode' or 'agents' - * @param {string} [options.workingDirectory] - Required for project scope - * @param {string} options.userSkillDir - User skills directory - * @param {Array} options.selections - Array of { skillDir, clawdhub: { slug, version } } - * @param {string} [options.conflictPolicy] - 'prompt', 'skipAll', or 'overwriteAll' - * @param {Object} [options.conflictDecisions] - Per-skill conflict decisions - * @returns {Promise<{ ok: boolean, installed?: Array, skipped?: Array, error?: Object }>} - */ -export async function installSkillsFromClawdHub({ - scope, - targetSource, - workingDirectory, - userSkillDir, - selections, - conflictPolicy, - conflictDecisions, -} = {}) { - if (scope !== 'user' && scope !== 'project') { - return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } }; - } - - if (targetSource !== undefined && targetSource !== 'opencode' && targetSource !== 'agents') { - return { ok: false, error: { kind: 'invalidSource', message: 'Invalid target source' } }; - } - - if (!userSkillDir) { - return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } }; - } - - const normalizedUserSkillDir = normalizeUserSkillDir(userSkillDir); - if (normalizedUserSkillDir) { - userSkillDir = normalizedUserSkillDir; - } - - if (scope === 'project' && !workingDirectory) { - return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } }; - } - - const requestedSkills = Array.isArray(selections) ? selections : []; - if (requestedSkills.length === 0) { - return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } }; - } - - // Build installation plans - const skillPlans = requestedSkills.map((sel) => { - const slug = sel.clawdhub?.slug || sel.skillDir; - const version = sel.clawdhub?.version || 'latest'; - return { - slug, - version, - installable: validateSkillName(slug), - }; - }); - - // Check for conflicts before downloading - const conflicts = []; - for (const plan of skillPlans) { - if (!plan.installable) { - continue; - } - - const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.slug }); - if (fs.existsSync(targetDir)) { - const decision = conflictDecisions?.[plan.slug]; - const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll'; - if (!decision && !hasAutoPolicy) { - conflicts.push({ skillName: plan.slug, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' }); - } - } - } - - if (conflicts.length > 0) { - return { - ok: false, - error: { - kind: 'conflicts', - message: 'Some skills already exist in the selected scope', - conflicts, - }, - }; - } - - const installed = []; - const skipped = []; - - for (const plan of skillPlans) { - if (!plan.installable) { - skipped.push({ skillName: plan.slug, reason: 'Invalid skill name' }); - continue; - } - - try { - // Resolve 'latest' version if needed - let resolvedVersion = plan.version; - if (resolvedVersion === 'latest') { - try { - const info = await fetchClawdHubSkillInfo(plan.slug); - const latest = info.skill?.tags?.latest || info.latestVersion?.version || null; - if (latest) { - resolvedVersion = latest; - } - } catch { - // ignore - } - - if (resolvedVersion === 'latest') { - skipped.push({ skillName: plan.slug, reason: 'Unable to resolve latest version' }); - continue; - } - } - - const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.slug }); - const exists = fs.existsSync(targetDir); - - // Determine conflict resolution - let decision = conflictDecisions?.[plan.slug] || null; - if (!decision) { - if (exists && conflictPolicy === 'skipAll') decision = 'skip'; - if (exists && conflictPolicy === 'overwriteAll') decision = 'overwrite'; - if (!exists) decision = 'overwrite'; // No conflict, proceed - } - - if (exists && decision === 'skip') { - skipped.push({ skillName: plan.slug, reason: 'Already installed (skipped)' }); - continue; - } - - if (exists && decision === 'overwrite') { - await safeRm(targetDir); - } - - // Download the skill ZIP - const zipBuffer = await downloadClawdHubSkill(plan.slug, resolvedVersion); - - // Extract to a temp directory first for validation - const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `clawdhub-${plan.slug}-`)); - - try { - const zip = new AdmZip(Buffer.from(zipBuffer)); - zip.extractAllTo(tempDir, true); - - // Verify SKILL.md exists - const skillMdPath = path.join(tempDir, 'SKILL.md'); - if (!fs.existsSync(skillMdPath)) { - skipped.push({ skillName: plan.slug, reason: 'SKILL.md not found in downloaded package' }); - continue; - } - - // Move to target directory - await ensureDir(path.dirname(targetDir)); - await fs.promises.rename(tempDir, targetDir); - - installed.push({ skillName: plan.slug, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' }); - } catch (extractError) { - await safeRm(tempDir); - throw extractError; - } - } catch (error) { - console.error(`Failed to install ClawdHub skill "${plan.slug}":`, error); - skipped.push({ - skillName: plan.slug, - reason: error instanceof Error ? error.message : 'Failed to download or extract skill', - }); - } - } - - return { ok: true, installed, skipped }; -} diff --git a/packages/web/server/lib/skills-catalog/clawdhub/install.test.js b/packages/web/server/lib/skills-catalog/clawdhub/install.test.js deleted file mode 100644 index e57a29ba..00000000 --- a/packages/web/server/lib/skills-catalog/clawdhub/install.test.js +++ /dev/null @@ -1,100 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import AdmZip from 'adm-zip'; - -// Mock the ClawdHub network client so no real HTTP happens. The download -// function is what feeds the ZIP buffer into adm-zip inside install.js. -vi.mock('./api.js', () => ({ - downloadClawdHubSkill: vi.fn(), - fetchClawdHubSkillInfo: vi.fn(), -})); - -const { downloadClawdHubSkill } = await import('./api.js'); -const { installSkillsFromClawdHub } = await import('./install.js'); - -/** - * Build a real ZIP archive with adm-zip (the dependency under test). - * Returns the raw Buffer, mirroring what downloadClawdHubSkill resolves to. - */ -function buildSkillZip(entries) { - const zip = new AdmZip(); - for (const [entryName, content] of Object.entries(entries)) { - zip.addFile(entryName, Buffer.from(content, 'utf8')); - } - return zip.toBuffer(); -} - -describe('installSkillsFromClawdHub (adm-zip extraction path)', () => { - let userSkillDir; - - beforeEach(async () => { - // Keep the target dir under os.tmpdir() so the temp->target rename in - // install.js stays on one filesystem (avoids EXDEV cross-device errors). - userSkillDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'clawdhub-test-skills-')); - vi.clearAllMocks(); - }); - - afterEach(async () => { - await fs.promises.rm(userSkillDir, { recursive: true, force: true }).catch(() => {}); - }); - - it('extracts a real ZIP (incl. nested subdirectories) into the target skill dir', async () => { - const skillMd = 'name: demo-skill\ndescription: adm-zip extraction regression guard\n'; - const nested = 'nested file content for subdirectory extraction check\n'; - downloadClawdHubSkill.mockResolvedValue( - buildSkillZip({ 'SKILL.md': skillMd, 'nested/data.txt': nested }), - ); - - const result = await installSkillsFromClawdHub({ - scope: 'user', - targetSource: 'opencode', - userSkillDir, - // Non-'latest' version avoids the fetchClawdHubSkillInfo resolve branch. - selections: [{ clawdhub: { slug: 'demo-skill', version: '1.0.0' } }], - }); - - expect(result.ok).toBe(true); - expect(result.installed).toEqual([ - { skillName: 'demo-skill', scope: 'user', source: 'opencode' }, - ]); - expect(result.skipped).toEqual([]); - - // downloadClawdHubSkill received the resolved (non-latest) version. - expect(downloadClawdHubSkill).toHaveBeenCalledWith('demo-skill', '1.0.0'); - - // adm-zip actually wrote the files, preserving the nested subdirectory. - const targetDir = path.join(userSkillDir, 'demo-skill'); - const skillMdPath = path.join(targetDir, 'SKILL.md'); - const nestedPath = path.join(targetDir, 'nested', 'data.txt'); - - expect(fs.existsSync(skillMdPath)).toBe(true); - expect(fs.existsSync(nestedPath)).toBe(true); - expect(fs.readFileSync(skillMdPath, 'utf8')).toBe(skillMd); - expect(fs.readFileSync(nestedPath, 'utf8')).toBe(nested); - }); - - it('skips a package whose extracted contents lack SKILL.md', async () => { - // Valid ZIP, but no SKILL.md at the root -> install.js must skip it and - // must NOT create the target dir. This exercises the extractAllTo path - // followed by the post-extraction validation. - downloadClawdHubSkill.mockResolvedValue( - buildSkillZip({ 'README.md': 'no skill manifest here\n' }), - ); - - const result = await installSkillsFromClawdHub({ - scope: 'user', - targetSource: 'opencode', - userSkillDir, - selections: [{ clawdhub: { slug: 'broken-skill', version: '1.0.0' } }], - }); - - expect(result.ok).toBe(true); - expect(result.installed).toEqual([]); - expect(result.skipped).toEqual([ - { skillName: 'broken-skill', reason: 'SKILL.md not found in downloaded package' }, - ]); - expect(fs.existsSync(path.join(userSkillDir, 'broken-skill'))).toBe(false); - }); -}); diff --git a/packages/web/server/lib/skills-catalog/clawdhub/scan.js b/packages/web/server/lib/skills-catalog/clawdhub/scan.js deleted file mode 100644 index 5a8a6e4d..00000000 --- a/packages/web/server/lib/skills-catalog/clawdhub/scan.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * ClawdHub skill scanning - * - * Fetches all available skills from the ClawdHub registry - * and transforms them into SkillsCatalogItem format. - */ - -import { fetchClawdHubSkills } from './api.js'; - -const CLAWDHUB_PAGE_LIMIT = 25; - -const mapClawdHubItem = (item) => { - const latestVersion = item.tags?.latest || item.latestVersion?.version || '1.0.0'; - - return { - sourceId: 'clawdhub', - repoSource: 'clawdhub:registry', - repoSubpath: null, - gitIdentityId: null, - skillDir: item.slug, - skillName: item.slug, - frontmatterName: item.displayName || item.slug, - description: item.summary || null, - installable: true, - warnings: [], - // ClawdHub-specific metadata - clawdhub: { - slug: item.slug, - version: latestVersion, - displayName: item.displayName, - owner: item.owner?.handle || null, - downloads: item.stats?.downloads || 0, - stars: item.stats?.stars || 0, - versionsCount: item.stats?.versions || 1, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - }, - }; -}; - -/** - * Scan a single ClawdHub page (cursor-based) - * @returns {Promise<{ ok: boolean, items?: Array, nextCursor?: string | null, error?: Object }>} - */ -export async function scanClawdHubPage({ cursor } = {}) { - try { - const { items, nextCursor } = await fetchClawdHubSkills({ cursor }); - const mapped = (items || []).map(mapClawdHubItem).slice(0, CLAWDHUB_PAGE_LIMIT); - mapped.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0)); - return { ok: true, items: mapped, nextCursor: nextCursor || null }; - } catch (error) { - console.error('ClawdHub page scan error:', error); - return { - ok: false, - error: { - kind: 'networkError', - message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub', - }, - }; - } -} diff --git a/packages/web/server/lib/skills-catalog/curated-sources.js b/packages/web/server/lib/skills-catalog/curated-sources.js index ba62696d..19f5c1c3 100644 --- a/packages/web/server/lib/skills-catalog/curated-sources.js +++ b/packages/web/server/lib/skills-catalog/curated-sources.js @@ -8,11 +8,27 @@ const CURATED_SKILLS_SOURCES = [ sourceType: 'github', }, { - id: 'clawdhub', - label: 'ClawHub', - description: 'Community skill registry with vector search', - source: 'clawdhub:registry', - sourceType: 'clawdhub', + id: 'openai', + label: 'OpenAI', + description: "OpenAI's curated skills", + source: 'openai/skills', + defaultSubpath: 'skills/.curated', + sourceType: 'github', + }, + { + id: 'cursor', + label: 'Cursor', + description: "Cursor's plugin skills", + source: 'cursor/plugins', + defaultSubpath: 'pstack/skills', + sourceType: 'github', + }, + { + id: 'mattpocock', + label: 'Matt Pocock', + description: 'Matt Pocock skills collection', + source: 'mattpocock/skills', + sourceType: 'github', }, ]; diff --git a/packages/web/server/lib/skills-catalog/curated-sources.test.js b/packages/web/server/lib/skills-catalog/curated-sources.test.js index 7db92de5..dfb568dd 100644 --- a/packages/web/server/lib/skills-catalog/curated-sources.test.js +++ b/packages/web/server/lib/skills-catalog/curated-sources.test.js @@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest'; import { getCuratedSkillsSources } from './curated-sources.js'; describe('getCuratedSkillsSources', () => { - it('labels the ClawHub curated source as ClawHub', () => { - const clawhub = getCuratedSkillsSources().find((source) => source.id === 'clawdhub'); - expect(clawhub).toBeDefined(); - expect(clawhub.label).toBe('ClawHub'); + it('includes the Anthropic curated source', () => { + const anthropic = getCuratedSkillsSources().find((source) => source.id === 'anthropic'); + expect(anthropic).toBeDefined(); + expect(anthropic.label).toBe('Anthropic'); }); }); diff --git a/packages/web/server/lib/skills-catalog/disk-cache.js b/packages/web/server/lib/skills-catalog/disk-cache.js new file mode 100644 index 00000000..6fb60ffe --- /dev/null +++ b/packages/web/server/lib/skills-catalog/disk-cache.js @@ -0,0 +1,52 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const resolveDataDir = () => (process.env.OPENCHAMBER_DATA_DIR + ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) + : path.join(os.homedir(), '.config', 'openchamber')); + +const readJsonFile = (filePath) => { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +}; + +/** + * Read a persisted cache object from the OpenChamber data directory. + * Returns null when the file is missing, unreadable, or malformed. + */ +export const readDiskCache = (fileName) => { + try { + return readJsonFile(path.join(resolveDataDir(), fileName)); + } catch { + return null; + } +}; + +/** + * Persist a cache object to the OpenChamber data directory with an atomic + * temp-file rename. Failures are ignored: the in-memory cache stays + * authoritative and the next successful write retries persistence. + */ +export const writeDiskCache = (fileName, data) => { + const filePath = path.join(resolveDataDir(), fileName); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(tempPath, JSON.stringify(data), { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(tempPath, filePath); + return true; + } catch { + try { + fs.unlinkSync(tempPath); + } catch { + // ignore + } + return false; + } +}; diff --git a/packages/web/server/lib/skills-catalog/github-meta.js b/packages/web/server/lib/skills-catalog/github-meta.js new file mode 100644 index 00000000..08a14aad --- /dev/null +++ b/packages/web/server/lib/skills-catalog/github-meta.js @@ -0,0 +1,139 @@ +import { readDiskCache, writeDiskCache } from './disk-cache.js'; + +const GITHUB_API_BASE = 'https://api.github.com'; +const CACHE_TTL_MS = 3 * 60 * 60 * 1000; +const FAILURE_CACHE_TTL_MS = 5 * 60 * 1000; +// Keep well under the catalog route's client request deadline so optional +// metadata enrichment can never abort catalog loading. +const FETCH_TIMEOUT_MS = 1500; +const DISK_CACHE_FILE = 'skills-github-meta.json'; + +const metaCache = new Map(); +const inFlight = new Map(); + +let diskLoaded = false; +let diskWriteTimer = null; + +const loadDiskEntries = () => { + if (diskLoaded) { + return; + } + diskLoaded = true; + const persisted = readDiskCache(DISK_CACHE_FILE); + if (!persisted) { + return; + } + const now = Date.now(); + for (const [repo, entry] of Object.entries(persisted)) { + if ( + entry + && typeof entry === 'object' + && typeof entry.expiresAt === 'number' + && entry.expiresAt > now + && entry.value + && typeof entry.value === 'object' + ) { + metaCache.set(repo, entry); + } + } +}; + +const scheduleDiskWrite = () => { + if (diskWriteTimer) { + return; + } + diskWriteTimer = setTimeout(() => { + diskWriteTimer = null; + const now = Date.now(); + const persisted = {}; + for (const [repo, entry] of metaCache.entries()) { + if (entry.expiresAt > now) { + persisted[repo] = entry; + } + } + writeDiskCache(DISK_CACHE_FILE, persisted); + }, 1000); + if (typeof diskWriteTimer.unref === 'function') { + diskWriteTimer.unref(); + } +}; + +const parseMeta = (payload) => { + if (!payload || typeof payload !== 'object') { + return null; + } + const pushedAt = payload.pushed_at; + return { + stars: Number.isFinite(payload.stargazers_count) ? payload.stargazers_count : null, + repoUpdatedAt: typeof pushedAt === 'string' && pushedAt ? pushedAt : null, + }; +}; + +const fetchRepoMeta = async (normalizedRepo) => { + loadDiskEntries(); + const cached = metaCache.get(normalizedRepo); + if (cached && Date.now() < cached.expiresAt) { + return cached.value; + } + + const existing = inFlight.get(normalizedRepo); + if (existing) { + return existing; + } + + const run = (async () => { + try { + const response = await fetch(`${GITHUB_API_BASE}/repos/${normalizedRepo}`, { + headers: { Accept: 'application/vnd.github+json' }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + // Cache failures briefly so repeated catalog loads do not re-hit a + // rate-limited or failing API for the same repository. + metaCache.set(normalizedRepo, { + expiresAt: Date.now() + FAILURE_CACHE_TTL_MS, + value: { stars: null, repoUpdatedAt: null }, + }); + scheduleDiskWrite(); + return null; + } + + const value = parseMeta(await response.json()); + if (value) { + metaCache.set(normalizedRepo, { expiresAt: Date.now() + CACHE_TTL_MS, value }); + scheduleDiskWrite(); + } + return value; + } catch { + metaCache.set(normalizedRepo, { + expiresAt: Date.now() + FAILURE_CACHE_TTL_MS, + value: { stars: null, repoUpdatedAt: null }, + }); + scheduleDiskWrite(); + return null; + } finally { + inFlight.delete(normalizedRepo); + } + })(); + + inFlight.set(normalizedRepo, run); + return run; +}; + +/** + * Fetch GitHub repository metadata (stars, last push) for a list of + * `owner/repo` strings. Best-effort: failed lookups resolve to null and + * never block the catalog response. + */ +export async function fetchGitHubRepoMetas(normalizedRepos) { + const unique = [...new Set(normalizedRepos.filter(Boolean))]; + const entries = await Promise.all(unique.map(async (repo) => [repo, await fetchRepoMeta(repo)])); + return Object.fromEntries(entries); +} + +/** For tests only: clear the in-memory repository metadata cache. */ +export function clearGitHubMetaCache() { + metaCache.clear(); + inFlight.clear(); + diskLoaded = true; +} diff --git a/packages/web/server/lib/skills-catalog/github-meta.test.js b/packages/web/server/lib/skills-catalog/github-meta.test.js new file mode 100644 index 00000000..f0a5c84e --- /dev/null +++ b/packages/web/server/lib/skills-catalog/github-meta.test.js @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { clearGitHubMetaCache, fetchGitHubRepoMetas } from './github-meta.js'; + +const originalFetch = globalThis.fetch; + +let tempDataDir; + +beforeEach(() => { + tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'github-meta-test-')); + process.env.OPENCHAMBER_DATA_DIR = tempDataDir; +}); + +afterEach(() => { + delete process.env.OPENCHAMBER_DATA_DIR; + globalThis.fetch = originalFetch; + clearGitHubMetaCache(); + vi.restoreAllMocks(); + fs.rmSync(tempDataDir, { recursive: true, force: true }); +}); + +describe('fetchGitHubRepoMetas', () => { + it('returns stars and pushed_at from the GitHub API', async () => { + const fetchMock = vi.fn(async () => new Response( + JSON.stringify({ stargazers_count: 42, pushed_at: '2026-08-01T00:00:00Z' }), + { status: 200 }, + )); + globalThis.fetch = fetchMock; + + const metas = await fetchGitHubRepoMetas(['anthropics/skills']); + + expect(metas).toEqual({ + 'anthropics/skills': { stars: 42, repoUpdatedAt: '2026-08-01T00:00:00Z' }, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('resolves failed lookups to null without throwing', async () => { + globalThis.fetch = vi.fn(async () => new Response('rate limited', { status: 403 })); + + const metas = await fetchGitHubRepoMetas(['anthropics/skills']); + + expect(metas).toEqual({ 'anthropics/skills': null }); + }); + + it('caches failed lookups briefly to avoid repeat hits', async () => { + const fetchMock = vi.fn(async () => new Response('rate limited', { status: 403 })); + globalThis.fetch = fetchMock; + + await fetchGitHubRepoMetas(['anthropics/skills']); + const second = await fetchGitHubRepoMetas(['anthropics/skills']); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(second).toEqual({ 'anthropics/skills': { stars: null, repoUpdatedAt: null } }); + }); + + it('deduplicates repositories', async () => { + const fetchMock = vi.fn(async () => new Response( + JSON.stringify({ stargazers_count: 1, pushed_at: null }), + { status: 200 }, + )); + globalThis.fetch = fetchMock; + + const metas = await fetchGitHubRepoMetas(['a/b', 'a/b', null]); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(metas['a/b']).toEqual({ stars: 1, repoUpdatedAt: null }); + }); +}); diff --git a/packages/web/server/lib/skills-catalog/source.js b/packages/web/server/lib/skills-catalog/source.js index 24e1dd8f..5af2a100 100644 --- a/packages/web/server/lib/skills-catalog/source.js +++ b/packages/web/server/lib/skills-catalog/source.js @@ -1,5 +1,4 @@ const GITHUB_HOST = 'github.com'; -const CLAWDHUB_SOURCE_PREFIX = 'clawdhub:'; function normalizeGitOwnerRepo(owner, repo) { @@ -86,7 +85,3 @@ export function parseSkillRepoSource(input, options = {}) { return { ok: false, error: { kind: 'invalidSource', message: 'Unsupported repository source format' } }; } - -export function isClawdHubSource(input) { - return typeof input === 'string' && input.trim().toLowerCase().startsWith(CLAWDHUB_SOURCE_PREFIX); -}