/** * Skill Hub Routes Module * Handles shared skill repository management endpoints * * Endpoints: * - GET /api/skill-hub/remote - Fetch remote skill index * - GET /api/skill-hub/local - List local shared skills * - GET /api/skill-hub/installed - List installed skills from hub * - POST /api/skill-hub/install - Install skill to claude/codex * - POST /api/skill-hub/cache - Cache remote skill locally * - GET /api/skill-hub/updates - Check for available updates */ import { readFileSync, existsSync, readdirSync, statSync, mkdirSync, cpSync, rmSync, writeFileSync } from 'fs'; import { join, dirname } from 'path'; import { homedir } from 'os'; import { fileURLToPath } from 'url'; import { validatePath as validateAllowedPath } from '../../utils/path-validator.js'; import type { RouteContext } from './types.js'; // ES Module __dirname equivalent const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); type CliType = 'claude' | 'codex'; // ============================================================================ // Security Helpers // ============================================================================ /** * Allowed domains for remote skill downloads (SSRF protection) */ const ALLOWED_REMOTE_DOMAINS = [ 'raw.githubusercontent.com', 'github.com', 'gist.githubusercontent.com', ]; /** * Validate that a URL is from an allowed domain (SSRF protection) */ function isUrlAllowed(url: string): boolean { try { const parsed = new URL(url); // Only allow HTTPS if (parsed.protocol !== 'https:') { return false; } // Check against whitelist return ALLOWED_REMOTE_DOMAINS.some(domain => parsed.hostname === domain || parsed.hostname.endsWith('.' + domain) ); } catch { return false; } } /** * Validate skill name for filesystem safety * Only allows alphanumeric, dash, and underscore characters */ function isValidSkillName(name: string): boolean { if (!name || name.length === 0 || name.length > 100) { return false; } // Only allow safe characters: a-z, A-Z, 0-9, -, _ const safeNameRegex = /^[a-zA-Z0-9_-]+$/; return safeNameRegex.test(name); } /** * Sanitize error message for client response * Returns a generic message while logging the actual error */ function sanitizeErrorMessage(error: unknown, operation: string): string { // Log the actual error for debugging console.error(`[SkillHub] ${operation} failed:`, error instanceof Error ? error.message : String(error)); // Return generic message to client return `${operation} failed. Please try again later.`; } // ============================================================================ // TypeScript Interfaces // ============================================================================ /** * Remote skill index entry (from GitHub or HTTP source) */ export interface RemoteSkillEntry { id: string; name: string; description: string; version: string; author: string; category: string; tags: string[]; downloadUrl?: string; path?: string; // Relative path to skill directory in repo readmeUrl?: string; homepage?: string; license?: string; updatedAt?: string; } /** * Remote skill index response */ export interface RemoteSkillIndex { version: string; updated_at: string; source: 'github' | 'http' | 'local'; skills: RemoteSkillEntry[]; } /** * Local shared skill info */ export interface LocalSkillInfo { id: string; name: string; folderName: string; description: string; version: string; author?: string; category?: string; tags?: string[]; path: string; source: 'local'; updatedAt: string; } /** * Installed skill info (from hub) */ export interface InstalledSkillInfo { id: string; name: string; folderName: string; version: string; installedAt: string; installedTo: 'claude' | 'codex'; source: 'remote' | 'local'; originalId: string; updatesAvailable?: boolean; latestVersion?: string; } /** * Skill install request */ interface SkillInstallRequest { skillId: string; cliType: CliType; source: 'remote' | 'local'; customName?: string; } /** * Skill cache request */ interface SkillCacheRequest { skillId: string; downloadUrl: string; } // ============================================================================ // Configuration // ============================================================================ /** * GitHub repository configuration for remote skills */ const GITHUB_CONFIG = { owner: 'catlog22', repo: 'skill-hub', branch: 'main', skillIndexPath: 'skill-hub/index.json' }; /** * Remote skills cache with TTL (10 minutes) */ let remoteSkillsCache: { data: RemoteSkillIndex | null; timestamp: number; } = { data: null, timestamp: 0 }; const CACHE_TTL_MS = 10 * 60 * 1000; // ============================================================================ // Storage Helpers // ============================================================================ /** * Get the skill-hub directory path */ function getSkillHubDir(): string { return join(homedir(), '.ccw', 'skill-hub'); } /** * Get the cached skills directory */ function getCachedSkillsDir(): string { return join(getSkillHubDir(), 'cached'); } /** * Get the local skills directory */ function getLocalSkillsDir(): string { return join(getSkillHubDir(), 'local'); } /** * Get the installed skills tracking file path */ function getInstalledSkillsFile(): string { return join(getSkillHubDir(), 'installed.json'); } /** * Ensure skill-hub directories exist */ function ensureSkillHubDirs(): void { const dirs = [getSkillHubDir(), getCachedSkillsDir(), getLocalSkillsDir()]; for (const dir of dirs) { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } } /** * Get CLI skills directory based on cliType */ function getCliSkillsDir(cliType: CliType): string { const cliDir = cliType === 'codex' ? '.codex' : '.claude'; return join(homedir(), cliDir, 'skills'); } // ============================================================================ // Skill Parsing Helpers // ============================================================================ /** * Parse skill frontmatter (YAML header) */ function parseSkillFrontmatter(content: string): { name: string; description: string; version: string; author?: string; category?: string; tags?: string[]; } { const result = { name: '', description: '', version: '1.0.0', author: undefined as string | undefined, category: undefined as string | undefined, tags: undefined as string[] | undefined, }; if (content.startsWith('---')) { const endIndex = content.indexOf('---', 3); if (endIndex > 0) { const frontmatter = content.substring(3, endIndex).trim(); const lines = frontmatter.split('\n'); for (const line of lines) { const colonIndex = line.indexOf(':'); if (colonIndex > 0) { const key = line.substring(0, colonIndex).trim().toLowerCase(); const value = line.substring(colonIndex + 1).trim(); switch (key) { case 'name': result.name = value.replace(/^["']|["']$/g, ''); break; case 'description': result.description = value.replace(/^["']|["']$/g, ''); break; case 'version': result.version = value.replace(/^["']|["']$/g, ''); break; case 'author': result.author = value.replace(/^["']|["']$/g, ''); break; case 'category': result.category = value.replace(/^["']|["']$/g, ''); break; case 'tags': result.tags = value .replace(/^\[|\]$/g, '') .split(',') .map(t => t.trim()) .filter(Boolean); break; } } } } } return result; } // ============================================================================ // Remote Skills Helpers // ============================================================================ /** * Fetch remote skill index from GitHub */ async function fetchRemoteSkillIndex(): Promise { // Check cache const now = Date.now(); if (remoteSkillsCache.data && (now - remoteSkillsCache.timestamp) < CACHE_TTL_MS) { return remoteSkillsCache.data; } const indexUrl = `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/${GITHUB_CONFIG.branch}/${GITHUB_CONFIG.skillIndexPath}`; try { const response = await fetch(indexUrl); if (!response.ok) { // Try local fallback const localIndex = loadLocalIndex(); if (localIndex) { return localIndex; } throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); } const index = await response.json() as RemoteSkillIndex; index.source = 'github'; // Update cache remoteSkillsCache = { data: index, timestamp: now }; // Persist to local cache file saveCachedIndex(index); return index; } catch (error) { // Return cached data if available, even if expired if (remoteSkillsCache.data) { return remoteSkillsCache.data; } // Try local fallback const localIndex = loadLocalIndex(); if (localIndex) { return localIndex; } throw error; } } /** * Load cached index from local file */ function loadLocalIndex(): RemoteSkillIndex | null { try { const cachedPath = join(getSkillHubDir(), 'index.json'); if (existsSync(cachedPath)) { const content = readFileSync(cachedPath, 'utf8'); const index = JSON.parse(content) as RemoteSkillIndex; index.source = 'local'; return index; } } catch (error) { console.error('[SkillHub] Failed to load cached index:', error instanceof Error ? error.message : String(error)); } return null; } /** * Save index to local cache */ function saveCachedIndex(index: RemoteSkillIndex): void { try { ensureSkillHubDirs(); const cachedPath = join(getSkillHubDir(), 'index.json'); writeFileSync(cachedPath, JSON.stringify(index, null, 2), 'utf8'); } catch (error) { console.error('[SkillHub] Failed to save cached index:', error instanceof Error ? error.message : String(error)); } } /** * Fetch a single skill from remote URL */ async function fetchRemoteSkill(downloadUrl: string): Promise { const response = await fetch(downloadUrl); if (!response.ok) { throw new Error(`Failed to fetch skill: ${response.status} ${response.statusText}`); } return response.text(); } /** * Build download URL from skill path */ function buildDownloadUrlFromPath(skillPath: string): string { return `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/${GITHUB_CONFIG.branch}/${skillPath}/SKILL.md`; } /** * Fetch skill directory contents from GitHub API * Returns list of files in the directory */ interface GitHubTreeEntry { path: string; mode: string; type: 'blob' | 'tree'; sha: string; size?: number; url: string; } async function fetchSkillDirectoryContents(skillPath: string): Promise { // Use GitHub API to get tree contents const apiUrl = `https://api.github.com/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/contents/${skillPath}?ref=${GITHUB_CONFIG.branch}`; const response = await fetch(apiUrl, { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'CCW-SkillHub/1.0', }, }); if (!response.ok) { throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); } return response.json(); } /** * Download all files from a skill directory */ async function downloadSkillDirectory( skillPath: string, targetDir: string ): Promise<{ success: boolean; files: string[] }> { const files: string[] = []; try { const contents = await fetchSkillDirectoryContents(skillPath); for (const entry of contents) { if (entry.type === 'blob') { // Download file const fileUrl = `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/${GITHUB_CONFIG.branch}/${entry.path}`; const response = await fetch(fileUrl); if (response.ok) { const content = await response.text(); const filePath = join(targetDir, entry.path.replace(skillPath + '/', '')); const fileDir = dirname(filePath); // Ensure directory exists if (!existsSync(fileDir)) { mkdirSync(fileDir, { recursive: true }); } writeFileSync(filePath, content, 'utf8'); files.push(entry.path); } } else if (entry.type === 'tree') { // Recursively download subdirectory const subDir = join(targetDir, entry.path.replace(skillPath + '/', '')); if (!existsSync(subDir)) { mkdirSync(subDir, { recursive: true }); } const subResult = await downloadSkillDirectory(entry.path, targetDir); files.push(...subResult.files); } } return { success: true, files }; } catch (error) { console.error('[SkillHub] Failed to download directory:', error); return { success: false, files }; } } // ============================================================================ // Local Skills Helpers // ============================================================================ /** * List local shared skills from ~/.ccw/skill-hub/local/ */ function listLocalSkills(): LocalSkillInfo[] { const result: LocalSkillInfo[] = []; const localDir = getLocalSkillsDir(); if (!existsSync(localDir)) { return result; } try { const entries = readdirSync(localDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const skillDir = join(localDir, entry.name); const skillMdPath = join(skillDir, 'SKILL.md'); if (!existsSync(skillMdPath)) continue; try { const content = readFileSync(skillMdPath, 'utf8'); const parsed = parseSkillFrontmatter(content); const stat = statSync(skillMdPath); result.push({ id: `local-${entry.name}`, name: parsed.name || entry.name, folderName: entry.name, description: parsed.description || '', version: parsed.version || '1.0.0', author: parsed.author, category: parsed.category, tags: parsed.tags, path: skillDir, source: 'local', updatedAt: stat.mtime.toISOString(), }); } catch { // Skip invalid skills } } } catch (error) { console.error('[SkillHub] Failed to list local skills:', error); } return result; } // ============================================================================ // Installed Skills Tracking // ============================================================================ interface InstalledSkillsRegistry { version: string; updatedAt: string; installed: InstalledSkillInfo[]; } /** * Load installed skills registry */ function loadInstalledSkills(): InstalledSkillsRegistry { try { const filePath = getInstalledSkillsFile(); if (existsSync(filePath)) { const content = readFileSync(filePath, 'utf8'); return JSON.parse(content) as InstalledSkillsRegistry; } } catch { // Ignore errors } return { version: '1.0.0', updatedAt: new Date().toISOString(), installed: [], }; } /** * Save installed skills registry */ function saveInstalledSkills(registry: InstalledSkillsRegistry): void { try { ensureSkillHubDirs(); registry.updatedAt = new Date().toISOString(); const filePath = getInstalledSkillsFile(); writeFileSync(filePath, JSON.stringify(registry, null, 2), 'utf8'); } catch (error) { console.error('[SkillHub] Failed to save installed skills:', error); } } /** * Add installed skill to registry */ function addInstalledSkill(info: InstalledSkillInfo): void { const registry = loadInstalledSkills(); // Remove existing entry with same id registry.installed = registry.installed.filter( s => !(s.originalId === info.originalId && s.installedTo === info.installedTo) ); registry.installed.push(info); saveInstalledSkills(registry); } /** * Remove installed skill from registry */ function removeInstalledSkill(skillId: string, cliType: CliType): void { const registry = loadInstalledSkills(); registry.installed = registry.installed.filter( s => !(s.originalId === skillId && s.installedTo === cliType) ); saveInstalledSkills(registry); } // ============================================================================ // Skill Installation Helpers // ============================================================================ /** * Install skill from local path */ async function installSkillFromLocal( localPath: string, cliType: CliType, customName?: string ): Promise<{ success: boolean; message: string; installedPath?: string }> { try { // Validate source path exists if (!existsSync(localPath)) { return { success: false, message: 'Source skill path not found' }; } // Validate source path is within allowed skill-hub directory const localSkillsDir = getLocalSkillsDir(); const resolvedLocalPath = localPath; if (!resolvedLocalPath.startsWith(localSkillsDir)) { console.error('[SkillHub] Path traversal attempt blocked:', localPath); return { success: false, message: 'Invalid source path' }; } // Read skill metadata const skillMdPath = join(localPath, 'SKILL.md'); if (!existsSync(skillMdPath)) { return { success: false, message: 'SKILL.md not found in source' }; } const content = readFileSync(skillMdPath, 'utf8'); const parsed = parseSkillFrontmatter(content); const skillName = customName || parsed.name; if (!skillName) { return { success: false, message: 'Skill name is required' }; } // Validate skill name with strict whitelist if (!isValidSkillName(skillName)) { console.error('[SkillHub] Invalid skill name rejected:', skillName); return { success: false, message: 'Invalid skill name. Only letters, numbers, dash and underscore allowed.' }; } // Get target directory const targetDir = getCliSkillsDir(cliType); const targetSkillDir = join(targetDir, skillName); // Create target directory if needed if (!existsSync(targetDir)) { mkdirSync(targetDir, { recursive: true }); } // Check if already exists if (existsSync(targetSkillDir)) { return { success: false, message: `Skill '${skillName}' already exists in ${cliType}` }; } // Copy skill directory cpSync(localPath, targetSkillDir, { recursive: true }); return { success: true, message: `Skill '${skillName}' installed to ${cliType}`, installedPath: targetSkillDir, }; } catch (error) { return { success: false, message: sanitizeErrorMessage(error, 'Skill installation'), }; } } /** * Install skill from remote URL */ async function installSkillFromRemote( downloadUrl: string, cliType: CliType, skillId: string, customName?: string ): Promise<{ success: boolean; message: string; installedPath?: string }> { try { // Validate URL for SSRF protection if (!isUrlAllowed(downloadUrl)) { console.error('[SkillHub] Blocked download from unauthorized URL:', downloadUrl); return { success: false, message: 'Download URL is not from an allowed source' }; } // Fetch skill content const skillContent = await fetchRemoteSkill(downloadUrl); // Parse metadata const parsed = parseSkillFrontmatter(skillContent); const skillName = customName || parsed.name; if (!skillName) { return { success: false, message: 'Skill name is required' }; } // Validate skill name with strict whitelist if (!isValidSkillName(skillName)) { console.error('[SkillHub] Invalid skill name rejected:', skillName); return { success: false, message: 'Invalid skill name. Only letters, numbers, dash and underscore allowed.' }; } // Validate skillId for caching path safety if (!isValidSkillName(skillId.replace('remote-', '').replace('local-', ''))) { console.error('[SkillHub] Invalid skill ID rejected:', skillId); return { success: false, message: 'Invalid skill ID' }; } // Get target directory const targetDir = getCliSkillsDir(cliType); const targetSkillDir = join(targetDir, skillName); // Create target directory if needed if (!existsSync(targetDir)) { mkdirSync(targetDir, { recursive: true }); } // Check if already exists if (existsSync(targetSkillDir)) { return { success: false, message: `Skill '${skillName}' already exists in ${cliType}` }; } // Create skill directory and write SKILL.md mkdirSync(targetSkillDir, { recursive: true }); writeFileSync(join(targetSkillDir, 'SKILL.md'), skillContent, 'utf8'); // Cache the skill locally try { ensureSkillHubDirs(); const cachedDir = join(getCachedSkillsDir(), skillId); if (!existsSync(cachedDir)) { mkdirSync(cachedDir, { recursive: true }); } writeFileSync(join(cachedDir, 'SKILL.md'), skillContent, 'utf8'); } catch (cacheError) { // Log but don't fail - caching is optional console.error('[SkillHub] Failed to cache skill:', cacheError instanceof Error ? cacheError.message : String(cacheError)); } return { success: true, message: `Skill '${skillName}' installed to ${cliType}`, installedPath: targetSkillDir, }; } catch (error) { return { success: false, message: sanitizeErrorMessage(error, 'Skill installation'), }; } } /** * Install skill from remote path (downloads entire directory) */ async function installSkillFromRemotePath( skillPath: string, cliType: CliType, skillId: string, customName?: string ): Promise<{ success: boolean; message: string; installedPath?: string }> { try { // Validate skillId for path safety if (!isValidSkillName(skillId.replace('remote-', '').replace('local-', ''))) { console.error('[SkillHub] Invalid skill ID rejected:', skillId); return { success: false, message: 'Invalid skill ID' }; } // Get target directory const targetDir = getCliSkillsDir(cliType); const skillName = customName || skillId; const targetSkillDir = join(targetDir, skillName); // Create target directory if needed if (!existsSync(targetDir)) { mkdirSync(targetDir, { recursive: true }); } // Check if already exists if (existsSync(targetSkillDir)) { return { success: false, message: `Skill '${skillName}' already exists in ${cliType}` }; } // Create skill directory mkdirSync(targetSkillDir, { recursive: true }); // Download entire skill directory console.log(`[SkillHub] Downloading skill directory: ${skillPath}`); const result = await downloadSkillDirectory(skillPath, targetSkillDir); if (!result.success || result.files.length === 0) { // Fallback: download only SKILL.md console.log('[SkillHub] Directory download failed, falling back to SKILL.md only'); const skillMdUrl = buildDownloadUrlFromPath(skillPath); const skillContent = await fetchRemoteSkill(skillMdUrl); writeFileSync(join(targetSkillDir, 'SKILL.md'), skillContent, 'utf8'); } // Cache the skill locally try { ensureSkillHubDirs(); const cachedDir = join(getCachedSkillsDir(), skillId); if (!existsSync(cachedDir)) { mkdirSync(cachedDir, { recursive: true }); } // Copy entire skill directory to cache cpSync(targetSkillDir, cachedDir, { recursive: true }); } catch (cacheError) { console.error('[SkillHub] Failed to cache skill:', cacheError instanceof Error ? cacheError.message : String(cacheError)); } return { success: true, message: `Skill '${skillName}' installed to ${cliType} (${result.files.length} files)`, installedPath: targetSkillDir, }; } catch (error) { return { success: false, message: sanitizeErrorMessage(error, 'Skill installation'), }; } } // ============================================================================ // Updates Check Helpers // ============================================================================ /** * Check for available updates */ async function checkForUpdates( installedSkills: InstalledSkillInfo[], remoteSkills: RemoteSkillEntry[] ): Promise { const remoteMap = new Map(remoteSkills.map(s => [s.id, s])); return installedSkills.map(skill => { const remote = remoteMap.get(skill.originalId); if (remote && remote.version !== skill.version) { return { ...skill, updatesAvailable: true, latestVersion: remote.version, }; } return { ...skill, updatesAvailable: false, }; }); } // ============================================================================ // Route Handler // ============================================================================ /** * Handle skill hub routes * @returns true if route was handled, false otherwise */ export async function handleSkillHubRoutes(ctx: RouteContext): Promise { const { pathname, req, res, handlePostRequest } = ctx; // Ensure skill-hub directories exist ensureSkillHubDirs(); // ==== LIST REMOTE SKILLS ==== // GET /api/skill-hub/remote if (pathname === '/api/skill-hub/remote' && req.method === 'GET') { try { // Check for refresh parameter to bypass cache const refresh = ctx.url.searchParams.get('refresh') === 'true'; if (refresh) { // Clear memory cache remoteSkillsCache = { data: null, timestamp: 0 }; // Clear file cache const cachedPath = join(getSkillHubDir(), 'index.json'); if (existsSync(cachedPath)) { rmSync(cachedPath, { force: true }); } } const index = await fetchRemoteSkillIndex(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, data: index.skills, meta: { version: index.version, updated_at: index.updated_at, source: index.source, }, total: index.skills.length, timestamp: new Date().toISOString(), })); return true; } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: (error as Error).message, message: 'Failed to fetch remote skills. Check network connectivity.', })); return true; } } // ==== LIST LOCAL SKILLS ==== // GET /api/skill-hub/local if (pathname === '/api/skill-hub/local' && req.method === 'GET') { try { const skills = listLocalSkills(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, data: skills, total: skills.length, timestamp: new Date().toISOString(), })); return true; } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: (error as Error).message, })); return true; } } // ==== LIST INSTALLED SKILLS ==== // GET /api/skill-hub/installed if (pathname === '/api/skill-hub/installed' && req.method === 'GET') { try { const registry = loadInstalledSkills(); // Optionally check for updates const checkUpdates = ctx.url.searchParams.get('checkUpdates') === 'true'; let installed = registry.installed; if (checkUpdates) { try { const remoteIndex = await fetchRemoteSkillIndex(); installed = await checkForUpdates(installed, remoteIndex.skills); } catch { // Ignore update check errors } } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, data: installed, total: installed.length, timestamp: new Date().toISOString(), })); return true; } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: (error as Error).message, })); return true; } } // ==== INSTALL SKILL ==== // POST /api/skill-hub/install if (pathname === '/api/skill-hub/install' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { const { skillId, cliType, source, customName, downloadUrl } = body as SkillInstallRequest & { downloadUrl?: string }; // Validation if (!skillId) { return { success: false, error: 'skillId is required', status: 400 }; } if (cliType !== 'claude' && cliType !== 'codex') { return { success: false, error: 'cliType must be "claude" or "codex"', status: 400 }; } if (source !== 'remote' && source !== 'local') { return { success: false, error: 'source must be "remote" or "local"', status: 400 }; } try { let result; if (source === 'local') { // Install from local const localSkills = listLocalSkills(); const localSkill = localSkills.find(s => s.id === skillId); if (!localSkill) { return { success: false, error: 'Local skill not found', status: 404 }; } result = await installSkillFromLocal(localSkill.path, cliType, customName); } else { // Install from remote let url = downloadUrl; let skillPath: string | undefined; if (!url) { // Fetch from remote index const index = await fetchRemoteSkillIndex(); const remoteSkill = index.skills.find(s => s.id === skillId); if (!remoteSkill) { return { success: false, error: 'Remote skill not found', status: 404 }; } url = remoteSkill.downloadUrl; skillPath = remoteSkill.path; } // Prefer path-based installation for full directory download if (skillPath && !url) { result = await installSkillFromRemotePath(skillPath, cliType, skillId, customName); } else if (url) { result = await installSkillFromRemote(url, cliType, skillId, customName); } else { return { success: false, error: 'No downloadUrl or path available for skill', status: 400 }; } } if (result.success) { // Track installation addInstalledSkill({ id: `${skillId}-${cliType}`, name: customName || skillId, folderName: customName || skillId, version: '1.0.0', // Would need to parse from installed skill installedAt: new Date().toISOString(), installedTo: cliType, source: source, originalId: skillId, }); } return result; } catch (error) { return { success: false, error: (error as Error).message, status: 500, }; } }); return true; } // ==== CACHE REMOTE SKILL ==== // POST /api/skill-hub/cache if (pathname === '/api/skill-hub/cache' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { const { skillId, downloadUrl } = body as SkillCacheRequest; if (!skillId || !downloadUrl) { return { success: false, error: 'skillId and downloadUrl are required', status: 400 }; } try { const content = await fetchRemoteSkill(downloadUrl); ensureSkillHubDirs(); const cachedDir = join(getCachedSkillsDir(), skillId); mkdirSync(cachedDir, { recursive: true }); writeFileSync(join(cachedDir, 'SKILL.md'), content, 'utf8'); return { success: true, message: 'Skill cached successfully', path: cachedDir, }; } catch (error) { return { success: false, error: (error as Error).message, status: 500, }; } }); return true; } // ==== CHECK UPDATES ==== // GET /api/skill-hub/updates if (pathname === '/api/skill-hub/updates' && req.method === 'GET') { try { const registry = loadInstalledSkills(); const remoteIndex = await fetchRemoteSkillIndex(); const updated = await checkForUpdates(registry.installed, remoteIndex.skills); const updatesAvailable = updated.filter(s => s.updatesAvailable); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, data: updatesAvailable, total: updatesAvailable.length, timestamp: new Date().toISOString(), })); return true; } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: (error as Error).message, })); return true; } } // ==== UNINSTALL SKILL ==== // DELETE /api/skill-hub/installed/:id if (pathname.match(/^\/api\/skill-hub\/installed\/[^/]+$/) && req.method === 'DELETE') { const skillId = pathname.split('/').pop(); handlePostRequest(req, res, async (body) => { const { cliType } = body as { cliType: CliType }; if (!cliType) { return { success: false, error: 'cliType is required', status: 400 }; } try { const registry = loadInstalledSkills(); const installed = registry.installed.find( s => s.id === skillId && s.installedTo === cliType ); if (!installed) { return { success: false, error: 'Installed skill not found', status: 404 }; } // Remove skill directory const skillDir = join(getCliSkillsDir(cliType), installed.folderName); if (existsSync(skillDir)) { rmSync(skillDir, { recursive: true, force: true }); } // Remove from registry removeInstalledSkill(installed.originalId, cliType); return { success: true, message: 'Skill uninstalled' }; } catch (error) { return { success: false, error: (error as Error).message, status: 500, }; } }); return true; } return false; }