UNPKG

claude-code-templates

Version:

CLI tool to setup Claude Code configurations with framework-specific commands, automation hooks and MCP Servers for your projects

458 lines (390 loc) • 16 kB
const chalk = require('chalk'); const fs = require('fs-extra'); const path = require('path'); const os = require('os'); const { exec } = require('child_process'); const { promisify } = require('util'); const execAsync = promisify(exec); const QRCode = require('qrcode'); /** * SessionSharing - Handles exporting Claude Code sessions as downloadable context */ class SessionSharing { constructor(conversationAnalyzer) { this.conversationAnalyzer = conversationAnalyzer; } /** * Export conversation session as downloadable markdown file * @param {string} conversationId - Conversation ID to export * @param {Object} conversationData - Full conversation data object * @param {Object} options - Export options (messageLimit, etc.) * @returns {Promise<Object>} Export result with markdown content and filename */ async exportSessionAsMarkdown(conversationId, conversationData, options = {}) { console.log(chalk.blue(`šŸ“„ Preparing session ${conversationId} for download...`)); try { // 1. Get conversation messages const allMessages = await this.conversationAnalyzer.getParsedConversation(conversationData.filePath); // Limit messages to avoid large file sizes (default: last 100 messages) const messageLimit = options.messageLimit || 100; const messages = allMessages.slice(-messageLimit); // 2. Convert to markdown format const markdown = this.convertToMarkdown(messages, conversationData, { messageCount: messages.length, totalMessageCount: allMessages.length, wasLimited: allMessages.length > messageLimit }); // 3. Generate filename const projectName = (conversationData.project || 'session').replace(/[^a-zA-Z0-9-_]/g, '-'); const date = new Date().toISOString().split('T')[0]; const filename = `claude-context-${projectName}-${date}.md`; console.log(chalk.green(`āœ… Session exported successfully!`)); console.log(chalk.gray(`šŸ“Š Exported ${messages.length} messages`)); return { success: true, markdown, filename, messageCount: messages.length, totalMessageCount: allMessages.length, wasLimited: allMessages.length > messageLimit }; } catch (error) { console.error(chalk.red('āŒ Failed to export session:'), error.message); throw error; } } /** * Convert conversation messages to markdown format optimized for Claude Code * @param {Array} messages - Parsed conversation messages * @param {Object} conversationData - Conversation metadata * @param {Object} stats - Export statistics * @returns {string} Markdown formatted content */ convertToMarkdown(messages, conversationData, stats) { const lines = []; // Header for Claude Code lines.push('# Previous Conversation Context\n'); lines.push('> **Note to Claude Code**: This file contains the complete conversation history from a previous session. Read and understand this context to continue helping the user with their task.\n'); lines.push(`**Project:** ${conversationData.project || 'Unknown'}`); lines.push(`**Date:** ${new Date().toISOString().split('T')[0]}`); lines.push(`**Messages in this export:** ${stats.messageCount}${stats.wasLimited ? ` (most recent from a total of ${stats.totalMessageCount})` : ''}`); lines.push(''); lines.push('---'); lines.push(''); // Conversation lines.push('## šŸ’¬ Conversation History\n'); messages.forEach((msg, index) => { const role = msg.role === 'user' ? 'šŸ‘¤ User' : 'šŸ¤– Assistant'; const timestamp = new Date(msg.timestamp).toLocaleString(); lines.push(`### Message ${index + 1}: ${role}`); lines.push(`*${timestamp}*\n`); // Extract text content from message if (Array.isArray(msg.content)) { msg.content.forEach(block => { if (block.type === 'text') { lines.push(block.text); } else if (block.type === 'tool_use') { lines.push(`\`\`\`${block.name || 'tool'}`); lines.push(JSON.stringify(block.input || {}, null, 2)); lines.push('```'); } else if (block.type === 'tool_result') { lines.push('**Tool Result:**'); lines.push('```'); lines.push(typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2)); lines.push('```'); } }); } else if (typeof msg.content === 'string') { lines.push(msg.content); } lines.push(''); lines.push('---'); lines.push(''); }); // Footer lines.push('\n---'); lines.push(''); lines.push('*Generated by Claude Code Templates - [aitmpl.com](https://aitmpl.com)*'); return lines.join('\n'); } /** * Export session data to standardized format * @param {string} conversationId - Conversation ID * @param {Object} conversationData - Conversation metadata * @param {Object} options - Export options * @returns {Promise<Object>} Exported session object */ async exportSessionData(conversationId, conversationData, options = {}) { // Get all messages from the conversation const allMessages = await this.conversationAnalyzer.getParsedConversation(conversationData.filePath); // Limit messages to avoid large file sizes (default: last 100 messages) const messageLimit = options.messageLimit || 100; const messages = allMessages.slice(-messageLimit); // Convert parsed messages back to JSONL format (original Claude Code format) const jsonlMessages = messages.map(msg => { // Reconstruct original JSONL entry format const entry = { uuid: msg.uuid || msg.id, type: msg.role === 'assistant' ? 'assistant' : 'user', timestamp: msg.timestamp.toISOString(), message: { id: msg.id, role: msg.role, content: msg.content } }; // Add model info for assistant messages if (msg.model) { entry.message.model = msg.model; } // Add usage info if (msg.usage) { entry.message.usage = msg.usage; } // Add compact summary flag if present if (msg.isCompactSummary) { entry.isCompactSummary = true; } return entry; }); // Create export package const exportData = { version: '1.0.0', exported_at: new Date().toISOString(), conversation: { id: conversationId, project: conversationData.project || 'shared-session', created: conversationData.created, lastModified: conversationData.lastModified, messageCount: messages.length, totalMessageCount: allMessages.length, wasLimited: allMessages.length > messageLimit, tokens: conversationData.tokens, model: conversationData.modelInfo?.primaryModel || 'claude-sonnet-4-5-20250929' }, messages: jsonlMessages, metadata: { exportTool: 'claude-code-templates', exportVersion: require('../package.json').version || '1.0.0', messageLimit: messageLimit, description: 'Claude Code session export - can be cloned with: npx claude-code-templates@latest --clone-session <url>' } }; // Log information about exported messages if (allMessages.length > messageLimit) { console.log(chalk.yellow(`āš ļø Session has ${allMessages.length} messages, exporting last ${messageLimit} messages`)); } else { console.log(chalk.gray(`šŸ“Š Exporting ${messages.length} messages`)); } return exportData; } /** * Upload session to x0.at * @param {Object} sessionData - Session export data * @param {string} conversationId - Conversation ID for filename * @returns {Promise<string>} Upload URL */ async uploadToX0(sessionData, conversationId) { const tmpDir = path.join(os.tmpdir(), 'claude-code-sessions'); await fs.ensureDir(tmpDir); const tmpFile = path.join(tmpDir, `session-${conversationId}.json`); try { // Write session data to temp file await fs.writeFile(tmpFile, JSON.stringify(sessionData, null, 2), 'utf8'); console.log(chalk.gray(`šŸ“ Created temp file: ${tmpFile}`)); console.log(chalk.gray(`šŸ“¤ Uploading to x0.at...`)); // Upload to x0.at using curl with form data // x0.at API: curl -F'file=@yourfile.png' https://x0.at // Response: Direct URL in plain text const { stdout, stderr } = await execAsync( `curl -s -F "file=@${tmpFile}" ${this.uploadUrl}`, { maxBuffer: 10 * 1024 * 1024 } // 10MB buffer ); // x0.at returns URL directly in plain text const uploadUrl = stdout.trim(); // Validate response if (!uploadUrl || !uploadUrl.startsWith('http')) { throw new Error(`Invalid response from x0.at: ${uploadUrl || stderr}`); } console.log(chalk.green(`āœ… Uploaded to x0.at successfully`)); console.log(chalk.yellow(`āš ļø Files kept for 3-100 days (based on size)`)); console.log(chalk.gray(`šŸ”“ Note: Files are not encrypted by default`)); // Clean up temp file await fs.remove(tmpFile); return uploadUrl; } catch (error) { // Clean up temp file on error await fs.remove(tmpFile).catch(() => {}); throw error; } } /** * Clone a session from a shared URL * Downloads the session and places it in the correct Claude Code location * @param {string} url - URL to download session from * @param {Object} options - Clone options * @returns {Promise<Object>} Result with session path */ async cloneSession(url, options = {}) { console.log(chalk.blue(`šŸ“„ Downloading session from ${url}...`)); try { // 1. Download session data const sessionData = await this.downloadSession(url); // 2. Validate session data this.validateSessionData(sessionData); console.log(chalk.green(`āœ… Session downloaded successfully`)); console.log(chalk.gray(`šŸ“Š Project: ${sessionData.conversation.project}`)); console.log(chalk.gray(`šŸ’¬ Messages: ${sessionData.conversation.messageCount}`)); console.log(chalk.gray(`šŸ¤– Model: ${sessionData.conversation.model}`)); // 3. Install session in Claude Code directory const installResult = await this.installSession(sessionData, options); console.log(chalk.green(`\nāœ… Session installed successfully!`)); console.log(chalk.cyan(`šŸ“‚ Location: ${installResult.sessionPath}`)); // Show resume command (only conversation ID needed) const resumeCommand = `claude --resume ${installResult.conversationId}`; console.log(chalk.yellow(`\nšŸ’” To continue this conversation, run:`)); console.log(chalk.white(`\n ${resumeCommand}\n`)); console.log(chalk.gray(` Or open Claude Code to see it in your sessions list`)); return installResult; } catch (error) { console.error(chalk.red('āŒ Failed to clone session:'), error.message); throw error; } } /** * Download session data from URL * @param {string} url - URL to download from * @returns {Promise<Object>} Session data */ async downloadSession(url) { try { // Use curl to download (works with x0.at and other services) const { stdout, stderr } = await execAsync(`curl -L "${url}"`, { maxBuffer: 50 * 1024 * 1024 // 50MB buffer for large sessions }); if (stderr && !stdout) { throw new Error(`Download failed: ${stderr}`); } // Parse JSON response const sessionData = JSON.parse(stdout); return sessionData; } catch (error) { if (error.message.includes('Unexpected token')) { throw new Error('Invalid session file - corrupted or not a Claude Code session'); } throw error; } } /** * Validate session data structure * @param {Object} sessionData - Session data to validate * @throws {Error} If validation fails */ validateSessionData(sessionData) { if (!sessionData.version) { throw new Error('Invalid session file - missing version'); } if (!sessionData.conversation || !sessionData.conversation.id) { throw new Error('Invalid session file - missing conversation data'); } if (!sessionData.messages || !Array.isArray(sessionData.messages)) { throw new Error('Invalid session file - missing or invalid messages'); } if (sessionData.messages.length === 0) { throw new Error('Invalid session file - no messages found'); } } /** * Install session in Claude Code directory structure * @param {Object} sessionData - Session data to install * @param {Object} options - Installation options * @returns {Promise<Object>} Installation result */ async installSession(sessionData, options = {}) { const homeDir = os.homedir(); const claudeDir = path.join(homeDir, '.claude'); // Determine project directory const projectName = sessionData.conversation.project || 'shared-session'; const projectDirName = this.sanitizeProjectName(projectName); // Create project directory structure // Format: ~/.claude/projects/-path-to-project/ const projectDir = path.join(claudeDir, 'projects', projectDirName); await fs.ensureDir(projectDir); // Generate conversation filename with original ID const conversationId = sessionData.conversation.id; const conversationFile = path.join(projectDir, `${conversationId}.jsonl`); // Convert messages back to JSONL format (one JSON object per line) const jsonlContent = sessionData.messages .map(msg => JSON.stringify(msg)) .join('\n'); // Write conversation file await fs.writeFile(conversationFile, jsonlContent, 'utf8'); console.log(chalk.gray(`šŸ“ Created conversation file: ${conversationFile}`)); // Create or update settings.json const settingsFile = path.join(projectDir, 'settings.json'); const settings = { projectName: sessionData.conversation.project, projectPath: options.projectPath || process.cwd(), sharedSession: true, originalExport: { exportedAt: sessionData.exported_at, exportTool: sessionData.metadata?.exportTool, exportVersion: sessionData.metadata?.exportVersion }, importedAt: new Date().toISOString() }; await fs.writeFile(settingsFile, JSON.stringify(settings, null, 2), 'utf8'); console.log(chalk.gray(`āš™ļø Created settings file: ${settingsFile}`)); return { success: true, sessionPath: conversationFile, projectDir, projectPath: settings.projectPath, conversationId, messageCount: sessionData.messages.length }; } /** * Generate QR code for share command * @param {string} command - Command to encode in QR * @returns {Promise<Object>} QR code data (Data URL for web display) */ async generateQRCode(command) { try { // Generate QR code as Data URL (for web display) const qrDataUrl = await QRCode.toDataURL(command, { errorCorrectionLevel: 'M', type: 'image/png', width: 300, margin: 2, color: { dark: '#000000', light: '#FFFFFF' } }); return { dataUrl: qrDataUrl, command: command }; } catch (error) { console.warn(chalk.yellow('āš ļø Could not generate QR code:'), error.message); return { dataUrl: null, command: command }; } } /** * Sanitize project name for directory usage * @param {string} projectName - Original project name * @returns {string} Sanitized name */ sanitizeProjectName(projectName) { // Replace spaces and special chars with hyphens return projectName .replace(/[^a-zA-Z0-9-_]/g, '-') .replace(/-+/g, '-') .toLowerCase(); } } module.exports = SessionSharing;