'use client'; import { useState, useCallback, useEffect } from 'react'; import { Copy, Terminal, FolderOpen, Download, Check, Loader2, AlertTriangle } from 'lucide-react'; type Platform = 'claude' | 'codex' | 'copilot' | 'cursor' | 'windsurf'; // Flat-file platforms where the skill file goes directly into the rules/instructions dir const FLAT_FILE_PLATFORMS: Platform[] = ['cursor', 'windsurf', 'copilot']; // All known main instruction file names across platforms const MAIN_FILE_NAMES = ['SKILL.md', 'AGENTS.md', '.cursorrules', '.windsurfrules', 'copilot-instructions.md']; // Map sourceFormat to its native platform (skip transformation when source matches target) const FORMAT_NATIVE_PLATFORM: Record = { 'skill.md': 'claude', 'agents.md': 'codex', 'cursorrules': 'cursor', 'windsurfrules': 'windsurf', 'copilot-instructions': 'copilot', }; interface InstallCommand { cli: string; path: string; } interface SkillFile { name: string; path: string; type: 'file' | 'dir'; size: number; content?: string; downloadUrl?: string; } // JSZip type for dynamic loading interface JSZipInstance { file(name: string, content: string | ArrayBuffer): void; generateAsync(options: { type: 'blob' }): Promise; } interface JSZipConstructor { new(): JSZipInstance; } // CDN URLs for JSZip (with fallbacks) const JSZIP_CDN_URLS = [ 'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js', 'https://unpkg.com/jszip@3.10.1/dist/jszip.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js', ]; // Dynamically load JSZip from CDN with fallbacks let jsZipPromise: Promise | null = null; function loadJSZip(): Promise { if (jsZipPromise) return jsZipPromise; jsZipPromise = new Promise((resolve, reject) => { // Check if already loaded if (typeof window !== 'undefined' && (window as unknown as { JSZip?: JSZipConstructor }).JSZip) { resolve((window as unknown as { JSZip: JSZipConstructor }).JSZip); return; } // Try loading from CDN with fallbacks let cdnIndex = 0; function tryLoadFromCDN() { if (cdnIndex >= JSZIP_CDN_URLS.length) { reject(new Error('Failed to load JSZip from all CDNs. Please check your internet connection.')); return; } const script = document.createElement('script'); script.src = JSZIP_CDN_URLS[cdnIndex]; script.async = true; script.onload = () => { const JSZip = (window as unknown as { JSZip?: JSZipConstructor }).JSZip; if (JSZip) { resolve(JSZip); } else { // Try next CDN cdnIndex++; tryLoadFromCDN(); } }; script.onerror = () => { console.warn(`Failed to load JSZip from ${JSZIP_CDN_URLS[cdnIndex]}, trying fallback...`); cdnIndex++; tryLoadFromCDN(); }; document.head.appendChild(script); } tryLoadFromCDN(); }); return jsZipPromise; } // --- Platform-specific file transformation for downloads --- function stripFrontmatter(content: string): { body: string; description?: string; filePatterns?: string[] } { const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!match) return { body: content }; const yaml = match[1]; const body = match[2].trim(); // Extract description from YAML const descMatch = yaml.match(/^description:\s*(.+)$/m); const description = descMatch ? descMatch[1].trim() : undefined; // Extract file patterns from triggers.filePatterns const patternsMatch = yaml.match(/filePatterns:\s*\n((?:\s+-\s+.+\n?)+)/); let filePatterns: string[] | undefined; if (patternsMatch) { filePatterns = patternsMatch[1] .split('\n') .map(l => l.replace(/^\s+-\s+/, '').replace(/["']/g, '').trim()) .filter(Boolean); } return { body, description, filePatterns }; } function getPlatformFileName(platform: Platform, skillName: string): string { switch (platform) { case 'claude': case 'codex': return 'SKILL.md'; case 'cursor': return `${skillName}.mdc`; case 'windsurf': return `${skillName}.md`; case 'copilot': return `${skillName}.instructions.md`; } } function transformSkillContent(platform: Platform, content: string, skillName: string, srcFormat?: string): string { // If the source is already in the target platform's native format, skip transformation if (srcFormat && FORMAT_NATIVE_PLATFORM[srcFormat] === platform) return content; if (platform === 'claude' || platform === 'codex') return content; const { body, description, filePatterns } = stripFrontmatter(content); if (platform === 'cursor') { const mdcFields: string[] = []; if (description) mdcFields.push(`description: ${description}`); if (filePatterns && filePatterns.length > 0) { mdcFields.push(`globs: ${filePatterns.join(', ')}`); mdcFields.push('alwaysApply: false'); } else { mdcFields.push('alwaysApply: true'); } return `---\n${mdcFields.join('\n')}\n---\n${body}\n`; } // windsurf / copilot: plain markdown let plainBody = body; if (!plainBody.startsWith('# ')) { plainBody = `# ${skillName}\n\n${plainBody}`; } return plainBody + '\n'; } interface InstallSectionProps { skillId: string; skillName: string; repositoryUrl: string; sourceFormat?: string; installCommands: Record; translations: { title: string; cli: string; cliGlobal?: string; cliProject?: string; selectFolder: string; suggestedPath: string; copied: string; downloadZip: string; copyCommand: string; downloading: string; installing: string; installed: string; downloadFailed: string; browserNotSupported: string; rateLimitError: string; timeoutError: string; notFoundError: string; noFilesError: string; disclaimer: string; folderNotePrefix: string; folderNoteSuffix: string; }; } export function InstallSection({ skillId, skillName, repositoryUrl: _repositoryUrl, sourceFormat, installCommands, translations, }: InstallSectionProps) { const [selectedPlatform, setSelectedPlatform] = useState('claude'); const [copied, setCopied] = useState(false); const [installStatus, setInstallStatus] = useState<'idle' | 'downloading' | 'installing' | 'done' | 'error'>('idle'); const [errorMessage, setErrorMessage] = useState(''); // Check if File System Access API is supported (after mount to avoid hydration mismatch) const [isFileSystemSupported, setIsFileSystemSupported] = useState(false); useEffect(() => { setIsFileSystemSupported('showDirectoryPicker' in window); }, []); const currentCommand = installCommands[selectedPlatform]; // Copy command to clipboard const handleCopyCommand = useCallback(async () => { try { await navigator.clipboard.writeText(currentCommand.cli); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (err) { console.error('Failed to copy:', err); } }, [currentCommand.cli]); // Download ZIP using server-side generation (fallback) const downloadFromServer = useCallback(async () => { const response = await fetch(`/api/skill-files/zip?id=${encodeURIComponent(skillId)}&platform=${selectedPlatform}`); if (!response.ok) { const errorData = await response.json().catch(() => ({})); const errorCode = errorData.code || 'UNKNOWN'; switch (errorCode) { case 'RATE_LIMIT': throw new Error(translations.rateLimitError); case 'TIMEOUT': throw new Error(translations.timeoutError); case 'NOT_FOUND': case 'GITHUB_NOT_FOUND': throw new Error(translations.notFoundError); case 'NO_FILES': throw new Error(translations.noFilesError); default: throw new Error(errorData.error || translations.downloadFailed); } } // Server returns ZIP directly - trigger download const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${skillName}.zip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); }, [skillId, skillName, selectedPlatform, translations]); // Download ZIP containing only the skill folder // Tries client-side generation first, falls back to server-side if CDNs fail const handleDownloadZip = useCallback(async () => { try { setInstallStatus('downloading'); setErrorMessage(''); let useServerFallback = false; // Try client-side ZIP generation first try { // Fetch skill files from API const response = await fetch(`/api/skill-files?id=${encodeURIComponent(skillId)}`); if (!response.ok) { const errorData = await response.json().catch(() => ({})); const errorCode = errorData.code || 'UNKNOWN'; switch (errorCode) { case 'RATE_LIMIT': throw new Error(translations.rateLimitError); case 'TIMEOUT': throw new Error(translations.timeoutError); case 'NOT_FOUND': case 'GITHUB_NOT_FOUND': throw new Error(translations.notFoundError); default: throw new Error(errorData.error || translations.downloadFailed); } } const data = await response.json(); const files: SkillFile[] = data.files; if (!files || files.length === 0) { throw new Error(translations.noFilesError); } // Dynamically load JSZip from CDN const JSZip = await loadJSZip(); const zip = new JSZip(); // Add files to ZIP with platform-specific transformation const isFlatPlatform = FLAT_FILE_PLATFORMS.includes(selectedPlatform); for (const file of files) { if (file.type === 'file') { let content = file.content; // If no content but has downloadUrl, fetch it if (!content && file.downloadUrl) { try { const fileResponse = await fetch(file.downloadUrl); if (fileResponse.ok) { content = await fileResponse.text(); } } catch { console.warn(`Failed to download: ${file.path}`); continue; } } if (content) { const isMainSkillFile = MAIN_FILE_NAMES.includes(file.name) && (file.path === file.name); if (isMainSkillFile) { const platformFileName = getPlatformFileName(selectedPlatform, skillName); const transformed = transformSkillContent(selectedPlatform, content, skillName, sourceFormat); if (isFlatPlatform) { // Flat platform: skill file at root, supporting files in subfolder zip.file(platformFileName, transformed); } else { zip.file(`${skillName}/${platformFileName}`, transformed); } } else { // Supporting files (scripts, references) always go in subfolder zip.file(`${skillName}/${file.path || file.name}`, content); } } } } // Generate ZIP and download const zipBlob = await zip.generateAsync({ type: 'blob' }); const url = window.URL.createObjectURL(zipBlob); const a = document.createElement('a'); a.href = url; a.download = `${skillName}.zip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); } catch (clientErr) { // If JSZip failed to load from CDNs, use server-side fallback const errorMsg = clientErr instanceof Error ? clientErr.message : ''; if (errorMsg.includes('Failed to load JSZip')) { console.warn('JSZip CDN failed, falling back to server-side ZIP generation'); useServerFallback = true; } else { // Re-throw non-CDN errors throw clientErr; } } // Server-side fallback if (useServerFallback) { await downloadFromServer(); } // Track the download fetch(`/api/skills/install`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ skillId, platform: selectedPlatform, method: useServerFallback ? 'web-zip-server' : 'web-zip' }), }).catch(() => {}); // Silently fail - tracking is not critical setInstallStatus('done'); setTimeout(() => setInstallStatus('idle'), 3000); } catch (err) { console.error('Download failed:', err); const errorMsg = err instanceof Error ? err.message : translations.downloadFailed; setErrorMessage(errorMsg); setInstallStatus('error'); setTimeout(() => setInstallStatus('idle'), 5000); } }, [skillId, skillName, selectedPlatform, translations, downloadFromServer]); // Install using File System Access API const handleSelectFolder = useCallback(async () => { if (!isFileSystemSupported) { setErrorMessage(translations.browserNotSupported); setInstallStatus('error'); setTimeout(() => setInstallStatus('idle'), 3000); return; } try { setInstallStatus('downloading'); setErrorMessage(''); // Fetch skill files from API const response = await fetch(`/api/skill-files?id=${encodeURIComponent(skillId)}`); if (!response.ok) { const errorData = await response.json().catch(() => ({})); const errorCode = errorData.code || 'UNKNOWN'; // Show specific error messages based on error code switch (errorCode) { case 'RATE_LIMIT': throw new Error(translations.rateLimitError); case 'TIMEOUT': throw new Error(translations.timeoutError); case 'NOT_FOUND': case 'GITHUB_NOT_FOUND': throw new Error(translations.notFoundError); default: throw new Error(errorData.error || translations.downloadFailed); } } const data = await response.json(); const files: SkillFile[] = data.files; if (!files || files.length === 0) { throw new Error(translations.noFilesError); } setInstallStatus('installing'); // Request directory access const dirHandle = await (window as unknown as { showDirectoryPicker: () => Promise }).showDirectoryPicker(); // For flat-file platforms, the main skill file goes directly into the selected folder // Supporting files go in a subfolder const isFlatPlatform = FLAT_FILE_PLATFORMS.includes(selectedPlatform); const skillDir = isFlatPlatform ? dirHandle : await dirHandle.getDirectoryHandle(skillName, { create: true }); // For flat platforms, supporting files (scripts/references) still need a subfolder const trackingDir = isFlatPlatform ? await dirHandle.getDirectoryHandle(skillName, { create: true }) : skillDir; // Write all skill files with platform-specific transformation for (const file of files) { if (file.type === 'dir') { // Create subdirectory await getOrCreateDir(trackingDir, file.path); } else if (file.type === 'file') { let content = file.content; // If no content but has downloadUrl, fetch it if (!content && file.downloadUrl) { try { const fileResponse = await fetch(file.downloadUrl); if (fileResponse.ok) { content = await fileResponse.text(); } } catch { console.warn(`Failed to download: ${file.path}`); continue; } } if (content) { const isMainSkillFile = MAIN_FILE_NAMES.includes(file.name) && (file.path === file.name); if (isMainSkillFile) { // Transform and rename the main skill file const platformFileName = getPlatformFileName(selectedPlatform, skillName); const transformed = transformSkillContent(selectedPlatform, content, skillName, sourceFormat); const fileHandle = await skillDir.getFileHandle(platformFileName, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(transformed); await writable.close(); } else { // Supporting files go into the tracking/skill directory const pathParts = file.path.split('/'); const fileName = pathParts.pop() || file.name; let targetDir = trackingDir; if (pathParts.length > 0) { targetDir = await getOrCreateDir(trackingDir, pathParts.join('/')); } const fileHandle = await targetDir.getFileHandle(fileName, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(content); await writable.close(); } } } } // Track the installation fetch(`/api/skills/install`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ skillId, platform: selectedPlatform, method: 'web-folder' }), }).catch(() => {}); // Silently fail - tracking is not critical setInstallStatus('done'); setTimeout(() => setInstallStatus('idle'), 3000); } catch (err) { if ((err as Error).name === 'AbortError') { // User cancelled the picker setInstallStatus('idle'); return; } console.error('Install failed:', err); // Show the specific error message if available const errorMsg = err instanceof Error ? err.message : translations.downloadFailed; setErrorMessage(errorMsg); setInstallStatus('error'); setTimeout(() => setInstallStatus('idle'), 5000); // Show error longer (5s) } }, [isFileSystemSupported, skillName, skillId, selectedPlatform, translations]); // Helper function to get or create nested directories async function getOrCreateDir( parentDir: FileSystemDirectoryHandle, path: string ): Promise { const parts = path.split('/').filter(Boolean); let currentDir = parentDir; for (const part of parts) { currentDir = await currentDir.getDirectoryHandle(part, { create: true }); } return currentDir; } const platforms: Platform[] = ['claude', 'codex', 'copilot', 'cursor', 'windsurf']; return (

{translations.title}

{/* Platform Tabs */}
{platforms.map((platform) => ( ))}
{/* Security Disclaimer */}

{translations.disclaimer}

{/* CLI Install */}
{/* Global Install */}

{translations.cliGlobal || 'Install globally (user-level):'}

{currentCommand.cli}
{/* Project Install */}

{translations.cliProject || 'Install in current project:'}

{currentCommand.cli} --project
{copied && (

{translations.copied}

)}
{/* Install Options */}
{/* Download ZIP */} {/* Select Folder (File System API) */} {isFileSystemSupported && ( )}
{/* Error Message */} {installStatus === 'error' && errorMessage && (

{errorMessage}

)} {/* Suggested Path & Folder Note */}

{translations.suggestedPath}: {currentCommand.path}

{isFileSystemSupported && (

{translations.folderNotePrefix}{skillName}{translations.folderNoteSuffix}

)}
); }