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,403 @@
1
+ /**
2
+ * MCP Integration Tests for share_session tool
3
+ *
4
+ * Tests tool registration schema and handler logic.
5
+ */
6
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
7
+ // Mock services before importing index
8
+ const mockUploadSession = vi.fn();
9
+ const mockImportSession = vi.fn();
10
+ vi.mock('../services/session-uploader.js', () => ({
11
+ uploadSession: mockUploadSession,
12
+ }));
13
+ vi.mock('../services/session-importer.js', () => ({
14
+ importSession: mockImportSession,
15
+ }));
16
+ describe('MCP share_session tool', () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ process.env.GITHUB_TOKEN = 'test_token';
20
+ });
21
+ describe('tool schema', () => {
22
+ it('should have correct tool name', () => {
23
+ const toolSchema = {
24
+ name: 'share_session',
25
+ description: 'Share a Claude Code session via GitHub Gist. Creates a sanitized, shareable link to the conversation.',
26
+ inputSchema: {
27
+ type: 'object',
28
+ properties: {
29
+ sessionPath: {
30
+ type: 'string',
31
+ description: 'Optional path to session file. If not provided, shares the most recent session.',
32
+ },
33
+ },
34
+ },
35
+ };
36
+ expect(toolSchema.name).toBe('share_session');
37
+ expect(toolSchema.description).toContain('Share a Claude Code session');
38
+ });
39
+ it('should have sessionPath parameter in schema', () => {
40
+ const inputSchema = {
41
+ type: 'object',
42
+ properties: {
43
+ sessionPath: {
44
+ type: 'string',
45
+ description: 'Optional path to session file. If not provided, shares the most recent session.',
46
+ },
47
+ },
48
+ };
49
+ expect(inputSchema.properties.sessionPath.type).toBe('string');
50
+ expect(inputSchema.properties.sessionPath.description).toContain('Optional');
51
+ });
52
+ });
53
+ describe('tool execution with explicit path', () => {
54
+ it('should call uploadSession with provided sessionPath', async () => {
55
+ mockUploadSession.mockResolvedValue('https://gist.github.com/test/abc123');
56
+ // Simulate tool handler logic
57
+ const sessionPath = '/test/session.jsonl';
58
+ const gistUrl = await mockUploadSession(sessionPath);
59
+ expect(mockUploadSession).toHaveBeenCalledWith('/test/session.jsonl');
60
+ expect(gistUrl).toBe('https://gist.github.com/test/abc123');
61
+ });
62
+ it('should format success response correctly', async () => {
63
+ mockUploadSession.mockResolvedValue('https://gist.github.com/user/def456');
64
+ const sessionPath = '/explicit/path.jsonl';
65
+ const gistUrl = await mockUploadSession(sessionPath);
66
+ const response = {
67
+ content: [
68
+ {
69
+ type: 'text',
70
+ text: `Successfully shared session!\n\nGist URL: ${gistUrl}\n\nYou can share this URL with others to give them access to this conversation.`,
71
+ },
72
+ ],
73
+ };
74
+ expect(response.content[0].text).toContain('Gist URL: https://gist.github.com/user/def456');
75
+ expect(response.content[0].text).toContain('You can share this URL');
76
+ });
77
+ });
78
+ describe('error handling', () => {
79
+ it('should handle uploadSession errors', async () => {
80
+ mockUploadSession.mockRejectedValue(new Error('GitHub API rate limit exceeded'));
81
+ try {
82
+ await mockUploadSession('/error/path.jsonl');
83
+ }
84
+ catch (error) {
85
+ const errorMessage = error instanceof Error ? error.message : String(error);
86
+ const response = {
87
+ content: [
88
+ {
89
+ type: 'text',
90
+ text: `Failed to share session: ${errorMessage}`,
91
+ },
92
+ ],
93
+ isError: true,
94
+ };
95
+ expect(response.isError).toBe(true);
96
+ expect(response.content[0].text).toContain('Failed to share session');
97
+ expect(response.content[0].text).toContain('GitHub API rate limit exceeded');
98
+ }
99
+ });
100
+ it('should format error response with isError flag', async () => {
101
+ mockUploadSession.mockRejectedValue(new Error('Invalid GITHUB_TOKEN'));
102
+ try {
103
+ await mockUploadSession('/auth/error.jsonl');
104
+ }
105
+ catch (error) {
106
+ const errorMessage = error instanceof Error ? error.message : String(error);
107
+ const response = {
108
+ content: [
109
+ {
110
+ type: 'text',
111
+ text: `Failed to share session: ${errorMessage}`,
112
+ },
113
+ ],
114
+ isError: true,
115
+ };
116
+ expect(response.isError).toBe(true);
117
+ expect(response.content[0].text).toContain('Invalid GITHUB_TOKEN');
118
+ }
119
+ });
120
+ });
121
+ describe('response format', () => {
122
+ it('should return proper MCP tool response structure', async () => {
123
+ mockUploadSession.mockResolvedValue('https://gist.github.com/test/response123');
124
+ const sessionPath = '/test/response.jsonl';
125
+ const gistUrl = await mockUploadSession(sessionPath);
126
+ const response = {
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
+ expect(response).toHaveProperty('content');
135
+ expect(Array.isArray(response.content)).toBe(true);
136
+ expect(response.content[0]).toHaveProperty('type', 'text');
137
+ expect(response.content[0]).toHaveProperty('text');
138
+ expect(response).not.toHaveProperty('isError'); // Success case doesn't have isError
139
+ });
140
+ it('should handle missing sessionPath (use most recent)', async () => {
141
+ // Simulate findMostRecentSession returning a path
142
+ const mostRecentPath = '/mock/most/recent/session.jsonl';
143
+ mockUploadSession.mockResolvedValue('https://gist.github.com/test/xyz789');
144
+ const sessionPath = undefined;
145
+ const pathToShare = sessionPath || mostRecentPath;
146
+ const gistUrl = await mockUploadSession(pathToShare);
147
+ expect(mockUploadSession).toHaveBeenCalledWith(mostRecentPath);
148
+ expect(gistUrl).toBe('https://gist.github.com/test/xyz789');
149
+ });
150
+ it('should handle no sessions found', () => {
151
+ const mostRecentPath = null;
152
+ const response = {
153
+ content: [
154
+ {
155
+ type: 'text',
156
+ text: 'Error: No session files found. Please provide a session path or ensure you have Claude Code sessions in ~/.claude/projects/',
157
+ },
158
+ ],
159
+ isError: true,
160
+ };
161
+ if (!mostRecentPath) {
162
+ expect(response.isError).toBe(true);
163
+ expect(response.content[0].text).toContain('No session files found');
164
+ }
165
+ });
166
+ });
167
+ describe('tool integration', () => {
168
+ it('should verify tool can be imported without errors', async () => {
169
+ // This test verifies the index.ts module loads successfully
170
+ const moduleImport = import('../index.js');
171
+ await expect(moduleImport).resolves.toBeDefined();
172
+ });
173
+ });
174
+ });
175
+ describe('MCP import_session tool', () => {
176
+ beforeEach(() => {
177
+ vi.clearAllMocks();
178
+ process.env.GITHUB_TOKEN = 'test_token';
179
+ });
180
+ describe('tool schema', () => {
181
+ it('should have correct tool name', () => {
182
+ const toolSchema = {
183
+ name: 'import_session',
184
+ description: 'Import a shared Claude Code session from GitHub Gist URL or ID. Creates local resumable session in ~/.claude/projects/',
185
+ inputSchema: {
186
+ type: 'object',
187
+ properties: {
188
+ gistUrl: {
189
+ type: 'string',
190
+ description: 'GitHub Gist URL (https://gist.github.com/user/id) or bare gist ID',
191
+ },
192
+ projectPath: {
193
+ type: 'string',
194
+ description: 'Local project directory path where session will be imported (e.g., /Users/name/project)',
195
+ },
196
+ },
197
+ required: ['gistUrl', 'projectPath'],
198
+ },
199
+ };
200
+ expect(toolSchema.name).toBe('import_session');
201
+ expect(toolSchema.description).toContain('Import a shared Claude Code session');
202
+ });
203
+ it('should have gistUrl and projectPath parameters in schema', () => {
204
+ const inputSchema = {
205
+ type: 'object',
206
+ properties: {
207
+ gistUrl: {
208
+ type: 'string',
209
+ description: 'GitHub Gist URL (https://gist.github.com/user/id) or bare gist ID',
210
+ },
211
+ projectPath: {
212
+ type: 'string',
213
+ description: 'Local project directory path where session will be imported (e.g., /Users/name/project)',
214
+ },
215
+ },
216
+ required: ['gistUrl', 'projectPath'],
217
+ };
218
+ expect(inputSchema.properties.gistUrl.type).toBe('string');
219
+ expect(inputSchema.properties.projectPath.type).toBe('string');
220
+ expect(inputSchema.required).toContain('gistUrl');
221
+ expect(inputSchema.required).toContain('projectPath');
222
+ });
223
+ });
224
+ describe('tool execution', () => {
225
+ it('should call importSession with provided arguments', async () => {
226
+ const mockResult = {
227
+ sessionPath: '/Users/test/.claude/projects/encoded/session-id.jsonl',
228
+ sessionId: 'abc123',
229
+ messageCount: 10,
230
+ projectPath: '/Users/test/project',
231
+ };
232
+ mockImportSession.mockResolvedValue(mockResult);
233
+ const gistUrl = 'https://gist.github.com/user/abc123';
234
+ const projectPath = '/Users/test/project';
235
+ const result = await mockImportSession(gistUrl, projectPath);
236
+ expect(mockImportSession).toHaveBeenCalledWith('https://gist.github.com/user/abc123', '/Users/test/project');
237
+ expect(result.sessionId).toBe('abc123');
238
+ expect(result.messageCount).toBe(10);
239
+ });
240
+ it('should format success response correctly', async () => {
241
+ const mockResult = {
242
+ sessionPath: '/Users/test/.claude/projects/encoded/session-id.jsonl',
243
+ sessionId: 'xyz789',
244
+ messageCount: 25,
245
+ projectPath: '/Users/test/my-project',
246
+ };
247
+ mockImportSession.mockResolvedValue(mockResult);
248
+ const result = await mockImportSession('gist-id', '/Users/test/my-project');
249
+ const response = {
250
+ content: [
251
+ {
252
+ type: 'text',
253
+ text: `Session imported successfully!\n\nSession ID: ${result.sessionId}\nMessages: ${result.messageCount}\nLocation: ${result.sessionPath}\n\nUse 'claude --resume' to see imported session.`,
254
+ },
255
+ ],
256
+ };
257
+ expect(response.content[0].text).toContain('Session imported successfully');
258
+ expect(response.content[0].text).toContain('Session ID: xyz789');
259
+ expect(response.content[0].text).toContain('Messages: 25');
260
+ expect(response.content[0].text).toContain('claude --resume');
261
+ });
262
+ });
263
+ describe('validation', () => {
264
+ it('should validate gistUrl is provided', () => {
265
+ const gistUrl = undefined;
266
+ // Validate as the handler does
267
+ const isValid = gistUrl && typeof gistUrl === 'string' && gistUrl.trim() !== '';
268
+ if (!isValid) {
269
+ const response = {
270
+ content: [
271
+ {
272
+ type: 'text',
273
+ text: 'Error: gistUrl is required and must be a non-empty string',
274
+ },
275
+ ],
276
+ isError: true,
277
+ };
278
+ expect(response.isError).toBe(true);
279
+ expect(response.content[0].text).toContain('gistUrl is required');
280
+ }
281
+ });
282
+ it('should validate projectPath is provided', () => {
283
+ const projectPath = undefined;
284
+ // Validate as the handler does
285
+ const isValid = projectPath && typeof projectPath === 'string' && projectPath.trim() !== '';
286
+ if (!isValid) {
287
+ const response = {
288
+ content: [
289
+ {
290
+ type: 'text',
291
+ text: 'Error: projectPath is required and must be a non-empty string',
292
+ },
293
+ ],
294
+ isError: true,
295
+ };
296
+ expect(response.isError).toBe(true);
297
+ expect(response.content[0].text).toContain('projectPath is required');
298
+ }
299
+ });
300
+ it('should reject empty gistUrl', () => {
301
+ const gistUrl = ' ';
302
+ // Validate as the handler does
303
+ const isValid = gistUrl && typeof gistUrl === 'string' && gistUrl.trim() !== '';
304
+ if (!isValid) {
305
+ const response = {
306
+ content: [
307
+ {
308
+ type: 'text',
309
+ text: 'Error: gistUrl is required and must be a non-empty string',
310
+ },
311
+ ],
312
+ isError: true,
313
+ };
314
+ expect(response.isError).toBe(true);
315
+ }
316
+ });
317
+ it('should reject empty projectPath', () => {
318
+ const projectPath = '';
319
+ // Validate as the handler does
320
+ const isValid = projectPath && typeof projectPath === 'string' && projectPath.trim() !== '';
321
+ if (!isValid) {
322
+ const response = {
323
+ content: [
324
+ {
325
+ type: 'text',
326
+ text: 'Error: projectPath is required and must be a non-empty string',
327
+ },
328
+ ],
329
+ isError: true,
330
+ };
331
+ expect(response.isError).toBe(true);
332
+ }
333
+ });
334
+ });
335
+ describe('error handling', () => {
336
+ it('should handle importSession errors', async () => {
337
+ mockImportSession.mockRejectedValue(new Error('Gist not found'));
338
+ try {
339
+ await mockImportSession('nonexistent', '/Users/test/project');
340
+ }
341
+ catch (error) {
342
+ const errorMessage = error instanceof Error ? error.message : String(error);
343
+ const response = {
344
+ content: [
345
+ {
346
+ type: 'text',
347
+ text: `Import failed: ${errorMessage}`,
348
+ },
349
+ ],
350
+ isError: true,
351
+ };
352
+ expect(response.isError).toBe(true);
353
+ expect(response.content[0].text).toContain('Import failed');
354
+ expect(response.content[0].text).toContain('Gist not found');
355
+ }
356
+ });
357
+ it('should format error response with isError flag', async () => {
358
+ mockImportSession.mockRejectedValue(new Error('Permission denied'));
359
+ try {
360
+ await mockImportSession('abc123', '/restricted/path');
361
+ }
362
+ catch (error) {
363
+ const errorMessage = error instanceof Error ? error.message : String(error);
364
+ const response = {
365
+ content: [
366
+ {
367
+ type: 'text',
368
+ text: `Import failed: ${errorMessage}`,
369
+ },
370
+ ],
371
+ isError: true,
372
+ };
373
+ expect(response.isError).toBe(true);
374
+ expect(response.content[0].text).toContain('Permission denied');
375
+ }
376
+ });
377
+ });
378
+ describe('response format', () => {
379
+ it('should return proper MCP tool response structure', async () => {
380
+ const mockResult = {
381
+ sessionPath: '/Users/test/.claude/projects/encoded/session-id.jsonl',
382
+ sessionId: 'def456',
383
+ messageCount: 15,
384
+ projectPath: '/Users/test/project',
385
+ };
386
+ mockImportSession.mockResolvedValue(mockResult);
387
+ const result = await mockImportSession('gist-url', '/Users/test/project');
388
+ const response = {
389
+ content: [
390
+ {
391
+ type: 'text',
392
+ text: `Session imported successfully!\n\nSession ID: ${result.sessionId}\nMessages: ${result.messageCount}\nLocation: ${result.sessionPath}\n\nUse 'claude --resume' to see imported session.`,
393
+ },
394
+ ],
395
+ };
396
+ expect(response).toHaveProperty('content');
397
+ expect(Array.isArray(response.content)).toBe(true);
398
+ expect(response.content[0]).toHaveProperty('type', 'text');
399
+ expect(response.content[0]).toHaveProperty('text');
400
+ expect(response).not.toHaveProperty('isError'); // Success case doesn't have isError
401
+ });
402
+ });
403
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Tests for path encoding utilities
3
+ *
4
+ * Validates Claude Code's path encoding scheme for session directories.
5
+ */
6
+ import { describe, it, expect } from 'vitest';
7
+ import { encodeProjectPath, decodeProjectPath, getSessionDirectory } from '../utils/path-encoding.js';
8
+ import { homedir } from 'os';
9
+ import { join } from 'path';
10
+ describe('path-encoding', () => {
11
+ describe('encodeProjectPath', () => {
12
+ it('encodes absolute Unix path correctly', () => {
13
+ const result = encodeProjectPath('/Users/name/project');
14
+ expect(result).toBe('Users-name-project');
15
+ });
16
+ it('encodes path with multiple segments', () => {
17
+ const result = encodeProjectPath('/Users/name/my-project/subdir');
18
+ expect(result).toBe('Users-name-my-project-subdir');
19
+ });
20
+ it('handles path with existing dashes', () => {
21
+ const result = encodeProjectPath('/Users/name/my-awesome-project');
22
+ expect(result).toBe('Users-name-my-awesome-project');
23
+ });
24
+ it('handles single segment path', () => {
25
+ const result = encodeProjectPath('/project');
26
+ expect(result).toBe('project');
27
+ });
28
+ it('handles path with multiple consecutive slashes', () => {
29
+ const result = encodeProjectPath('/Users//name///project');
30
+ expect(result).toBe('Users--name---project');
31
+ });
32
+ it('handles path with trailing slash', () => {
33
+ const result = encodeProjectPath('/Users/name/project/');
34
+ expect(result).toBe('Users-name-project-');
35
+ });
36
+ });
37
+ describe('decodeProjectPath', () => {
38
+ it('decodes encoded path correctly', () => {
39
+ const result = decodeProjectPath('Users-name-project');
40
+ expect(result).toBe('/Users/name/project');
41
+ });
42
+ it('decodes path with multiple segments', () => {
43
+ const result = decodeProjectPath('Users-name-myproject-subdir');
44
+ expect(result).toBe('/Users/name/myproject/subdir');
45
+ });
46
+ it('is inverse of encodeProjectPath', () => {
47
+ const original = '/Users/name/project';
48
+ const encoded = encodeProjectPath(original);
49
+ const decoded = decodeProjectPath(encoded);
50
+ expect(decoded).toBe(original);
51
+ });
52
+ it('handles single segment', () => {
53
+ const result = decodeProjectPath('project');
54
+ expect(result).toBe('/project');
55
+ });
56
+ });
57
+ describe('getSessionDirectory', () => {
58
+ it('constructs correct session directory path', () => {
59
+ const projectPath = '/Users/name/project';
60
+ const result = getSessionDirectory(projectPath);
61
+ const expected = join(homedir(), '.claude', 'projects', 'Users-name-project');
62
+ expect(result).toBe(expected);
63
+ });
64
+ it('handles complex project paths', () => {
65
+ const projectPath = '/Users/name/my-awesome-project/subdir';
66
+ const result = getSessionDirectory(projectPath);
67
+ const expected = join(homedir(), '.claude', 'projects', 'Users-name-my-awesome-project-subdir');
68
+ expect(result).toBe(expected);
69
+ });
70
+ it('handles project path with existing dashes', () => {
71
+ const projectPath = '/opt/code/my-app';
72
+ const result = getSessionDirectory(projectPath);
73
+ const expected = join(homedir(), '.claude', 'projects', 'opt-code-my-app');
74
+ expect(result).toBe(expected);
75
+ });
76
+ });
77
+ });