import fs from 'fs'; import os from 'os'; import path from 'path'; import { execFile } from 'child_process'; import { promisify } from 'util'; import yaml from 'yaml'; import AdmZip from 'adm-zip'; import { discoverSkills } from './opencodeConfig'; const execFileAsync = promisify(execFile); const DEFAULT_TIMEOUT_MS = 60_000; const DEFAULT_MAX_BUFFER = 4 * 1024 * 1024; const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; type SkillScope = 'user' | 'project'; export type SkillsCatalogSourceConfig = { id: string; label: string; description?: string; source: string; defaultSubpath?: string; }; type CuratedSource = SkillsCatalogSourceConfig; type SkillFrontmatter = { name?: unknown; description?: unknown; [key: string]: unknown; }; export type ClawdHubSkillMetadata = { slug: string; version: string; displayName?: string; owner?: string; downloads?: number; stars?: number; }; export type SkillsCatalogItem = { repoSource: string; repoSubpath?: string; skillDir: string; skillName: string; frontmatterName?: string; description?: string; installable: boolean; warnings?: string[]; clawdhub?: ClawdHubSkillMetadata; }; type SkillsCatalogItemWithBadge = SkillsCatalogItem & { sourceId: string; installed: { isInstalled: boolean; scope?: SkillScope }; }; type SkillsRepoError = | { kind: 'authRequired'; message: string; sshOnly: boolean } | { kind: 'invalidSource'; message: string } | { kind: 'gitUnavailable'; message: string } | { kind: 'networkError'; message: string } | { kind: 'unknown'; message: string } | { kind: 'conflicts'; message: string; conflicts: Array<{ skillName: string; scope: SkillScope }> }; type SkillsRepoScanResult = | { ok: true; items: SkillsCatalogItem[] } | { ok: false; error: SkillsRepoError }; type SkillsInstallResult = | { ok: true; installed: Array<{ skillName: string; scope: SkillScope }>; skipped: Array<{ skillName: string; reason: string }> } | { ok: false; error: SkillsRepoError }; export const CURATED_SOURCES: CuratedSource[] = [ { id: 'anthropic', label: 'Anthropic', description: "Anthropic's public skills repository", source: 'anthropics/skills', defaultSubpath: 'skills', }, { id: 'clawdhub', label: 'ClawdHub', description: 'Community skill registry with vector search', source: 'clawdhub:registry', }, ]; // ============== ClawdHub API ============== const CLAWDHUB_API_BASE = 'https://clawdhub.com/api/v1'; const CLAWDHUB_RATE_LIMIT_MS = 100; let clawdhubLastRequest = 0; function isClawdHubSource(source: string): boolean { return typeof source === 'string' && source.startsWith('clawdhub:'); } async function clawdhubFetch(url: string, options?: RequestInit): Promise { const now = Date.now(); const elapsed = now - clawdhubLastRequest; if (elapsed < CLAWDHUB_RATE_LIMIT_MS) { await new Promise((resolve) => setTimeout(resolve, CLAWDHUB_RATE_LIMIT_MS - elapsed)); } clawdhubLastRequest = Date.now(); return fetch(url, { ...options, headers: { Accept: 'application/json', 'User-Agent': 'OpenChamber-VSCode/1.0', ...options?.headers, }, }); } type ClawdHubSkillListItem = { slug: string; displayName?: string; summary?: string; tags?: { latest?: string }; latestVersion?: { version?: string }; stats?: { downloads?: number; stars?: number }; owner?: { handle?: string }; }; type ClawdHubSkillsResponse = { items: ClawdHubSkillListItem[]; nextCursor?: string; }; async function scanClawdHub(): Promise { try { const allItems: SkillsCatalogItem[] = []; let cursor: string | null = null; const maxPages = 20; for (let page = 0; page < maxPages; page++) { const url = cursor ? `${CLAWDHUB_API_BASE}/skills?cursor=${encodeURIComponent(cursor)}` : `${CLAWDHUB_API_BASE}/skills`; const response = await clawdhubFetch(url); if (!response.ok) { throw new Error(`ClawdHub API error: ${response.status}`); } const data = (await response.json()) as ClawdHubSkillsResponse; for (const item of data.items || []) { const latestVersion = item.tags?.latest || item.latestVersion?.version || '1.0.0'; allItems.push({ repoSource: 'clawdhub:registry', skillDir: item.slug, skillName: item.slug, frontmatterName: item.displayName || item.slug, description: item.summary || undefined, installable: true, clawdhub: { slug: item.slug, version: latestVersion, displayName: item.displayName, owner: item.owner?.handle, downloads: item.stats?.downloads || 0, stars: item.stats?.stars || 0, }, }); } if (!data.nextCursor) break; cursor = data.nextCursor; } // Sort by downloads (most popular first) allItems.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0)); return { ok: true, items: allItems }; } catch (error) { return { ok: false, error: { kind: 'networkError', message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub', }, }; } } async function downloadClawdHubSkill(slug: string, version: string): Promise { const url = `${CLAWDHUB_API_BASE}/download?slug=${encodeURIComponent(slug)}&version=${encodeURIComponent(version)}`; const response = await clawdhubFetch(url, { headers: { Accept: 'application/zip' } }); if (!response.ok) { throw new Error(`ClawdHub download error: ${response.status}`); } const arrayBuffer = await response.arrayBuffer(); return Buffer.from(arrayBuffer); } type ClawdHubSkillInfoResponse = { skill?: { tags?: { latest?: string } }; latestVersion?: { version?: string }; }; async function fetchClawdHubSkillInfo(slug: string): Promise { const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`; const response = await clawdhubFetch(url); if (!response.ok) { throw new Error(`ClawdHub skill error: ${response.status}`); } return response.json() as Promise; } export async function installSkillsFromClawdHub(options: { scope: SkillScope; workingDirectory?: string; selections: Array<{ skillDir: string; clawdhub?: { slug: string; version: string } }>; conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll'; conflictDecisions?: Record; }): Promise { if (options.scope === 'project' && !options.workingDirectory) { return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } }; } const userSkillDir = getUserSkillBaseDir(); const requestedSkills = options.selections || []; if (requestedSkills.length === 0) { return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } }; } // Check for conflicts const conflicts: Array<{ skillName: string; scope: SkillScope }> = []; for (const sel of requestedSkills) { const slug = sel.clawdhub?.slug || sel.skillDir; if (!validateSkillName(slug)) continue; const targetDir = options.scope === 'user' ? path.join(userSkillDir, slug) : path.join(options.workingDirectory as string, '.opencode', 'skills', slug); if (fs.existsSync(targetDir)) { const decision = options.conflictDecisions?.[slug]; const hasAutoPolicy = options.conflictPolicy === 'skipAll' || options.conflictPolicy === 'overwriteAll'; if (!decision && !hasAutoPolicy) { conflicts.push({ skillName: slug, scope: options.scope }); } } } if (conflicts.length > 0) { return { ok: false, error: { kind: 'conflicts', message: 'Some skills already exist in the selected scope', conflicts } }; } const installed: Array<{ skillName: string; scope: SkillScope }> = []; const skipped: Array<{ skillName: string; reason: string }> = []; for (const sel of requestedSkills) { const slug = sel.clawdhub?.slug || sel.skillDir; let version = sel.clawdhub?.version || 'latest'; if (!validateSkillName(slug)) { skipped.push({ skillName: slug, reason: 'Invalid skill name' }); continue; } try { // Resolve 'latest' version if (version === 'latest') { try { const info = await fetchClawdHubSkillInfo(slug); version = info.skill?.tags?.latest || info.latestVersion?.version || version; } catch { // Fall back to 'latest' } } const targetDir = options.scope === 'user' ? path.join(userSkillDir, slug) : path.join(options.workingDirectory as string, '.opencode', 'skills', slug); const exists = fs.existsSync(targetDir); let decision = options.conflictDecisions?.[slug] || null; if (!decision) { if (exists && options.conflictPolicy === 'skipAll') decision = 'skip'; if (exists && options.conflictPolicy === 'overwriteAll') decision = 'overwrite'; if (!exists) decision = 'overwrite'; } if (exists && decision === 'skip') { skipped.push({ skillName: slug, reason: 'Already installed (skipped)' }); continue; } if (exists && decision === 'overwrite') { await safeRm(targetDir); } // Download and extract const zipBuffer = await downloadClawdHubSkill(slug, version); const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `clawdhub-${slug}-`)); try { const zip = new AdmZip(zipBuffer); zip.extractAllTo(tempDir, true); // Verify SKILL.md exists const skillMdPath = path.join(tempDir, 'SKILL.md'); if (!fs.existsSync(skillMdPath)) { skipped.push({ skillName: slug, reason: 'SKILL.md not found in downloaded package' }); continue; } // Move to target directory await fs.promises.mkdir(path.dirname(targetDir), { recursive: true }); await fs.promises.rename(tempDir, targetDir); installed.push({ skillName: slug, scope: options.scope }); } catch (extractError) { await safeRm(tempDir); throw extractError; } } catch (error) { skipped.push({ skillName: slug, reason: error instanceof Error ? error.message : 'Failed to download or extract skill', }); } } return { ok: true, installed, skipped }; } function validateSkillName(skillName: string): boolean { if (skillName.length < 1 || skillName.length > 64) return false; return SKILL_NAME_PATTERN.test(skillName); } function looksLikeAuthError(message: string): boolean { return ( /permission denied/i.test(message) || /publickey/i.test(message) || /could not read from remote repository/i.test(message) || /authentication failed/i.test(message) ); } async function runGit(args: string[], options?: { cwd?: string; timeoutMs?: number }) { try { const { stdout, stderr } = await execFileAsync('git', args, { cwd: options?.cwd, timeout: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, maxBuffer: DEFAULT_MAX_BUFFER, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', }, }); return { ok: true as const, stdout: stdout || '', stderr: stderr || '' }; } catch (error) { const err = error as { stdout?: string; stderr?: string; message?: string }; return { ok: false as const, stdout: typeof err.stdout === 'string' ? err.stdout : '', stderr: typeof err.stderr === 'string' ? err.stderr : '', message: typeof err.message === 'string' ? err.message : 'Git command failed', }; } } async function assertGitAvailable() { const result = await runGit(['--version'], { timeoutMs: 5_000 }); if (!result.ok) { return { ok: false as const, error: { kind: 'gitUnavailable' as const, message: 'Git is not available in PATH' } }; } return { ok: true as const }; } function parseSkillRepoSource(input: string, subpath?: string) { const raw = (input || '').trim(); if (!raw) { return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'Repository source is required' } }; } const explicitSubpath = subpath?.trim() ? subpath.trim() : null; const sshMatch = raw.match(/^git@github\.com:([^/\s]+)\/([^\s#]+)$/i); if (sshMatch) { const owner = sshMatch[1]; const repo = sshMatch[2].replace(/\.git$/i, ''); return { ok: true as const, normalizedRepo: `${owner}/${repo}`, cloneUrlHttps: `https://github.com/${owner}/${repo}.git`, cloneUrlSsh: `git@github.com:${owner}/${repo}.git`, effectiveSubpath: explicitSubpath, }; } const httpsMatch = raw.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^\s#]+)$/i); if (httpsMatch) { const owner = httpsMatch[1]; const repo = httpsMatch[2].replace(/\.git$/i, ''); return { ok: true as const, normalizedRepo: `${owner}/${repo}`, cloneUrlHttps: `https://github.com/${owner}/${repo}.git`, cloneUrlSsh: `git@github.com:${owner}/${repo}.git`, effectiveSubpath: explicitSubpath, }; } const shorthandMatch = raw.match(/^([^/\s]+)\/([^/\s]+)(?:\/(.+))?$/); if (shorthandMatch) { const owner = shorthandMatch[1]; const repo = shorthandMatch[2].replace(/\.git$/i, ''); const shorthandSubpath = shorthandMatch[3]?.trim() || null; return { ok: true as const, normalizedRepo: `${owner}/${repo}`, cloneUrlHttps: `https://github.com/${owner}/${repo}.git`, cloneUrlSsh: `git@github.com:${owner}/${repo}.git`, effectiveSubpath: explicitSubpath || shorthandSubpath, }; } return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'Unsupported repository source format' } }; } function parseSkillMd(content: string): { frontmatter: SkillFrontmatter; warnings: string[] } { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); if (!match) { return { frontmatter: {}, warnings: ['Invalid SKILL.md: missing YAML frontmatter delimiter'], }; } try { const parsed = yaml.parse(match[1]); const frontmatter = parsed && typeof parsed === 'object' ? (parsed as SkillFrontmatter) : {}; return { frontmatter, warnings: [] }; } catch { return { frontmatter: {}, warnings: ['Invalid SKILL.md: failed to parse YAML frontmatter'], }; } } async function safeRm(dir: string) { try { await fs.promises.rm(dir, { recursive: true, force: true }); } catch { // ignore } } async function cloneRepo(cloneUrl: string, targetDir: string) { const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, targetDir]; const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, targetDir]; const result = await runGit(preferred, { timeoutMs: 60_000 }); if (result.ok) return { ok: true as const }; const fallbackResult = await runGit(fallback, { timeoutMs: 60_000 }); if (fallbackResult.ok) return { ok: true as const }; const combined = `${fallbackResult.stderr}\n${fallbackResult.message}`.trim(); if (looksLikeAuthError(combined)) { return { ok: false as const, error: { kind: 'authRequired' as const, message: 'Private repositories are not supported in VS Code yet. Use Desktop/Web.', sshOnly: true, }, }; } return { ok: false as const, error: { kind: 'networkError' as const, message: combined || 'Failed to clone repository' } }; } export async function scanSkillsRepository(options: { source: string; subpath?: string; defaultSubpath?: string }): Promise { const gitCheck = await assertGitAvailable(); if (!gitCheck.ok) { return { ok: false as const, error: gitCheck.error }; } const parsed = parseSkillRepoSource(options.source, options.subpath); if (!parsed.ok) { return { ok: false as const, error: parsed.error }; } const effectiveSubpath = parsed.effectiveSubpath || options.defaultSubpath || null; const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-vscode-skills-scan-')); try { const cloned = await cloneRepo(parsed.cloneUrlHttps, tempBase); if (!cloned.ok) { return { ok: false as const, error: cloned.error }; } const toFsPath = (posixPath: string) => path.join(tempBase, ...posixPath.split('/').filter(Boolean)); const patterns = effectiveSubpath ? [`${effectiveSubpath}/SKILL.md`, `${effectiveSubpath}/**/SKILL.md`] : ['SKILL.md', '**/SKILL.md']; let skillMdPaths: string[] | null = null; const sparseInit = await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--no-cone'], { timeoutMs: 15_000 }); if (sparseInit.ok) { const sparseSet = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...patterns], { timeoutMs: 30_000 }); if (sparseSet.ok) { const checkout = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { timeoutMs: 60_000 }); if (checkout.ok) { const lsFiles = await runGit(['-C', tempBase, 'ls-files'], { timeoutMs: 15_000 }); if (lsFiles.ok) { skillMdPaths = lsFiles.stdout .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md'); } } } } if (!Array.isArray(skillMdPaths)) { const listArgs = ['-C', tempBase, 'ls-tree', '-r', '--name-only', 'HEAD']; if (effectiveSubpath) { listArgs.push('--', effectiveSubpath); } const list = await runGit(listArgs, { timeoutMs: 30_000 }); if (!list.ok) { return { ok: true as const, items: [] as SkillsCatalogItem[] }; } skillMdPaths = list.stdout .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean) .filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md'); } const skillDirs = Array.from(new Set(skillMdPaths.filter((p) => p !== 'SKILL.md').map((p) => path.posix.dirname(p)))); const items: SkillsCatalogItem[] = []; for (const skillDir of skillDirs) { const skillName = path.posix.basename(skillDir); const skillMdPath = path.posix.join(skillDir, 'SKILL.md'); const warnings: string[] = []; let content = ''; try { content = await fs.promises.readFile(toFsPath(skillMdPath), 'utf8'); } catch { const show = await runGit(['-C', tempBase, 'show', `HEAD:${skillMdPath}`], { timeoutMs: 15_000 }); if (!show.ok) { warnings.push('Failed to read SKILL.md'); } else { content = show.stdout; } } const parsedMd = parseSkillMd(content); warnings.push(...parsedMd.warnings); const description = typeof parsedMd.frontmatter.description === 'string' ? parsedMd.frontmatter.description : undefined; const frontmatterName = typeof parsedMd.frontmatter.name === 'string' ? parsedMd.frontmatter.name : undefined; const installable = validateSkillName(skillName); if (!installable) { warnings.push('Skill directory name is not a valid OpenCode skill name'); } items.push({ repoSource: options.source, repoSubpath: effectiveSubpath || undefined, skillDir, skillName, frontmatterName, description, installable, warnings: warnings.length ? warnings : undefined, }); } items.sort((a, b) => String(a.skillName).localeCompare(String(b.skillName))); return { ok: true as const, items }; } finally { await safeRm(tempBase); } } async function copyDirectoryNoSymlinks(srcDir: string, dstDir: string) { const srcReal = await fs.promises.realpath(srcDir); const ensureDir = async (dirPath: string) => { await fs.promises.mkdir(dirPath, { recursive: true }); }; const walk = async (currentSrc: string, currentDst: string) => { const entries = await fs.promises.readdir(currentSrc, { withFileTypes: true }); for (const entry of entries) { const nextSrc = path.join(currentSrc, entry.name); const nextDst = path.join(currentDst, entry.name); const stat = await fs.promises.lstat(nextSrc); if (stat.isSymbolicLink()) { throw new Error('Symlinks are not supported in skills'); } const nextRealParent = await fs.promises.realpath(path.dirname(nextSrc)); if (!nextRealParent.startsWith(srcReal)) { throw new Error('Invalid source path traversal detected'); } if (stat.isDirectory()) { await ensureDir(nextDst); await walk(nextSrc, nextDst); continue; } if (stat.isFile()) { await ensureDir(path.dirname(nextDst)); await fs.promises.copyFile(nextSrc, nextDst); try { await fs.promises.chmod(nextDst, stat.mode & 0o777); } catch { // best-effort } } } }; await ensureDir(dstDir); await walk(srcDir, dstDir); } function getUserSkillBaseDir() { const pluralPath = path.join(os.homedir(), '.config', 'opencode', 'skills'); const legacyPath = path.join(os.homedir(), '.config', 'opencode', 'skill'); if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; return pluralPath; } function toFsPath(repoDir: string, repoRelPosixPath: string) { const parts = repoRelPosixPath.split('/').filter(Boolean); return path.join(repoDir, ...parts); } export async function installSkillsFromRepository(options: { source: string; subpath?: string; scope: SkillScope; workingDirectory?: string; selections: Array<{ skillDir: string }>; conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll'; conflictDecisions?: Record; }): Promise { const gitCheck = await assertGitAvailable(); if (!gitCheck.ok) { return { ok: false as const, error: gitCheck.error }; } if (options.scope === 'project' && !options.workingDirectory) { return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'Project installs require a directory parameter' } }; } const parsed = parseSkillRepoSource(options.source, options.subpath); if (!parsed.ok) { return { ok: false as const, error: parsed.error }; } const requestedDirs = options.selections.map((s) => String(s.skillDir || '').trim()).filter(Boolean); if (requestedDirs.length === 0) { return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'No skills selected for installation' } }; } const userSkillDir = getUserSkillBaseDir(); const skillPlans = requestedDirs.map((dir) => { const skillName = path.posix.basename(dir); return { skillDirPosix: dir, skillName, installable: validateSkillName(skillName) }; }); const conflicts: Array<{ skillName: string; scope: SkillScope }> = []; for (const plan of skillPlans) { if (!plan.installable) continue; const targetDir = options.scope === 'user' ? path.join(userSkillDir, plan.skillName) : path.join(options.workingDirectory as string, '.opencode', 'skills', plan.skillName); if (fs.existsSync(targetDir)) { const decision = options.conflictDecisions?.[plan.skillName]; const hasAutoPolicy = options.conflictPolicy === 'skipAll' || options.conflictPolicy === 'overwriteAll'; if (!decision && !hasAutoPolicy) { conflicts.push({ skillName: plan.skillName, scope: options.scope }); } } } if (conflicts.length > 0) { return { ok: false as const, error: { kind: 'conflicts' as const, message: 'Some skills already exist in the selected scope', conflicts }, }; } const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-vscode-skills-install-')); try { const cloned = await cloneRepo(parsed.cloneUrlHttps, tempBase); if (!cloned.ok) { return { ok: false as const, error: cloned.error }; } await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--cone'], { timeoutMs: 15_000 }); const setResult = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...requestedDirs], { timeoutMs: 30_000 }); if (!setResult.ok) { return { ok: false as const, error: { kind: 'unknown' as const, message: setResult.stderr || setResult.message || 'Failed to configure sparse checkout' } }; } const checkoutResult = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { timeoutMs: 60_000 }); if (!checkoutResult.ok) { return { ok: false as const, error: { kind: 'unknown' as const, message: checkoutResult.stderr || checkoutResult.message || 'Failed to checkout repository' } }; } const installed: Array<{ skillName: string; scope: SkillScope }> = []; const skipped: Array<{ skillName: string; reason: string }> = []; for (const plan of skillPlans) { if (!plan.installable) { skipped.push({ skillName: plan.skillName, reason: 'Invalid skill name (directory basename)' }); continue; } const srcDir = toFsPath(tempBase, plan.skillDirPosix); const skillMdPath = path.join(srcDir, 'SKILL.md'); if (!fs.existsSync(skillMdPath)) { skipped.push({ skillName: plan.skillName, reason: 'SKILL.md not found in selected directory' }); continue; } const targetDir = options.scope === 'user' ? path.join(userSkillDir, plan.skillName) : path.join(options.workingDirectory as string, '.opencode', 'skills', plan.skillName); const exists = fs.existsSync(targetDir); let decision = options.conflictDecisions?.[plan.skillName] || null; if (!decision) { if (exists && options.conflictPolicy === 'skipAll') decision = 'skip'; if (exists && options.conflictPolicy === 'overwriteAll') decision = 'overwrite'; if (!exists) decision = 'overwrite'; } if (exists && decision === 'skip') { skipped.push({ skillName: plan.skillName, reason: 'Already installed (skipped)' }); continue; } if (exists && decision === 'overwrite') { await safeRm(targetDir); } await fs.promises.mkdir(path.dirname(targetDir), { recursive: true }); try { await copyDirectoryNoSymlinks(srcDir, targetDir); installed.push({ skillName: plan.skillName, scope: options.scope }); } catch (error) { await safeRm(targetDir); skipped.push({ skillName: plan.skillName, reason: error instanceof Error ? error.message : 'Failed to copy skill files', }); } } return { ok: true as const, installed, skipped }; } finally { await safeRm(tempBase); } } const catalogCache = new Map(); const CATALOG_TTL_MS = 30 * 60 * 1000; export async function getSkillsCatalog( workingDirectory?: string, refresh?: boolean, additionalSources?: SkillsCatalogSourceConfig[] ) { const sources = [...CURATED_SOURCES, ...(Array.isArray(additionalSources) ? additionalSources : [])]; const discovered = discoverSkills(workingDirectory); const installedByName = new Map(discovered.map((s) => [s.name, s])); const itemsBySource: Record = {}; for (const src of sources) { // Handle ClawdHub sources separately (API-based, not git-based) if (isClawdHubSource(src.source)) { const cacheKey = 'clawdhub:registry'; let cached = !refresh ? catalogCache.get(cacheKey) : null; if (cached && Date.now() >= cached.expiresAt) { catalogCache.delete(cacheKey); cached = null; } let items: SkillsCatalogItem[] = []; if (cached) { items = cached.items; } else { const scanned = await scanClawdHub(); if (!scanned.ok) { itemsBySource[src.id] = []; continue; } items = scanned.items || []; catalogCache.set(cacheKey, { expiresAt: Date.now() + CATALOG_TTL_MS, items }); } itemsBySource[src.id] = items.map((item) => { const installed = installedByName.get(item.skillName); return { sourceId: src.id, ...item, installed: installed ? { isInstalled: true, scope: installed.scope } : { isInstalled: false }, }; }); continue; } // Handle GitHub sources (git clone based) const parsed = parseSkillRepoSource(src.source); if (!parsed.ok) { itemsBySource[src.id] = []; continue; } const effectiveSubpath = src.defaultSubpath || parsed.effectiveSubpath || ''; const cacheKey = `${parsed.normalizedRepo}::${effectiveSubpath}`; let cached = !refresh ? catalogCache.get(cacheKey) : null; if (cached && Date.now() >= cached.expiresAt) { catalogCache.delete(cacheKey); cached = null; } let items: SkillsCatalogItem[] = []; if (cached) { items = cached.items; } else { const scanned = await scanSkillsRepository({ source: src.source, defaultSubpath: src.defaultSubpath }); if (!scanned.ok) { itemsBySource[src.id] = []; continue; } items = scanned.items || []; catalogCache.set(cacheKey, { expiresAt: Date.now() + CATALOG_TTL_MS, items }); } itemsBySource[src.id] = items.map((item) => { const installed = installedByName.get(item.skillName); return { sourceId: src.id, ...item, installed: installed ? { isInstalled: true, scope: installed.scope } : { isInstalled: false }, }; }); } return { ok: true as const, sources, itemsBySource }; } export { isClawdHubSource };