feat: enhance ClawdHub integration with paging and retry

Add paging support when loading ClawdHub skills
Retry on API errors and throttle for ClawdHub requests
Load source content on source change and show loading indicators
This commit is contained in:
Bohdan Triapitsyn
2026-01-26 19:50:11 +02:00
parent eec170771c
commit 25525d5b1b
11 changed files with 699 additions and 319 deletions
+217 -39
View File
@@ -10,11 +10,30 @@ import type {
SkillsInstallRequest,
SkillsInstallResponse,
SkillsInstallError,
SkillsCatalogSourceResponse,
} from '@/lib/api/types';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { opencodeClient } from '@/lib/opencode/client';
const FALLBACK_SOURCES: SkillsCatalogSource[] = [
{
id: 'anthropic',
label: 'Anthropic',
description: "Anthropic's public skills repository",
source: 'anthropics/skills',
defaultSubpath: 'skills',
sourceType: 'github',
},
{
id: 'clawdhub',
label: 'ClawdHub',
description: 'Community skill registry with vector search',
source: 'clawdhub:registry',
sourceType: 'clawdhub',
},
];
const getCurrentDirectory = (): string | null => {
const opencodeDirectory = opencodeClient.getDirectory();
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
@@ -38,8 +57,13 @@ export interface SkillsCatalogState {
sources: SkillsCatalogSource[];
itemsBySource: Record<string, SkillsCatalogItem[]>;
selectedSourceId: string | null;
pageInfoBySource: Record<string, { nextCursor?: string | null }>;
loadedSourceIds: Record<string, boolean>;
clawdhubHasMoreBySource: Record<string, boolean>;
isLoadingCatalog: boolean;
isLoadingSource: boolean;
isLoadingMore: boolean;
isScanning: boolean;
isInstalling: boolean;
@@ -52,6 +76,8 @@ export interface SkillsCatalogState {
setSelectedSource: (id: string | null) => void;
loadCatalog: (options?: { refresh?: boolean }) => Promise<boolean>;
loadSource: (sourceId: string, options?: { refresh?: boolean }) => Promise<boolean>;
loadMoreClawdHub: () => Promise<boolean>;
scanRepo: (request: SkillsRepoScanRequest) => Promise<SkillsRepoScanResponse>;
installSkills: (request: SkillsInstallRequest) => Promise<SkillsInstallResponse>;
}
@@ -59,11 +85,16 @@ export interface SkillsCatalogState {
export const useSkillsCatalogStore = create<SkillsCatalogState>()(
devtools(
(set, get) => ({
sources: [],
sources: FALLBACK_SOURCES,
itemsBySource: {},
selectedSourceId: null,
selectedSourceId: FALLBACK_SOURCES[0]?.id ?? null,
pageInfoBySource: {},
loadedSourceIds: {},
clawdhubHasMoreBySource: {},
isLoadingCatalog: false,
isLoadingSource: false,
isLoadingMore: false,
isScanning: false,
isInstalling: false,
@@ -81,55 +112,64 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
const previous = {
sources: get().sources,
itemsBySource: get().itemsBySource,
pageInfoBySource: get().pageInfoBySource,
loadedSourceIds: get().loadedSourceIds,
clawdhubHasMoreBySource: get().clawdhubHasMoreBySource,
};
let lastError: SkillsCatalogResponse['error'] | null = null;
try {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const currentDirectory = getCurrentDirectory();
const refresh = options?.refresh ? '&refresh=true' : '';
const queryParams = currentDirectory
? `?directory=${encodeURIComponent(currentDirectory)}${refresh}`
: refresh
? `?refresh=true`
: '';
const refresh = options?.refresh ? '?refresh=true' : '';
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 3000);
const response = await fetch(`/api/config/skills/catalog${queryParams}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
try {
const response = await fetch(`/api/config/skills/catalog${refresh}`, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal,
});
const payload = (await response.json().catch(() => null)) as SkillsCatalogResponse | null;
if (!response.ok || !payload?.ok) {
lastError = payload?.error || { kind: 'unknown', message: `Failed to load catalog (${response.status})` };
const waitMs = 200 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
const sources = payload.sources || [];
const itemsBySource = payload.itemsBySource || {};
const currentSelected = get().selectedSourceId;
const selectedSourceId =
(currentSelected && sources.some((s) => s.id === currentSelected))
? currentSelected
: (sources[0]?.id ?? null);
set({ sources, itemsBySource, selectedSourceId });
return true;
} catch (error) {
lastError = { kind: 'unknown', message: error instanceof Error ? error.message : String(error) };
const waitMs = 200 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, waitMs));
const payload = (await response.json().catch(() => null)) as SkillsCatalogResponse | null;
if (!response.ok || !payload?.ok) {
lastError = payload?.error || { kind: 'unknown', message: `Failed to load catalog (${response.status})` };
throw new Error(lastError.message);
}
}
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))
? currentSelected
: (sources[0]?.id ?? null);
set({
sources,
itemsBySource,
pageInfoBySource,
loadedSourceIds,
clawdhubHasMoreBySource,
selectedSourceId,
});
return true;
} finally {
window.clearTimeout(timeoutId);
}
} catch (error) {
lastError = lastError || { kind: 'unknown', message: error instanceof Error ? error.message : String(error) };
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' },
});
@@ -139,6 +179,144 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
}
},
loadSource: async (sourceId, options) => {
if (!sourceId) {
return false;
}
set({ isLoadingSource: true, lastCatalogError: null });
try {
const currentDirectory = getCurrentDirectory();
const refresh = options?.refresh ? '&refresh=true' : '';
const queryParams = currentDirectory
? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}`
: `?sourceId=${encodeURIComponent(sourceId)}${refresh}`;
const response = await fetch(`/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 fetch(`/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 },
pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor: null } },
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
clawdhubHasMoreBySource: { ...state.clawdhubHasMoreBySource, [sourceId]: false },
}));
return true;
}
set({
lastCatalogError: payload?.error || { kind: 'unknown', message: `Failed to load source (${response.status})` },
});
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 = getCurrentDirectory();
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 fetch(`/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;
}
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;
} finally {
set({ isLoadingMore: false });
}
},
scanRepo: async (request) => {
set({ isScanning: true, lastScanError: null, scanResults: null });
try {