claude-session-share 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +313 -0
- package/dist/__tests__/e2e.test.js +532 -0
- package/dist/__tests__/gist-client.test.js +341 -0
- package/dist/__tests__/index.test.js +16 -0
- package/dist/__tests__/mcp-integration.test.js +403 -0
- package/dist/__tests__/path-encoding.test.js +77 -0
- package/dist/__tests__/pipeline.test.js +342 -0
- package/dist/__tests__/redactor.test.js +162 -0
- package/dist/__tests__/sanitizer.test.js +345 -0
- package/dist/__tests__/session-importer.test.js +216 -0
- package/dist/__tests__/session-reader.test.js +298 -0
- package/dist/__tests__/session-uploader.test.js +216 -0
- package/dist/__tests__/session-writer.test.js +286 -0
- package/dist/__tests__/uuid-mapper.test.js +249 -0
- package/dist/gist/client.js +199 -0
- package/dist/gist/types.js +7 -0
- package/dist/index.js +214 -0
- package/dist/sanitization/pipeline.js +48 -0
- package/dist/sanitization/redactor.js +48 -0
- package/dist/sanitization/sanitizer.js +87 -0
- package/dist/services/session-importer.js +88 -0
- package/dist/services/session-uploader.js +64 -0
- package/dist/session/finder.js +65 -0
- package/dist/session/metadata.js +55 -0
- package/dist/session/reader.js +101 -0
- package/dist/session/types.js +11 -0
- package/dist/session/writer.js +74 -0
- package/dist/utils/path-encoding.js +54 -0
- package/dist/utils/uuid-mapper.js +73 -0
- package/package.json +54 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session import service
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates the complete workflow of importing a Claude Code session from GitHub Gist:
|
|
5
|
+
* 1. Fetch gist content
|
|
6
|
+
* 2. Extract session JSONL
|
|
7
|
+
* 3. Parse messages with error recovery
|
|
8
|
+
* 4. Remap UUIDs to avoid conflicts
|
|
9
|
+
* 5. Write to local storage
|
|
10
|
+
*/
|
|
11
|
+
import { GistClient } from '../gist/client.js';
|
|
12
|
+
import { UUIDMapper } from '../utils/uuid-mapper.js';
|
|
13
|
+
import { writeSessionToLocal } from '../session/writer.js';
|
|
14
|
+
/**
|
|
15
|
+
* Import a session from GitHub Gist
|
|
16
|
+
*
|
|
17
|
+
* Fetches a shared session gist, remaps UUIDs, and writes to local storage.
|
|
18
|
+
* Includes error recovery for malformed messages (logs and continues).
|
|
19
|
+
*
|
|
20
|
+
* @param gistIdOrUrl - GitHub Gist URL or bare gist ID
|
|
21
|
+
* @param projectPath - Local project directory path (e.g., "/Users/name/project")
|
|
22
|
+
* @returns Promise resolving to import result with session path and metadata
|
|
23
|
+
* @throws Error if gist not found, no JSONL file, or write fails
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const result = await importSession('https://gist.github.com/user/abc123', '/Users/name/project');
|
|
27
|
+
* console.log(`Imported ${result.messageCount} messages to ${result.sessionPath}`);
|
|
28
|
+
*/
|
|
29
|
+
export async function importSession(gistIdOrUrl, projectPath) {
|
|
30
|
+
try {
|
|
31
|
+
// Step 1: Initialize GistClient (validates GITHUB_TOKEN)
|
|
32
|
+
const gistClient = new GistClient();
|
|
33
|
+
// Step 2: Fetch gist content
|
|
34
|
+
const gist = await gistClient.fetchGist(gistIdOrUrl);
|
|
35
|
+
// Step 3: Extract session JSONL file
|
|
36
|
+
// Look for file with .jsonl extension
|
|
37
|
+
const jsonlFileName = Object.keys(gist.files).find((name) => name.endsWith('.jsonl'));
|
|
38
|
+
if (!jsonlFileName) {
|
|
39
|
+
throw new Error('No JSONL file found in gist. Expected a .jsonl file containing session messages.');
|
|
40
|
+
}
|
|
41
|
+
const jsonlFile = gist.files[jsonlFileName];
|
|
42
|
+
if (!jsonlFile || !jsonlFile.content) {
|
|
43
|
+
throw new Error(`JSONL file "${jsonlFileName}" has no content. The gist may be malformed.`);
|
|
44
|
+
}
|
|
45
|
+
const jsonlContent = jsonlFile.content;
|
|
46
|
+
// Step 4: Parse messages with per-line error recovery
|
|
47
|
+
const messages = [];
|
|
48
|
+
const lines = jsonlContent.split('\n').filter((line) => line.trim());
|
|
49
|
+
let parseErrors = 0;
|
|
50
|
+
for (const [index, line] of lines.entries()) {
|
|
51
|
+
try {
|
|
52
|
+
const message = JSON.parse(line);
|
|
53
|
+
messages.push(message);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
// Log error but continue parsing remaining lines
|
|
57
|
+
parseErrors++;
|
|
58
|
+
console.warn(`Failed to parse message at line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (messages.length === 0) {
|
|
62
|
+
throw new Error(`No valid messages found in JSONL file. Parse errors: ${parseErrors}`);
|
|
63
|
+
}
|
|
64
|
+
// Log parse errors if any occurred
|
|
65
|
+
if (parseErrors > 0) {
|
|
66
|
+
console.warn(`Imported ${messages.length} messages with ${parseErrors} parse errors`);
|
|
67
|
+
}
|
|
68
|
+
// Step 5: Remap UUIDs to avoid conflicts
|
|
69
|
+
const mapper = new UUIDMapper();
|
|
70
|
+
const remappedMessages = messages.map((msg) => mapper.remapMessage(msg));
|
|
71
|
+
// Step 6: Write to local storage
|
|
72
|
+
const result = await writeSessionToLocal(remappedMessages, projectPath);
|
|
73
|
+
// Step 7: Return import result
|
|
74
|
+
return {
|
|
75
|
+
sessionPath: result.filePath,
|
|
76
|
+
sessionId: result.sessionId,
|
|
77
|
+
messageCount: remappedMessages.length,
|
|
78
|
+
projectPath,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
// Add context to errors for better debugging
|
|
83
|
+
if (error instanceof Error) {
|
|
84
|
+
throw new Error(`Failed to import session: ${error.message}`);
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`Failed to import session: ${String(error)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-to-Gist upload service
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates the complete workflow of uploading a Claude Code session to GitHub Gist:
|
|
5
|
+
* 1. Read session messages
|
|
6
|
+
* 2. Sanitize for privacy
|
|
7
|
+
* 3. Convert to JSONL format
|
|
8
|
+
* 4. Extract metadata
|
|
9
|
+
* 5. Upload to Gist
|
|
10
|
+
*/
|
|
11
|
+
import { parseSessionFile } from '../session/reader.js';
|
|
12
|
+
import { extractMetadata } from '../session/metadata.js';
|
|
13
|
+
import { sanitizeSession, inferBasePath } from '../sanitization/pipeline.js';
|
|
14
|
+
import { GistClient } from '../gist/client.js';
|
|
15
|
+
/**
|
|
16
|
+
* Upload a session file to GitHub Gist
|
|
17
|
+
*
|
|
18
|
+
* Performs full sanitization pipeline and uploads to secret (unlisted) Gist.
|
|
19
|
+
*
|
|
20
|
+
* @param sessionPath - Absolute path to session JSONL file
|
|
21
|
+
* @returns Promise resolving to Gist URL
|
|
22
|
+
* @throws Error if any step fails (reading, sanitizing, uploading)
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const url = await uploadSession('/Users/name/.claude/projects/abc/session.jsonl');
|
|
26
|
+
* console.log(`Shared at: ${url}`);
|
|
27
|
+
*/
|
|
28
|
+
export async function uploadSession(sessionPath) {
|
|
29
|
+
try {
|
|
30
|
+
// Step 1: Read session messages
|
|
31
|
+
const messages = await parseSessionFile(sessionPath);
|
|
32
|
+
if (messages.length === 0) {
|
|
33
|
+
throw new Error('Session file is empty or contains no valid messages');
|
|
34
|
+
}
|
|
35
|
+
// Step 2: Sanitize session for privacy
|
|
36
|
+
const basePath = inferBasePath(messages);
|
|
37
|
+
const sanitizedMessages = sanitizeSession(messages, basePath);
|
|
38
|
+
// Step 3: Convert sanitized messages to JSONL string
|
|
39
|
+
const sessionJsonl = sanitizedMessages.map(msg => JSON.stringify(msg)).join('\n');
|
|
40
|
+
// Step 4: Extract metadata for gist description and metadata file
|
|
41
|
+
const metadata = extractMetadata(sanitizedMessages);
|
|
42
|
+
if (!metadata) {
|
|
43
|
+
throw new Error('Failed to extract session metadata');
|
|
44
|
+
}
|
|
45
|
+
// Step 5: Upload to Gist
|
|
46
|
+
const gistClient = new GistClient();
|
|
47
|
+
// Use metadata title if available, fallback to timestamp-based title
|
|
48
|
+
const description = metadata.projectPath && metadata.projectPath !== 'unknown'
|
|
49
|
+
? `Claude Code Session - ${metadata.projectPath.split('/').pop()}`
|
|
50
|
+
: `Claude Code Session - ${new Date(metadata.firstTimestamp).toISOString()}`;
|
|
51
|
+
const response = await gistClient.createGist(description, {
|
|
52
|
+
'session.jsonl': sessionJsonl,
|
|
53
|
+
'metadata.json': JSON.stringify(metadata, null, 2),
|
|
54
|
+
});
|
|
55
|
+
return response.html_url;
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
// Add context to errors for better debugging
|
|
59
|
+
if (error instanceof Error) {
|
|
60
|
+
throw new Error(`Failed to upload session: ${error.message}`);
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`Failed to upload session: ${String(error)}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session file discovery for Claude Code projects
|
|
3
|
+
*
|
|
4
|
+
* Finds all session JSONL files (main and agent) for a given project path.
|
|
5
|
+
* Uses Claude Code's path encoding scheme to locate session directories.
|
|
6
|
+
*/
|
|
7
|
+
import { readdir } from 'fs/promises';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { getSessionDirectory } from '../utils/path-encoding.js';
|
|
10
|
+
/**
|
|
11
|
+
* Finds all session files for a project
|
|
12
|
+
*
|
|
13
|
+
* Discovers both main session files (uuid.jsonl) and agent session files
|
|
14
|
+
* (agent-uuid.jsonl) in the project's session directory.
|
|
15
|
+
*
|
|
16
|
+
* @param projectPath - Absolute path to the project
|
|
17
|
+
* @returns Array of session files with metadata
|
|
18
|
+
* @throws Error if home directory cannot be determined
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const sessions = await findSessionFiles('/Users/name/project');
|
|
22
|
+
* // Returns: [
|
|
23
|
+
* // { path: '/Users/name/.claude/projects/Users-name-project/abc-123.jsonl',
|
|
24
|
+
* // sessionId: 'abc-123', isAgent: false },
|
|
25
|
+
* // { path: '/Users/name/.claude/projects/Users-name-project/agent-def-456.jsonl',
|
|
26
|
+
* // sessionId: 'def-456', isAgent: true }
|
|
27
|
+
* // ]
|
|
28
|
+
*/
|
|
29
|
+
export async function findSessionFiles(projectPath) {
|
|
30
|
+
// Get home directory
|
|
31
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE;
|
|
32
|
+
if (!homeDir) {
|
|
33
|
+
throw new Error('Cannot determine home directory: HOME and USERPROFILE environment variables not set');
|
|
34
|
+
}
|
|
35
|
+
// Get the encoded session directory path
|
|
36
|
+
const sessionDir = getSessionDirectory(projectPath);
|
|
37
|
+
try {
|
|
38
|
+
// List all files in the session directory
|
|
39
|
+
const files = await readdir(sessionDir);
|
|
40
|
+
// Filter and map to SessionFile objects
|
|
41
|
+
return files
|
|
42
|
+
.filter(filename => filename.endsWith('.jsonl'))
|
|
43
|
+
.map(filename => {
|
|
44
|
+
// Detect agent sessions by filename prefix
|
|
45
|
+
const isAgent = filename.startsWith('agent-');
|
|
46
|
+
// Extract session ID: remove 'agent-' prefix if present, then remove '.jsonl' extension
|
|
47
|
+
const sessionId = isAgent
|
|
48
|
+
? filename.replace('agent-', '').replace('.jsonl', '')
|
|
49
|
+
: filename.replace('.jsonl', '');
|
|
50
|
+
return {
|
|
51
|
+
path: join(sessionDir, filename),
|
|
52
|
+
sessionId,
|
|
53
|
+
isAgent,
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
// Handle missing directory gracefully - not an error if project has no sessions yet
|
|
59
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
// Re-throw other errors (permission denied, etc.)
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session metadata extraction from parsed messages
|
|
3
|
+
*
|
|
4
|
+
* Extracts useful metadata from session messages including message counts,
|
|
5
|
+
* timestamps, project path, and agent conversation detection.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Extracts metadata from session messages
|
|
9
|
+
*
|
|
10
|
+
* Analyzes message array to extract session metadata including:
|
|
11
|
+
* - Session ID and project path (from user messages)
|
|
12
|
+
* - Message count and timestamp range
|
|
13
|
+
* - Agent conversation detection
|
|
14
|
+
* - Claude Code version
|
|
15
|
+
*
|
|
16
|
+
* @param messages - Array of parsed session messages
|
|
17
|
+
* @returns Session metadata, or null if messages array is empty
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* const messages = await parseSessionFile('/path/to/session.jsonl');
|
|
21
|
+
* const metadata = extractMetadata(messages);
|
|
22
|
+
* // Returns: {
|
|
23
|
+
* // sessionId: 'abc-123',
|
|
24
|
+
* // projectPath: '/Users/name/project',
|
|
25
|
+
* // messageCount: 42,
|
|
26
|
+
* // firstTimestamp: '2026-01-11T10:00:00.000Z',
|
|
27
|
+
* // lastTimestamp: '2026-01-11T11:30:00.000Z',
|
|
28
|
+
* // hasAgentConversations: true,
|
|
29
|
+
* // version: '1.2.3'
|
|
30
|
+
* // }
|
|
31
|
+
*/
|
|
32
|
+
export function extractMetadata(messages) {
|
|
33
|
+
// Return null for empty arrays
|
|
34
|
+
if (messages.length === 0) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
// Get first and last messages for timestamps
|
|
38
|
+
const firstMessage = messages[0];
|
|
39
|
+
const lastMessage = messages[messages.length - 1];
|
|
40
|
+
// Find first user message to extract cwd and version
|
|
41
|
+
// Use type guard to safely access UserMessage fields
|
|
42
|
+
const firstUserMessage = messages.find(m => m.type === 'user');
|
|
43
|
+
// Detect agent conversations by checking isSidechain flag
|
|
44
|
+
const hasAgentConversations = messages.some(m => m.isSidechain === true);
|
|
45
|
+
// Build metadata object with all fields
|
|
46
|
+
return {
|
|
47
|
+
sessionId: firstMessage.sessionId,
|
|
48
|
+
projectPath: firstUserMessage?.cwd || 'unknown',
|
|
49
|
+
messageCount: messages.length,
|
|
50
|
+
firstTimestamp: firstMessage.timestamp,
|
|
51
|
+
lastTimestamp: lastMessage.timestamp,
|
|
52
|
+
hasAgentConversations,
|
|
53
|
+
version: firstUserMessage?.version || 'unknown',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming JSONL reader for Claude Code session files
|
|
3
|
+
*
|
|
4
|
+
* Provides memory-efficient line-by-line reading with error recovery.
|
|
5
|
+
* Handles large session files without loading entire content into memory.
|
|
6
|
+
*/
|
|
7
|
+
import { createReadStream } from 'fs';
|
|
8
|
+
import { createInterface } from 'readline';
|
|
9
|
+
/**
|
|
10
|
+
* Reads session file line-by-line using async generator
|
|
11
|
+
*
|
|
12
|
+
* Streams the file efficiently, yielding non-empty lines.
|
|
13
|
+
* Handles CRLF line endings automatically.
|
|
14
|
+
*
|
|
15
|
+
* @param filePath - Absolute path to session JSONL file
|
|
16
|
+
* @yields Non-empty lines from the file
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* for await (const line of readSessionLines('/path/to/session.jsonl')) {
|
|
20
|
+
* console.log(line);
|
|
21
|
+
* }
|
|
22
|
+
*/
|
|
23
|
+
export async function* readSessionLines(filePath) {
|
|
24
|
+
// Create read stream with UTF-8 encoding
|
|
25
|
+
const fileStream = createReadStream(filePath, { encoding: 'utf-8' });
|
|
26
|
+
// Create readline interface for line-by-line reading
|
|
27
|
+
// crlfDelay: Infinity handles both \n and \r\n line endings
|
|
28
|
+
const rl = createInterface({
|
|
29
|
+
input: fileStream,
|
|
30
|
+
crlfDelay: Infinity,
|
|
31
|
+
});
|
|
32
|
+
// Yield each non-empty line
|
|
33
|
+
for await (const line of rl) {
|
|
34
|
+
const trimmedLine = line.trim();
|
|
35
|
+
if (trimmedLine) {
|
|
36
|
+
yield trimmedLine;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Checks if a parsed object has required session message fields
|
|
42
|
+
*
|
|
43
|
+
* @param obj - Parsed JSON object
|
|
44
|
+
* @returns true if object has required fields (type, uuid, sessionId)
|
|
45
|
+
*/
|
|
46
|
+
function hasRequiredFields(obj) {
|
|
47
|
+
return (typeof obj === 'object' &&
|
|
48
|
+
obj !== null &&
|
|
49
|
+
'type' in obj &&
|
|
50
|
+
'uuid' in obj &&
|
|
51
|
+
'sessionId' in obj &&
|
|
52
|
+
typeof obj.type === 'string' &&
|
|
53
|
+
typeof obj.uuid === 'string' &&
|
|
54
|
+
typeof obj.sessionId === 'string');
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Parses a session JSONL file with error recovery
|
|
58
|
+
*
|
|
59
|
+
* Reads the file line-by-line, parsing each line as JSON.
|
|
60
|
+
* Continues processing even if individual lines fail to parse.
|
|
61
|
+
* Skips lines missing required fields (type, uuid, sessionId).
|
|
62
|
+
*
|
|
63
|
+
* @param filePath - Absolute path to session JSONL file
|
|
64
|
+
* @returns Array of successfully parsed session messages
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* const messages = await parseSessionFile('/path/to/session.jsonl');
|
|
68
|
+
* console.log(`Loaded ${messages.length} messages`);
|
|
69
|
+
*/
|
|
70
|
+
export async function parseSessionFile(filePath) {
|
|
71
|
+
const messages = [];
|
|
72
|
+
let lineNumber = 0;
|
|
73
|
+
try {
|
|
74
|
+
// Process each line from the file
|
|
75
|
+
for await (const line of readSessionLines(filePath)) {
|
|
76
|
+
lineNumber++;
|
|
77
|
+
try {
|
|
78
|
+
// Parse JSON - this is wrapped per line for error recovery
|
|
79
|
+
const parsed = JSON.parse(line);
|
|
80
|
+
// Validate required fields exist
|
|
81
|
+
if (!hasRequiredFields(parsed)) {
|
|
82
|
+
console.warn(`[reader] Line ${lineNumber}: Skipping message missing required fields (type, uuid, sessionId)`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// Type assertion is safe after validation
|
|
86
|
+
messages.push(parsed);
|
|
87
|
+
}
|
|
88
|
+
catch (parseError) {
|
|
89
|
+
// Log warning but continue processing remaining lines
|
|
90
|
+
console.warn(`[reader] Line ${lineNumber}: Failed to parse JSON - ${parseError instanceof Error ? parseError.message : String(parseError)}`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (fileError) {
|
|
96
|
+
// This catches file-level errors (file not found, permission denied, etc.)
|
|
97
|
+
console.error(`[reader] Error reading file ${filePath}: ${fileError instanceof Error ? fileError.message : String(fileError)}`);
|
|
98
|
+
throw fileError;
|
|
99
|
+
}
|
|
100
|
+
return messages;
|
|
101
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript types for Claude Code session messages
|
|
3
|
+
*
|
|
4
|
+
* Sessions are stored as JSONL files with three message types:
|
|
5
|
+
* - user: User input messages
|
|
6
|
+
* - assistant: Assistant responses with thinking snapshots
|
|
7
|
+
* - file-history-snapshot: File state tracking
|
|
8
|
+
*
|
|
9
|
+
* Uses discriminated unions for type-safe message handling.
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session writer for local JSONL storage
|
|
3
|
+
*
|
|
4
|
+
* Writes session messages to Claude Code's local storage format:
|
|
5
|
+
* ~/.claude/projects/{encodedPath}/{sessionId}.jsonl
|
|
6
|
+
*
|
|
7
|
+
* Uses atomic file writes and handles filesystem errors gracefully.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdir, writeFile } from 'fs/promises';
|
|
10
|
+
import { randomUUID } from 'crypto';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import { homedir } from 'os';
|
|
13
|
+
import { encodeProjectPath } from '../utils/path-encoding.js';
|
|
14
|
+
/**
|
|
15
|
+
* Error thrown when session writing fails
|
|
16
|
+
*/
|
|
17
|
+
export class SessionWriteError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(message, code) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.name = 'SessionWriteError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Write session messages to local Claude Code storage
|
|
27
|
+
*
|
|
28
|
+
* Creates a new session file in ~/.claude/projects/{encodedPath}/{sessionId}.jsonl
|
|
29
|
+
* with JSONL format (one JSON object per line).
|
|
30
|
+
*
|
|
31
|
+
* @param messages - Array of session messages to write
|
|
32
|
+
* @param projectPath - Absolute path to the project (e.g., "/Users/name/project")
|
|
33
|
+
* @returns Promise resolving to written file path and session ID
|
|
34
|
+
* @throws {SessionWriteError} If writing fails (permissions, disk space, etc.)
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* const result = await writeSessionToLocal(messages, '/Users/name/my-project');
|
|
38
|
+
* console.log(`Written to: ${result.filePath}`);
|
|
39
|
+
*/
|
|
40
|
+
export async function writeSessionToLocal(messages, projectPath) {
|
|
41
|
+
try {
|
|
42
|
+
// 1. Encode project path for directory name
|
|
43
|
+
const encodedPath = encodeProjectPath(projectPath);
|
|
44
|
+
// 2. Generate new session ID for filename
|
|
45
|
+
// Note: Messages already have remapped sessionIds in their fields,
|
|
46
|
+
// but the filename needs its own unique ID
|
|
47
|
+
const sessionId = randomUUID();
|
|
48
|
+
// 3. Build target directory and file paths
|
|
49
|
+
const sessionDirectory = join(homedir(), '.claude', 'projects', encodedPath);
|
|
50
|
+
const targetPath = join(sessionDirectory, `${sessionId}.jsonl`);
|
|
51
|
+
// 4. Create directory structure (handles missing ~/.claude/projects/ gracefully)
|
|
52
|
+
await mkdir(sessionDirectory, { recursive: true });
|
|
53
|
+
// 5. Format as JSONL: one JSON object per line with trailing newline
|
|
54
|
+
const jsonlContent = messages.map((msg) => JSON.stringify(msg)).join('\n') + '\n';
|
|
55
|
+
// 6. Write file atomically
|
|
56
|
+
await writeFile(targetPath, jsonlContent, { encoding: 'utf-8' });
|
|
57
|
+
// 7. Return written file path and session ID for verification
|
|
58
|
+
return {
|
|
59
|
+
filePath: targetPath,
|
|
60
|
+
sessionId,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
// Handle filesystem errors with descriptive messages
|
|
65
|
+
if (error.code === 'EACCES') {
|
|
66
|
+
throw new SessionWriteError(`Permission denied: Cannot write to session directory. Check permissions for ~/.claude/projects/`, error.code);
|
|
67
|
+
}
|
|
68
|
+
if (error.code === 'ENOSPC') {
|
|
69
|
+
throw new SessionWriteError(`Disk full: Not enough space to write session file`, error.code);
|
|
70
|
+
}
|
|
71
|
+
// Generic error
|
|
72
|
+
throw new SessionWriteError(`Failed to write session: ${error.message || 'Unknown error'}`, error.code);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path encoding utilities for Claude Code session directories
|
|
3
|
+
*
|
|
4
|
+
* Claude Code stores sessions in ~/.claude/projects/{encodedPath}/
|
|
5
|
+
* where paths are encoded by replacing `/` with `-` and removing leading `-`
|
|
6
|
+
*
|
|
7
|
+
* Example: /Users/name/project -> Users-name-project
|
|
8
|
+
*/
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
/**
|
|
12
|
+
* Encodes an absolute project path into Claude Code's directory naming format
|
|
13
|
+
*
|
|
14
|
+
* @param absolutePath - Absolute file system path (e.g., "/Users/name/project")
|
|
15
|
+
* @returns Encoded directory name (e.g., "Users-name-project")
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* encodeProjectPath('/Users/name/my-project')
|
|
19
|
+
* // Returns: 'Users-name-my-project'
|
|
20
|
+
*/
|
|
21
|
+
export function encodeProjectPath(absolutePath) {
|
|
22
|
+
// Replace all forward slashes with dashes
|
|
23
|
+
const encoded = absolutePath.replace(/\//g, '-');
|
|
24
|
+
// Remove leading dash (from root /)
|
|
25
|
+
return encoded.startsWith('-') ? encoded.slice(1) : encoded;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Decodes a Claude Code directory name back to an absolute path
|
|
29
|
+
*
|
|
30
|
+
* @param encodedName - Encoded directory name (e.g., "Users-name-project")
|
|
31
|
+
* @returns Decoded absolute path (e.g., "/Users/name/project")
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* decodeProjectPath('Users-name-my-project')
|
|
35
|
+
* // Returns: '/Users/name/my-project'
|
|
36
|
+
*/
|
|
37
|
+
export function decodeProjectPath(encodedName) {
|
|
38
|
+
// Replace all dashes with forward slashes and add leading slash
|
|
39
|
+
return '/' + encodedName.replace(/-/g, '/');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Gets the full session directory path for a project
|
|
43
|
+
*
|
|
44
|
+
* @param projectPath - Absolute project path
|
|
45
|
+
* @returns Full path to ~/.claude/projects/{encodedPath}
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* getSessionDirectory('/Users/name/my-project')
|
|
49
|
+
* // Returns: '/Users/name/.claude/projects/Users-name-my-project'
|
|
50
|
+
*/
|
|
51
|
+
export function getSessionDirectory(projectPath) {
|
|
52
|
+
const encoded = encodeProjectPath(projectPath);
|
|
53
|
+
return join(homedir(), '.claude', 'projects', encoded);
|
|
54
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UUID remapper for session import with collision avoidance
|
|
3
|
+
*
|
|
4
|
+
* Provides consistent UUID remapping to avoid conflicts when importing
|
|
5
|
+
* sessions into local Claude Code storage. Maintains parent-child
|
|
6
|
+
* relationships through consistent mapping.
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from 'crypto';
|
|
9
|
+
/**
|
|
10
|
+
* Maps original UUIDs to new UUIDs consistently
|
|
11
|
+
*
|
|
12
|
+
* Ensures that the same original UUID always maps to the same new UUID
|
|
13
|
+
* within a session import operation. This preserves message threading
|
|
14
|
+
* and parent-child relationships.
|
|
15
|
+
*/
|
|
16
|
+
export class UUIDMapper {
|
|
17
|
+
map;
|
|
18
|
+
constructor() {
|
|
19
|
+
this.map = new Map();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Remap a UUID to a new collision-free UUID
|
|
23
|
+
*
|
|
24
|
+
* @param originalUuid - The original UUID to remap (or null)
|
|
25
|
+
* @returns A new UUID that consistently maps from the original, or null if input is null
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* const mapper = new UUIDMapper();
|
|
29
|
+
* const newUuid1 = mapper.remap('abc-123'); // Generates new UUID
|
|
30
|
+
* const newUuid2 = mapper.remap('abc-123'); // Returns same UUID as newUuid1
|
|
31
|
+
* mapper.remap(null); // Returns null
|
|
32
|
+
*/
|
|
33
|
+
remap(originalUuid) {
|
|
34
|
+
// Handle null gracefully (used for root messages with no parent)
|
|
35
|
+
if (originalUuid === null) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
// Check if we've already mapped this UUID
|
|
39
|
+
const existing = this.map.get(originalUuid);
|
|
40
|
+
if (existing) {
|
|
41
|
+
return existing;
|
|
42
|
+
}
|
|
43
|
+
// Generate a new UUID and cache the mapping
|
|
44
|
+
const newUuid = randomUUID();
|
|
45
|
+
this.map.set(originalUuid, newUuid);
|
|
46
|
+
return newUuid;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Remap all UUIDs in a session message
|
|
50
|
+
*
|
|
51
|
+
* Creates an immutable copy of the message with remapped uuid, sessionId,
|
|
52
|
+
* and parentUuid fields. Preserves all other fields unchanged.
|
|
53
|
+
*
|
|
54
|
+
* @param message - The original session message
|
|
55
|
+
* @returns A new message with remapped UUIDs (original unchanged)
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* const mapper = new UUIDMapper();
|
|
59
|
+
* const original = { type: 'user', uuid: 'abc', sessionId: '123', parentUuid: null, ... };
|
|
60
|
+
* const remapped = mapper.remapMessage(original);
|
|
61
|
+
* // original is unchanged, remapped has new UUIDs
|
|
62
|
+
*/
|
|
63
|
+
remapMessage(message) {
|
|
64
|
+
// Create immutable copy with remapped UUID fields
|
|
65
|
+
// Spread operator preserves all other fields (type, message, cwd, etc.)
|
|
66
|
+
return {
|
|
67
|
+
...message,
|
|
68
|
+
uuid: this.remap(message.uuid),
|
|
69
|
+
sessionId: this.remap(message.sessionId),
|
|
70
|
+
parentUuid: this.remap(message.parentUuid),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-session-share",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for sharing Claude Code sessions via GitHub Gist with privacy protection",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"claude-session-share": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/**/*",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"dev": "tsc --watch",
|
|
19
|
+
"prepublishOnly": "npm run build && npm test"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"mcp-server",
|
|
24
|
+
"claude",
|
|
25
|
+
"claude-code",
|
|
26
|
+
"session-sharing",
|
|
27
|
+
"github-gist",
|
|
28
|
+
"privacy",
|
|
29
|
+
"conversation-export",
|
|
30
|
+
"ai-tools"
|
|
31
|
+
],
|
|
32
|
+
"author": "Omkar Kovvali <okovvali5@gmail.com>",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/OmkarKovvali/claude-session-share.git"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/OmkarKovvali/claude-session-share/issues"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/OmkarKovvali/claude-session-share#readme",
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18.0.0"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
47
|
+
"octokit": "^5.0.5"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.10.5",
|
|
51
|
+
"typescript": "^5.7.3",
|
|
52
|
+
"vitest": "^4.0.16"
|
|
53
|
+
}
|
|
54
|
+
}
|