feat(skills): curated GitHub catalog redesign (#3016)
* feat(skills): remove ClawHub catalog integration Drop the ClawHub registry as a skills catalog source across web server, shared UI, VS Code, docs, and locales. The catalog now serves git-based sources only: the curated Anthropic repo and user-defined repositories. Also removes the now-unused adm-zip dependency. * feat(skills): redesign catalog around curated GitHub repositories Replace the single-source dropdown with a card grid of curated GitHub repositories (Anthropic, OpenAI, Cursor pstack/skills, Matt Pocock) plus user-defined sources. Source cards show skill counts, GitHub stars, and last-updated time; a global search covers all loaded sources. Server: curated sources gain GitHub repo metadata (stars, pushed_at) fetched best-effort with a 3-hour in-memory and on-disk cache; scans run through a concurrency-limited, deduplicated cache with 3-hour TTL persisted across restarts. Refresh still bypasses the cache. Shared UI: source cards, global search with clear button, per-skill GitHub links, install/installed states. VS Code curated list updated to match. All new copy translated across 12 locales. * fix(skills): address catalog review findings - GitHub metadata fetch timeout drops to 1.5s (under the catalog client's 3s deadline) and failed lookups cache briefly (5 min) so repeated catalog loads do not re-hit a failing API. - Disk cache files are written with owner-only permissions (0o600); rename preserves the mode. - loadSource deduplicates concurrent in-flight requests per source and the shared isLoadingSource flag now clears only when the last active source load finishes.
This commit is contained in:
committed by
GitHub
parent
90d8868bfc
commit
1ed3f1f575
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user