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.
@@ -0,0 +1,199 @@
1
+ /**
2
+ * GitHub Gist client for creating and managing secret gists
3
+ *
4
+ * Uses Octokit v5 with automatic rate limiting and retry handling.
5
+ * Requires GITHUB_TOKEN environment variable for authentication.
6
+ */
7
+ import { Octokit } from 'octokit';
8
+ /**
9
+ * Error thrown when GITHUB_TOKEN is missing or invalid
10
+ */
11
+ export class GistAuthError extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = 'GistAuthError';
15
+ }
16
+ }
17
+ /**
18
+ * Error thrown when Gist API operations fail
19
+ */
20
+ export class GistApiError extends Error {
21
+ statusCode;
22
+ constructor(message, statusCode) {
23
+ super(message);
24
+ this.statusCode = statusCode;
25
+ this.name = 'GistApiError';
26
+ }
27
+ }
28
+ /**
29
+ * Authenticated GitHub Gist client
30
+ *
31
+ * Handles authentication, rate limiting, and error handling for GitHub Gist operations.
32
+ */
33
+ export class GistClient {
34
+ octokit;
35
+ constructor() {
36
+ const token = process.env.GITHUB_TOKEN;
37
+ if (!token) {
38
+ throw new GistAuthError('GITHUB_TOKEN environment variable is required. ' +
39
+ 'Create a personal access token at https://github.com/settings/tokens with "gist" scope.');
40
+ }
41
+ // Initialize Octokit with authentication and throttling
42
+ this.octokit = new Octokit({
43
+ auth: token,
44
+ throttle: {
45
+ onRateLimit: (retryAfter, options, octokit, retryCount) => {
46
+ // Retry up to 3 times on primary rate limits
47
+ console.warn(`Rate limit hit for ${options.method} ${options.url}. ` +
48
+ `Retrying after ${retryAfter} seconds (attempt ${retryCount + 1}/3)`);
49
+ return retryCount < 3;
50
+ },
51
+ onSecondaryRateLimit: (retryAfter, options, octokit) => {
52
+ // Always retry secondary rate limits (abuse detection)
53
+ console.warn(`Secondary rate limit hit for ${options.method} ${options.url}. ` +
54
+ `Retrying after ${retryAfter} seconds`);
55
+ return true;
56
+ },
57
+ },
58
+ });
59
+ }
60
+ /**
61
+ * Get the authenticated Octokit instance
62
+ * @internal Used for testing
63
+ */
64
+ getOctokit() {
65
+ return this.octokit;
66
+ }
67
+ /**
68
+ * Extract gist ID from a GitHub gist URL or return the ID if already in ID format
69
+ *
70
+ * @param gistIdOrUrl - Either a full gist URL (https://gist.github.com/username/abc123) or just the gist ID
71
+ * @returns The extracted gist ID
72
+ *
73
+ * @example
74
+ * extractGistId('https://gist.github.com/user/abc123def456') // Returns 'abc123def456'
75
+ * extractGistId('abc123def456') // Returns 'abc123def456'
76
+ */
77
+ extractGistId(gistIdOrUrl) {
78
+ // Remove trailing slash if present
79
+ const normalized = gistIdOrUrl.replace(/\/$/, '');
80
+ // If it's already just an ID (no slashes), return it
81
+ if (!normalized.includes('/')) {
82
+ return normalized;
83
+ }
84
+ // Extract ID from URL format: https://gist.github.com/username/abc123def456
85
+ // The ID is the last segment after the username
86
+ const segments = normalized.split('/');
87
+ const lastSegment = segments[segments.length - 1];
88
+ // Return the last segment (which should be the gist ID)
89
+ return lastSegment;
90
+ }
91
+ /**
92
+ * Fetch a gist by ID or URL
93
+ *
94
+ * @param gistIdOrUrl - Either a full gist URL or just the gist ID
95
+ * @returns Promise resolving to GistResponse with gist content
96
+ * @throws {GistAuthError} If token is invalid (401)
97
+ * @throws {GistApiError} If gist not found (404), private/deleted (403), or other errors
98
+ *
99
+ * @example
100
+ * const gist = await client.fetchGist('https://gist.github.com/user/abc123');
101
+ * const gist = await client.fetchGist('abc123');
102
+ */
103
+ async fetchGist(gistIdOrUrl) {
104
+ const gistId = this.extractGistId(gistIdOrUrl);
105
+ try {
106
+ const response = await this.octokit.rest.gists.get({
107
+ gist_id: gistId,
108
+ });
109
+ // Map response to our GistResponse type
110
+ return {
111
+ id: response.data.id,
112
+ url: response.data.url,
113
+ html_url: response.data.html_url,
114
+ files: response.data.files,
115
+ public: response.data.public,
116
+ created_at: response.data.created_at,
117
+ updated_at: response.data.updated_at,
118
+ description: response.data.description || '',
119
+ };
120
+ }
121
+ catch (error) {
122
+ // Handle authentication errors
123
+ if (error.status === 401) {
124
+ throw new GistAuthError('Invalid GITHUB_TOKEN. Please check that your token is valid and has the "gist" scope. ' +
125
+ 'Create a new token at https://github.com/settings/tokens');
126
+ }
127
+ // Handle not found errors
128
+ if (error.status === 404) {
129
+ throw new GistApiError(`Gist not found: ${gistId}. The gist may not exist or you may not have access to it.`, 404);
130
+ }
131
+ // Handle permission/private gist errors
132
+ if (error.status === 403) {
133
+ const message = error.message || 'Forbidden';
134
+ if (message.toLowerCase().includes('rate limit')) {
135
+ throw new GistApiError('GitHub API rate limit exceeded. Please wait and try again later.', 403);
136
+ }
137
+ throw new GistApiError(`Access denied to gist ${gistId}. The gist may be private or deleted. Ensure your GITHUB_TOKEN has the "gist" scope.`, 403);
138
+ }
139
+ // Handle other errors
140
+ throw new GistApiError(`Failed to fetch gist: ${error.message || 'Unknown error'}`, error.status);
141
+ }
142
+ }
143
+ /**
144
+ * Create a secret (unlisted) gist with the provided files
145
+ *
146
+ * @param description - Description of the gist
147
+ * @param files - Object mapping filenames to file content
148
+ * @returns Promise resolving to GistResponse with gist URL, ID, and files
149
+ * @throws {GistAuthError} If token is invalid (401)
150
+ * @throws {GistApiError} If API request fails (403, 422, or other errors)
151
+ */
152
+ async createGist(description, files) {
153
+ try {
154
+ // Convert files object to Gist API format
155
+ const gistFiles = {};
156
+ for (const [filename, content] of Object.entries(files)) {
157
+ gistFiles[filename] = { content };
158
+ }
159
+ // Create secret gist (public: false makes it unlisted)
160
+ const response = await this.octokit.rest.gists.create({
161
+ description,
162
+ public: false,
163
+ files: gistFiles,
164
+ });
165
+ // Map response to our GistResponse type
166
+ return {
167
+ id: response.data.id,
168
+ url: response.data.url,
169
+ html_url: response.data.html_url,
170
+ files: response.data.files,
171
+ public: response.data.public,
172
+ created_at: response.data.created_at,
173
+ updated_at: response.data.updated_at,
174
+ description: response.data.description || '',
175
+ };
176
+ }
177
+ catch (error) {
178
+ // Handle authentication errors
179
+ if (error.status === 401) {
180
+ throw new GistAuthError('Invalid GITHUB_TOKEN. Please check that your token is valid and has the "gist" scope. ' +
181
+ 'Create a new token at https://github.com/settings/tokens');
182
+ }
183
+ // Handle permission/rate limit errors
184
+ if (error.status === 403) {
185
+ const message = error.message || 'Forbidden';
186
+ if (message.toLowerCase().includes('rate limit')) {
187
+ throw new GistApiError('GitHub API rate limit exceeded. Please wait and try again later.', 403);
188
+ }
189
+ throw new GistApiError('Permission denied. Ensure your GITHUB_TOKEN has the "gist" scope.', 403);
190
+ }
191
+ // Handle validation errors
192
+ if (error.status === 422) {
193
+ throw new GistApiError(`Invalid gist data: ${error.message || 'Validation failed'}`, 422);
194
+ }
195
+ // Handle other errors
196
+ throw new GistApiError(`Failed to create gist: ${error.message || 'Unknown error'}`, error.status);
197
+ }
198
+ }
199
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * TypeScript types for GitHub Gist operations
3
+ *
4
+ * These types represent the structure of Gist API responses
5
+ * and request payloads for creating and reading gists.
6
+ */
7
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { uploadSession } from "./services/session-uploader.js";
6
+ import { importSession } from "./services/session-importer.js";
7
+ import { homedir } from 'os';
8
+ import { join } from 'path';
9
+ import { readdir, stat } from 'fs/promises';
10
+ /**
11
+ * MCP Server for Claude Code session sharing
12
+ *
13
+ * This server provides tools for exporting and importing Claude Code sessions,
14
+ * enabling collaboration through shareable links.
15
+ */
16
+ // Create the MCP server instance
17
+ const server = new Server({
18
+ name: "claude-session-share",
19
+ version: "0.1.0",
20
+ }, {
21
+ capabilities: {
22
+ tools: {},
23
+ },
24
+ });
25
+ /**
26
+ * Find the most recent session file
27
+ * Searches ~/.claude/projects/ for all session files and returns the most recently modified one
28
+ */
29
+ async function findMostRecentSession() {
30
+ const projectsDir = join(homedir(), '.claude', 'projects');
31
+ try {
32
+ // Get all subdirectories in ~/.claude/projects/
33
+ const entries = await readdir(projectsDir, { withFileTypes: true });
34
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
35
+ let mostRecentFile = null;
36
+ let mostRecentTime = 0;
37
+ // Search each project directory for session files
38
+ for (const dir of dirs) {
39
+ const dirPath = join(projectsDir, dir);
40
+ try {
41
+ const files = await readdir(dirPath);
42
+ for (const file of files) {
43
+ if (file.endsWith('.jsonl')) {
44
+ const filePath = join(dirPath, file);
45
+ const stats = await stat(filePath);
46
+ if (stats.mtimeMs > mostRecentTime) {
47
+ mostRecentTime = stats.mtimeMs;
48
+ mostRecentFile = filePath;
49
+ }
50
+ }
51
+ }
52
+ }
53
+ catch (err) {
54
+ // Skip directories we can't read
55
+ continue;
56
+ }
57
+ }
58
+ return mostRecentFile;
59
+ }
60
+ catch (err) {
61
+ throw new Error(`Failed to find sessions: ${err instanceof Error ? err.message : String(err)}`);
62
+ }
63
+ }
64
+ /**
65
+ * List available tools
66
+ */
67
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
68
+ return {
69
+ tools: [
70
+ {
71
+ name: "share_session",
72
+ description: "Share a Claude Code session via GitHub Gist. Creates a sanitized, shareable link to the conversation.",
73
+ inputSchema: {
74
+ type: "object",
75
+ properties: {
76
+ sessionPath: {
77
+ type: "string",
78
+ description: "Optional path to session file. If not provided, shares the most recent session.",
79
+ },
80
+ },
81
+ },
82
+ },
83
+ {
84
+ name: "import_session",
85
+ description: "Import a shared Claude Code session from GitHub Gist URL or ID. Creates local resumable session in ~/.claude/projects/",
86
+ inputSchema: {
87
+ type: "object",
88
+ properties: {
89
+ gistUrl: {
90
+ type: "string",
91
+ description: "GitHub Gist URL (https://gist.github.com/user/id) or bare gist ID",
92
+ },
93
+ projectPath: {
94
+ type: "string",
95
+ description: "Local project directory path where session will be imported (e.g., /Users/name/project)",
96
+ },
97
+ },
98
+ required: ["gistUrl", "projectPath"],
99
+ },
100
+ },
101
+ ],
102
+ };
103
+ });
104
+ /**
105
+ * Handle tool execution
106
+ */
107
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
108
+ if (request.params.name === "share_session") {
109
+ try {
110
+ const sessionPath = request.params.arguments?.sessionPath;
111
+ // If no sessionPath provided, find most recent session
112
+ const pathToShare = sessionPath || await findMostRecentSession();
113
+ if (!pathToShare) {
114
+ return {
115
+ content: [
116
+ {
117
+ type: "text",
118
+ text: "Error: No session files found. Please provide a session path or ensure you have Claude Code sessions in ~/.claude/projects/",
119
+ },
120
+ ],
121
+ isError: true,
122
+ };
123
+ }
124
+ // Upload session and get gist URL
125
+ const gistUrl = await uploadSession(pathToShare);
126
+ return {
127
+ content: [
128
+ {
129
+ type: "text",
130
+ text: `Successfully shared session!\n\nGist URL: ${gistUrl}\n\nYou can share this URL with others to give them access to this conversation.`,
131
+ },
132
+ ],
133
+ };
134
+ }
135
+ catch (error) {
136
+ const errorMessage = error instanceof Error ? error.message : String(error);
137
+ return {
138
+ content: [
139
+ {
140
+ type: "text",
141
+ text: `Failed to share session: ${errorMessage}`,
142
+ },
143
+ ],
144
+ isError: true,
145
+ };
146
+ }
147
+ }
148
+ if (request.params.name === "import_session") {
149
+ try {
150
+ const gistUrl = request.params.arguments?.gistUrl;
151
+ const projectPath = request.params.arguments?.projectPath;
152
+ // Validate inputs
153
+ if (!gistUrl || typeof gistUrl !== 'string' || gistUrl.trim() === '') {
154
+ return {
155
+ content: [
156
+ {
157
+ type: "text",
158
+ text: "Error: gistUrl is required and must be a non-empty string",
159
+ },
160
+ ],
161
+ isError: true,
162
+ };
163
+ }
164
+ if (!projectPath || typeof projectPath !== 'string' || projectPath.trim() === '') {
165
+ return {
166
+ content: [
167
+ {
168
+ type: "text",
169
+ text: "Error: projectPath is required and must be a non-empty string",
170
+ },
171
+ ],
172
+ isError: true,
173
+ };
174
+ }
175
+ // Import session
176
+ const result = await importSession(gistUrl, projectPath);
177
+ return {
178
+ content: [
179
+ {
180
+ type: "text",
181
+ text: `Session imported successfully!\n\nSession ID: ${result.sessionId}\nMessages: ${result.messageCount}\nLocation: ${result.sessionPath}\n\nUse 'claude --resume' to see imported session.`,
182
+ },
183
+ ],
184
+ };
185
+ }
186
+ catch (error) {
187
+ const errorMessage = error instanceof Error ? error.message : String(error);
188
+ return {
189
+ content: [
190
+ {
191
+ type: "text",
192
+ text: `Import failed: ${errorMessage}`,
193
+ },
194
+ ],
195
+ isError: true,
196
+ };
197
+ }
198
+ }
199
+ throw new Error(`Unknown tool: ${request.params.name}`);
200
+ });
201
+ /**
202
+ * Start the server with stdio transport
203
+ * This allows the MCP server to communicate via standard input/output
204
+ */
205
+ async function main() {
206
+ const transport = new StdioServerTransport();
207
+ await server.connect(transport);
208
+ // Server is now running and ready to handle MCP protocol messages
209
+ console.error("Claude Session Share MCP server running on stdio");
210
+ }
211
+ main().catch((error) => {
212
+ console.error("Fatal error in main():", error);
213
+ process.exit(1);
214
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Session sanitization pipeline
3
+ *
4
+ * Orchestrates full session sanitization:
5
+ * - Maps over all messages in session
6
+ * - Applies appropriate sanitization based on message type
7
+ * - Preserves message order and structure
8
+ */
9
+ import { sanitizeAssistantMessage, sanitizeUserMessage, sanitizeFileHistorySnapshot, } from './sanitizer.js';
10
+ /**
11
+ * Sanitize entire session by processing all messages
12
+ *
13
+ * Uses discriminated union type guards to apply correct sanitization
14
+ * Returns new array (immutable transformation)
15
+ */
16
+ export function sanitizeSession(messages, basePath) {
17
+ return messages.map((msg) => {
18
+ switch (msg.type) {
19
+ case 'assistant':
20
+ return sanitizeAssistantMessage(msg, basePath);
21
+ case 'user':
22
+ return sanitizeUserMessage(msg, basePath);
23
+ case 'file-history-snapshot':
24
+ return sanitizeFileHistorySnapshot(msg, basePath);
25
+ default:
26
+ // Exhaustiveness check - TypeScript ensures all cases covered
27
+ const _exhaustive = msg;
28
+ return _exhaustive;
29
+ }
30
+ });
31
+ }
32
+ /**
33
+ * Infer base path from session messages
34
+ *
35
+ * Extracts the working directory from first user message with cwd
36
+ * Returns empty string if no cwd found
37
+ */
38
+ export function inferBasePath(messages) {
39
+ for (const msg of messages) {
40
+ if (msg.type === 'user') {
41
+ const userMsg = msg;
42
+ if (userMsg.cwd) {
43
+ return userMsg.cwd;
44
+ }
45
+ }
46
+ }
47
+ return '';
48
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Secret redaction utilities for tool results
3
+ *
4
+ * Detects and redacts common secret patterns:
5
+ * - API keys, tokens, access keys
6
+ * - Environment variable values
7
+ * - GitHub tokens
8
+ * - AWS credentials
9
+ * - Long base64/hex strings
10
+ */
11
+ /**
12
+ * Pattern-based secret detection and redaction
13
+ *
14
+ * Philosophy: Better to redact too much (false positive) than leak secrets (false negative)
15
+ */
16
+ export function redactSecrets(content) {
17
+ if (!content) {
18
+ return content;
19
+ }
20
+ let redacted = content;
21
+ // Pattern 1: API keys in JSON/object notation
22
+ // Matches: "api_key": "value", "apiKey": "value", "API_KEY": "value"
23
+ redacted = redacted.replace(/(["']?(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|access[_-]?token|auth[_-]?token|bearer[_-]?token)["']?\s*[=:]\s*)["']([^"'\s]{8,})["']/gi, '$1"[REDACTED]"');
24
+ // Pattern 2: Environment variable format (KEY=value)
25
+ // Matches: API_KEY=abc123, SECRET_TOKEN=xyz789
26
+ redacted = redacted.replace(/\b([A-Z_]+(?:KEY|TOKEN|SECRET|PASSWORD|AUTH)[A-Z_]*)\s*=\s*([^\s"']{8,})/g, '$1=[REDACTED]');
27
+ // Pattern 3: GitHub tokens
28
+ // Matches: ghp_, ghs_, github_pat_
29
+ redacted = redacted.replace(/\b(ghp_|ghs_|github_pat_)[a-zA-Z0-9]{30,}/g, '[REDACTED]');
30
+ // Pattern 4: AWS access keys
31
+ // Matches: AKIA..., aws_secret_access_key with long value
32
+ redacted = redacted.replace(/\bAKIA[A-Z0-9]{16}\b/g, '[REDACTED]');
33
+ redacted = redacted.replace(/(aws[_-]?secret[_-]?access[_-]?key\s*[=:]\s*)["']?([^\s"']{20,})["']?/gi, '$1[REDACTED]');
34
+ // Pattern 5: Generic long strings that look like secrets
35
+ // Base64-like strings (alphanumeric + / + =, 32+ chars)
36
+ // Hex strings (32+ hex chars)
37
+ // Only in value positions (after = or :)
38
+ redacted = redacted.replace(/([=:]\s*)["']?([A-Za-z0-9+/]{32,}={0,2})["']?/g, (match, prefix, value) => {
39
+ // Avoid redacting things that look like file paths or normal text
40
+ if (value.includes('/') && value.split('/').length > 2) {
41
+ return match; // Likely a file path
42
+ }
43
+ return prefix + '[REDACTED]';
44
+ });
45
+ // Pattern 6: Private keys (BEGIN PRIVATE KEY)
46
+ redacted = redacted.replace(/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g, '[REDACTED PRIVATE KEY]');
47
+ return redacted;
48
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Session sanitization utilities for privacy protection
3
+ *
4
+ * Removes sensitive data before sharing:
5
+ * - Strips thinking blocks from assistant messages
6
+ * - Sanitizes absolute paths to relative paths
7
+ * - Immutable transformations (returns new objects)
8
+ */
9
+ import * as path from 'node:path';
10
+ import { redactSecrets } from './redactor.js';
11
+ /**
12
+ * Sanitize assistant message by stripping thinking and sanitizing paths in tool results
13
+ */
14
+ export function sanitizeAssistantMessage(msg, basePath) {
15
+ return {
16
+ ...msg,
17
+ snapshot: {
18
+ thinking: null, // Strip thinking block
19
+ messages: msg.snapshot.messages.map((m) => ({
20
+ ...m,
21
+ content: redactSecrets(sanitizePathsInContent(m.content, basePath)),
22
+ })),
23
+ },
24
+ };
25
+ }
26
+ /**
27
+ * Sanitize user message by converting absolute cwd to relative path
28
+ */
29
+ export function sanitizeUserMessage(msg, basePath) {
30
+ return {
31
+ ...msg,
32
+ cwd: sanitizePath(msg.cwd, basePath),
33
+ };
34
+ }
35
+ /**
36
+ * Sanitize file history snapshot by converting absolute paths to relative
37
+ */
38
+ export function sanitizeFileHistorySnapshot(msg, basePath) {
39
+ return {
40
+ ...msg,
41
+ snapshot: {
42
+ ...msg.snapshot,
43
+ files: msg.snapshot.files.map((file) => ({
44
+ ...file,
45
+ path: sanitizePath(file.path, basePath),
46
+ })),
47
+ },
48
+ };
49
+ }
50
+ /**
51
+ * Sanitize a single path: convert absolute to relative if within basePath
52
+ */
53
+ function sanitizePath(absolutePath, basePath) {
54
+ if (!basePath || !absolutePath) {
55
+ return absolutePath;
56
+ }
57
+ // Normalize paths for comparison
58
+ const normalizedBase = path.resolve(basePath);
59
+ const normalizedPath = path.resolve(absolutePath);
60
+ // Check if path is within basePath
61
+ if (normalizedPath.startsWith(normalizedBase)) {
62
+ const relativePath = path.relative(normalizedBase, normalizedPath);
63
+ // Avoid returning empty string for basePath itself
64
+ return relativePath || '.';
65
+ }
66
+ // External path - return as-is
67
+ return absolutePath;
68
+ }
69
+ /**
70
+ * Sanitize paths found in text content (tool results, etc.)
71
+ */
72
+ function sanitizePathsInContent(content, basePath) {
73
+ if (!basePath || !content) {
74
+ return content;
75
+ }
76
+ // Replace absolute paths with relative ones
77
+ // Match file paths (must be platform-aware)
78
+ const normalizedBase = path.resolve(basePath);
79
+ // Create regex that matches the base path
80
+ // Escape special regex characters in path
81
+ const escapedBase = normalizedBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
82
+ // Match absolute paths starting with base (both forward and backslashes)
83
+ const pathRegex = new RegExp(escapedBase.replace(/\\/g, '[\\\\/]') + '[\\\\/][^\\s"\'<>|]*', 'g');
84
+ return content.replace(pathRegex, (match) => {
85
+ return sanitizePath(match, basePath);
86
+ });
87
+ }