Files
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

88 lines
3.3 KiB
JavaScript

const GITHUB_HOST = 'github.com';
function normalizeGitOwnerRepo(owner, repo) {
const normalizedOwner = String(owner || '').trim();
const normalizedRepo = String(repo || '').trim().replace(/\.git$/i, '');
if (!normalizedOwner || !normalizedRepo) {
return null;
}
return { owner: normalizedOwner, repo: normalizedRepo };
}
export function parseSkillRepoSource(input, options = {}) {
const raw = typeof input === 'string' ? input.trim() : '';
if (!raw) {
return { ok: false, error: { kind: 'invalidSource', message: 'Repository source is required' } };
}
const explicitSubpath = typeof options.subpath === 'string' && options.subpath.trim() ? options.subpath.trim() : null;
const urlFormat = raw.startsWith('https://') ? 'https' : raw.startsWith('git@') ? 'ssh' : 'shorthand';
const gitHost = urlFormat === 'https' ? raw.split('/')[2] : urlFormat === 'ssh' ? raw.split('@')[1].split(':')[0] : null;
if (gitHost === null && urlFormat !== 'shorthand') {
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid repository URL format' } };
}
const pathSegments = urlFormat === 'https'
? raw.split('/').slice(3).filter(Boolean)
: urlFormat === 'ssh'
? (raw.split('@')[1].split(':')[1] ?? '').split('/').filter(Boolean)
: null;
const repoName = pathSegments && pathSegments.length > 0
? pathSegments[pathSegments.length - 1].replace(/\.git$/i, '')
: null;
const gitOwner = pathSegments && pathSegments.length > 1
? pathSegments.slice(0, -1).join('/')
: (pathSegments && pathSegments.length === 1 ? pathSegments[0] : null);
// SSH git@host:owner/repo(.git) or HTTPS https://host/owner/repo(.git)
if (urlFormat === 'ssh' || urlFormat === 'https') {
const parsed = normalizeGitOwnerRepo(gitOwner, repoName);
if (!parsed) {
return { ok: false, error: { kind: 'invalidSource', message: `Invalid ${urlFormat} repository URL` } };
}
return {
ok: true,
host: gitHost,
owner: parsed.owner,
repo: parsed.repo,
cloneUrlSsh: `git@${gitHost}:${parsed.owner}/${parsed.repo}.git`,
cloneUrlHttps: `https://${gitHost}/${parsed.owner}/${parsed.repo}.git`,
// For SSH URLs, subpath is only accepted via options.subpath
effectiveSubpath: explicitSubpath,
normalizedRepo: `${parsed.owner}/${parsed.repo}`,
};
}
// Shorthand: owner/repo[/subpath...]
const shorthandMatch = raw.match(/^([^/\s]+)\/([^/\s]+)(?:\/(.+))?$/);
if (shorthandMatch) {
const parsed = normalizeGitOwnerRepo(shorthandMatch[1], shorthandMatch[2]);
if (!parsed) {
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid repository source' } };
}
const shorthandSubpath = typeof shorthandMatch[3] === 'string' && shorthandMatch[3].trim() ? shorthandMatch[3].trim() : null;
const effectiveSubpath = explicitSubpath || shorthandSubpath;
return {
ok: true,
host: GITHUB_HOST,
owner: parsed.owner,
repo: parsed.repo,
cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`,
cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
effectiveSubpath,
normalizedRepo: `${parsed.owner}/${parsed.repo}`,
};
}
return { ok: false, error: { kind: 'invalidSource', message: 'Unsupported repository source format' } };
}