* 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.
402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
import { create } from 'zustand';
|
|
import { devtools } from 'zustand/middleware';
|
|
|
|
import type {
|
|
SkillsCatalogResponse,
|
|
SkillsCatalogSource,
|
|
SkillsCatalogItem,
|
|
SkillsRepoScanRequest,
|
|
SkillsRepoScanResponse,
|
|
SkillsInstallRequest,
|
|
SkillsInstallResponse,
|
|
SkillsInstallError,
|
|
SkillsCatalogSourceResponse,
|
|
} from '@/lib/api/types';
|
|
|
|
import { invalidateSkillsLoadCache, refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore';
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { startConfigUpdate } from '@/lib/configUpdate';
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
|
import { noteDeferredRestartFromPayload } from '@/lib/opencode/deferredRestart';
|
|
|
|
const FALLBACK_SOURCES: SkillsCatalogSource[] = [
|
|
{
|
|
id: 'anthropic',
|
|
label: 'Anthropic',
|
|
description: "Anthropic's public skills repository",
|
|
source: 'anthropics/skills',
|
|
defaultSubpath: 'skills',
|
|
sourceType: 'github',
|
|
},
|
|
{
|
|
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',
|
|
},
|
|
];
|
|
|
|
const SKILLS_CATALOG_LOAD_CACHE_TTL_MS = 5000;
|
|
const DEFAULT_SKILLS_CATALOG_CACHE_KEY = '__default__';
|
|
const skillsCatalogLastLoadedAt = new Map<string, number>();
|
|
const skillsCatalogLoadInFlight = new Map<string, Promise<boolean>>();
|
|
const sourceLoadInFlight = new Map<string, Promise<boolean>>();
|
|
let activeSourceLoads = 0;
|
|
|
|
const getSkillsCatalogCacheKey = (directory: string | null): string => {
|
|
return directory?.trim() || DEFAULT_SKILLS_CATALOG_CACHE_KEY;
|
|
};
|
|
|
|
const getRequestDirectory = (): string | null => {
|
|
try {
|
|
const projectsStore = useProjectsStore.getState();
|
|
const activeProject = projectsStore.getActiveProject?.();
|
|
|
|
if (activeProject?.path?.trim()) {
|
|
return activeProject.path.trim();
|
|
}
|
|
|
|
const clientDir = opencodeClient.getDirectory();
|
|
if (clientDir?.trim()) {
|
|
return clientDir.trim();
|
|
}
|
|
} catch (err) {
|
|
console.warn('[SkillsCatalogStore] Error resolving config directory:', err);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
export interface SkillsCatalogState {
|
|
sources: SkillsCatalogSource[];
|
|
itemsBySource: Record<string, SkillsCatalogItem[]>;
|
|
selectedSourceId: string | null;
|
|
loadedSourceIds: Record<string, boolean>;
|
|
|
|
isLoadingCatalog: boolean;
|
|
isLoadingSource: boolean;
|
|
isScanning: boolean;
|
|
isInstalling: boolean;
|
|
|
|
lastCatalogError: SkillsCatalogResponse['error'] | null;
|
|
lastScanError: SkillsRepoScanResponse['error'] | null;
|
|
lastInstallError: SkillsInstallError | null;
|
|
|
|
scanResults: SkillsCatalogItem[] | null;
|
|
|
|
setSelectedSource: (id: string | null) => void;
|
|
|
|
loadCatalog: (options?: { refresh?: boolean }) => Promise<boolean>;
|
|
loadSource: (sourceId: string, options?: { refresh?: boolean }) => Promise<boolean>;
|
|
scanRepo: (request: SkillsRepoScanRequest) => Promise<SkillsRepoScanResponse>;
|
|
installSkills: (request: SkillsInstallRequest, options?: { directory?: string | null }) => Promise<SkillsInstallResponse>;
|
|
}
|
|
|
|
export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
|
devtools(
|
|
(set, get) => ({
|
|
sources: FALLBACK_SOURCES,
|
|
itemsBySource: {},
|
|
selectedSourceId: FALLBACK_SOURCES[0]?.id ?? null,
|
|
loadedSourceIds: {},
|
|
|
|
isLoadingCatalog: false,
|
|
isLoadingSource: false,
|
|
isScanning: false,
|
|
isInstalling: false,
|
|
|
|
lastCatalogError: null,
|
|
lastScanError: null,
|
|
lastInstallError: null,
|
|
|
|
scanResults: null,
|
|
|
|
setSelectedSource: (id) => set({ selectedSourceId: id }),
|
|
|
|
loadCatalog: async (options) => {
|
|
const currentDirectory = getRequestDirectory();
|
|
const cacheKey = getSkillsCatalogCacheKey(currentDirectory);
|
|
const now = Date.now();
|
|
const loadedAt = skillsCatalogLastLoadedAt.get(cacheKey) ?? 0;
|
|
const hasCachedCatalog = get().sources.length > 0;
|
|
if (!options?.refresh && hasCachedCatalog && now - loadedAt < SKILLS_CATALOG_LOAD_CACHE_TTL_MS) {
|
|
return true;
|
|
}
|
|
|
|
const inFlight = skillsCatalogLoadInFlight.get(cacheKey);
|
|
if (!options?.refresh && inFlight) {
|
|
return inFlight;
|
|
}
|
|
|
|
const request = (async () => {
|
|
set({ isLoadingCatalog: true, lastCatalogError: null });
|
|
|
|
const previous = {
|
|
sources: get().sources,
|
|
itemsBySource: get().itemsBySource,
|
|
loadedSourceIds: get().loadedSourceIds,
|
|
};
|
|
|
|
let lastError: SkillsCatalogResponse['error'] | null = null;
|
|
|
|
try {
|
|
const refresh = options?.refresh ? '?refresh=true' : '';
|
|
const controller = new AbortController();
|
|
const timeoutId = window.setTimeout(() => controller.abort(), 3000);
|
|
|
|
try {
|
|
const response = await runtimeFetch(`/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})` };
|
|
throw new Error(lastError.message);
|
|
}
|
|
|
|
const sources = (payload.sources && payload.sources.length > 0) ? payload.sources : previous.sources;
|
|
const itemsBySource = options?.refresh ? {} : (get().itemsBySource || {});
|
|
const loadedSourceIds = options?.refresh ? {} : (get().loadedSourceIds || {});
|
|
const currentSelected = get().selectedSourceId;
|
|
const selectedSourceId =
|
|
(currentSelected && sources.some((s) => s.id === currentSelected))
|
|
? currentSelected
|
|
: (sources[0]?.id ?? null);
|
|
|
|
set({
|
|
sources,
|
|
itemsBySource,
|
|
loadedSourceIds,
|
|
selectedSourceId,
|
|
});
|
|
|
|
skillsCatalogLastLoadedAt.set(cacheKey, Date.now());
|
|
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,
|
|
loadedSourceIds: previous.loadedSourceIds,
|
|
lastCatalogError: lastError || { kind: 'unknown', message: 'Failed to load catalog' },
|
|
});
|
|
|
|
return false;
|
|
} finally {
|
|
set({ isLoadingCatalog: false });
|
|
}
|
|
})();
|
|
|
|
skillsCatalogLoadInFlight.set(cacheKey, request);
|
|
try {
|
|
return await request;
|
|
} finally {
|
|
skillsCatalogLoadInFlight.delete(cacheKey);
|
|
}
|
|
},
|
|
|
|
loadSource: async (sourceId, options) => {
|
|
if (!sourceId) {
|
|
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 });
|
|
|
|
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}`, {
|
|
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: { kind: 'unknown', message: error instanceof Error ? error.message : String(error) },
|
|
});
|
|
return false;
|
|
} finally {
|
|
activeSourceLoads -= 1;
|
|
if (activeSourceLoads === 0) {
|
|
set({ isLoadingSource: false });
|
|
}
|
|
}
|
|
})();
|
|
|
|
sourceLoadInFlight.set(sourceId, request);
|
|
try {
|
|
return await request;
|
|
} finally {
|
|
if (sourceLoadInFlight.get(sourceId) === request) {
|
|
sourceLoadInFlight.delete(sourceId);
|
|
}
|
|
}
|
|
},
|
|
|
|
scanRepo: async (request) => {
|
|
set({ isScanning: true, lastScanError: null, scanResults: null });
|
|
try {
|
|
const currentDirectory = getRequestDirectory();
|
|
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
|
|
|
const response = await runtimeFetch(`/api/config/skills/scan${queryParams}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify(request),
|
|
});
|
|
|
|
const payload = (await response.json().catch(() => null)) as SkillsRepoScanResponse | null;
|
|
if (!response.ok || !payload) {
|
|
const error = payload?.error || { kind: 'unknown', message: 'Failed to scan repository' };
|
|
set({ lastScanError: error });
|
|
return { ok: false, error };
|
|
}
|
|
|
|
if (!payload.ok) {
|
|
set({ lastScanError: payload.error || { kind: 'unknown', message: 'Failed to scan repository' } });
|
|
return payload;
|
|
}
|
|
|
|
set({ scanResults: payload.items || [] });
|
|
return payload;
|
|
} finally {
|
|
set({ isScanning: false });
|
|
}
|
|
},
|
|
|
|
installSkills: async (request, options) => {
|
|
set({ isInstalling: true, lastInstallError: null });
|
|
try {
|
|
const directoryOverride = typeof options?.directory === 'string' && options.directory.trim().length > 0
|
|
? options.directory.trim()
|
|
: null;
|
|
const currentDirectory = directoryOverride ?? getRequestDirectory();
|
|
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
|
|
|
const response = await runtimeFetch(`/api/config/skills/install${queryParams}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify(request),
|
|
});
|
|
|
|
const payload = (await response.json().catch(() => null)) as SkillsInstallResponse | null;
|
|
if (!payload) {
|
|
const error = { kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError;
|
|
set({ lastInstallError: error });
|
|
return { ok: false, error };
|
|
}
|
|
|
|
if (!response.ok || !payload.ok) {
|
|
const error = payload.error || ({ kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError);
|
|
set({ lastInstallError: error });
|
|
return { ok: false, error };
|
|
}
|
|
|
|
invalidateSkillsLoadCache(currentDirectory);
|
|
|
|
if (payload.requiresManualRestart) {
|
|
void get().loadCatalog({ refresh: true });
|
|
return payload;
|
|
}
|
|
|
|
if (noteDeferredRestartFromPayload(payload, 'skills')) {
|
|
void get().loadCatalog({ refresh: true });
|
|
return { ...payload, restartDeferred: true };
|
|
}
|
|
|
|
if (payload.requiresReload) {
|
|
startConfigUpdate('Installing skills…');
|
|
await refreshSkillsAfterOpenCodeRestart({
|
|
message: payload.message,
|
|
delayMs: payload.reloadDelayMs,
|
|
});
|
|
} else {
|
|
void useSkillsStore.getState().loadSkills();
|
|
}
|
|
|
|
return payload;
|
|
} catch (error) {
|
|
const err = { kind: 'unknown', message: error instanceof Error ? error.message : String(error) } as SkillsInstallError;
|
|
set({ lastInstallError: err });
|
|
return { ok: false, error: err };
|
|
} finally {
|
|
set({ isInstalling: false });
|
|
}
|
|
},
|
|
}),
|
|
{ name: 'skills-catalog-store' }
|
|
)
|
|
);
|