Files
openchamber/packages/ui/src/stores/useSkillsCatalogStore.ts
T
Bohdan Triapitsyn 1ed3f1f575 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.
2026-08-20 01:40:10 +03:00

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' }
)
);