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
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user