ai-cli-mcp 2.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.
Files changed (69) hide show
  1. package/.claude/settings.local.json +19 -0
  2. package/.github/workflows/ci.yml +31 -0
  3. package/.github/workflows/test.yml +43 -0
  4. package/.vscode/settings.json +3 -0
  5. package/AGENT.md +57 -0
  6. package/CHANGELOG.md +126 -0
  7. package/LICENSE +22 -0
  8. package/README.md +329 -0
  9. package/RELEASE.md +74 -0
  10. package/data/rooms/refactor-haiku-alias-main/messages.jsonl +5 -0
  11. package/data/rooms/refactor-haiku-alias-main/presence.json +20 -0
  12. package/data/rooms.json +10 -0
  13. package/dist/__tests__/e2e.test.js +238 -0
  14. package/dist/__tests__/edge-cases.test.js +135 -0
  15. package/dist/__tests__/error-cases.test.js +296 -0
  16. package/dist/__tests__/mocks.js +32 -0
  17. package/dist/__tests__/model-alias.test.js +36 -0
  18. package/dist/__tests__/process-management.test.js +632 -0
  19. package/dist/__tests__/server.test.js +665 -0
  20. package/dist/__tests__/setup.js +11 -0
  21. package/dist/__tests__/utils/claude-mock.js +80 -0
  22. package/dist/__tests__/utils/mcp-client.js +104 -0
  23. package/dist/__tests__/utils/persistent-mock.js +25 -0
  24. package/dist/__tests__/utils/test-helpers.js +11 -0
  25. package/dist/__tests__/validation.test.js +212 -0
  26. package/dist/__tests__/version-print.test.js +69 -0
  27. package/dist/parsers.js +54 -0
  28. package/dist/server.js +614 -0
  29. package/docs/RELEASE_CHECKLIST.md +26 -0
  30. package/docs/e2e-testing.md +148 -0
  31. package/docs/local_install.md +111 -0
  32. package/hello.txt +3 -0
  33. package/implementation-log.md +110 -0
  34. package/implementation-plan.md +189 -0
  35. package/investigation-report.md +135 -0
  36. package/package.json +53 -0
  37. package/print-eslint-config.js +3 -0
  38. package/quality-score.json +47 -0
  39. package/refactoring-requirements.md +25 -0
  40. package/review-report.md +132 -0
  41. package/scripts/check-version-log.sh +34 -0
  42. package/scripts/publish-release.sh +95 -0
  43. package/scripts/restore-config.sh +28 -0
  44. package/scripts/test-release.sh +69 -0
  45. package/src/__tests__/e2e.test.ts +290 -0
  46. package/src/__tests__/edge-cases.test.ts +181 -0
  47. package/src/__tests__/error-cases.test.ts +378 -0
  48. package/src/__tests__/mocks.ts +35 -0
  49. package/src/__tests__/model-alias.test.ts +44 -0
  50. package/src/__tests__/process-management.test.ts +772 -0
  51. package/src/__tests__/server.test.ts +851 -0
  52. package/src/__tests__/setup.ts +13 -0
  53. package/src/__tests__/utils/claude-mock.ts +87 -0
  54. package/src/__tests__/utils/mcp-client.ts +129 -0
  55. package/src/__tests__/utils/persistent-mock.ts +29 -0
  56. package/src/__tests__/utils/test-helpers.ts +13 -0
  57. package/src/__tests__/validation.test.ts +258 -0
  58. package/src/__tests__/version-print.test.ts +86 -0
  59. package/src/parsers.ts +55 -0
  60. package/src/server.ts +735 -0
  61. package/start.bat +9 -0
  62. package/start.sh +21 -0
  63. package/test-results.md +119 -0
  64. package/test-standalone.js +5877 -0
  65. package/tsconfig.json +16 -0
  66. package/vitest.config.e2e.ts +27 -0
  67. package/vitest.config.ts +22 -0
  68. package/vitest.config.unit.ts +29 -0
  69. package/xx.txt +1 -0
@@ -0,0 +1,13 @@
1
+ // Global test setup
2
+ import { beforeAll, afterAll } from 'vitest';
3
+ import { getSharedMock, cleanupSharedMock } from './utils/persistent-mock.js';
4
+
5
+ beforeAll(async () => {
6
+ console.error('[TEST SETUP] Creating shared mock for all tests...');
7
+ await getSharedMock();
8
+ });
9
+
10
+ afterAll(async () => {
11
+ console.error('[TEST SETUP] Cleaning up shared mock...');
12
+ await cleanupSharedMock();
13
+ });
@@ -0,0 +1,87 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+
4
+ /**
5
+ * Mock Claude CLI for testing
6
+ * This creates a fake Claude CLI that can be used during testing
7
+ */
8
+ export class ClaudeMock {
9
+ private mockPath: string;
10
+ private responses = new Map<string, string>();
11
+
12
+ constructor(binaryName: string = 'claude') {
13
+ // Always use /tmp directory for mocks in tests
14
+ this.mockPath = join('/tmp', 'claude-code-test-mock', binaryName);
15
+ }
16
+
17
+ /**
18
+ * Setup the mock Claude CLI
19
+ */
20
+ async setup(): Promise<void> {
21
+ const dir = dirname(this.mockPath);
22
+ if (!existsSync(dir)) {
23
+ mkdirSync(dir, { recursive: true });
24
+ }
25
+
26
+ // Create a simple bash script that echoes responses
27
+ const mockScript = `#!/bin/bash
28
+ # Mock Claude CLI for testing
29
+
30
+ # Extract the prompt from arguments
31
+ prompt=""
32
+ verbose=false
33
+ while [[ $# -gt 0 ]]; do
34
+ case $1 in
35
+ -p|--prompt)
36
+ prompt="$2"
37
+ shift 2
38
+ ;;
39
+ --verbose)
40
+ verbose=true
41
+ shift
42
+ ;;
43
+ --yes|-y|--dangerously-skip-permissions)
44
+ shift
45
+ ;;
46
+ *)
47
+ shift
48
+ ;;
49
+ esac
50
+ done
51
+
52
+ # Mock responses based on prompt
53
+ if [[ "$prompt" == *"create"* ]]; then
54
+ echo "Created file successfully"
55
+ elif [[ "$prompt" == *"Create"* ]]; then
56
+ echo "Created file successfully"
57
+ elif [[ "$prompt" == *"git"* ]] && [[ "$prompt" == *"commit"* ]]; then
58
+ echo "Committed changes successfully"
59
+ elif [[ "$prompt" == *"error"* ]]; then
60
+ echo "Error: Mock error response" >&2
61
+ exit 1
62
+ else
63
+ echo "Command executed successfully"
64
+ fi
65
+ `;
66
+
67
+ writeFileSync(this.mockPath, mockScript);
68
+ // Make executable
69
+ const { chmod } = await import('node:fs/promises');
70
+ await chmod(this.mockPath, 0o755);
71
+ }
72
+
73
+ /**
74
+ * Cleanup the mock Claude CLI
75
+ */
76
+ async cleanup(): Promise<void> {
77
+ const { rm } = await import('node:fs/promises');
78
+ await rm(this.mockPath, { force: true });
79
+ }
80
+
81
+ /**
82
+ * Add a mock response for a specific prompt pattern
83
+ */
84
+ addResponse(pattern: string, response: string): void {
85
+ this.responses.set(pattern, response);
86
+ }
87
+ }
@@ -0,0 +1,129 @@
1
+ import { spawn, ChildProcess } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+
4
+ export interface MCPResponse {
5
+ jsonrpc: string;
6
+ id: number;
7
+ result?: any;
8
+ error?: {
9
+ code: number;
10
+ message: string;
11
+ data?: any;
12
+ };
13
+ }
14
+
15
+ /**
16
+ * Mock MCP client for testing the server
17
+ */
18
+ export class MCPTestClient extends EventEmitter {
19
+ private server: ChildProcess | null = null;
20
+ private requestId = 0;
21
+ private pendingRequests = new Map<number, {
22
+ resolve: (response: MCPResponse) => void;
23
+ reject: (error: Error) => void;
24
+ }>();
25
+ private buffer = '';
26
+
27
+ constructor(private serverPath: string, private env: Record<string, string> = {}) {
28
+ super();
29
+ }
30
+
31
+ async connect(): Promise<void> {
32
+ return new Promise((resolve, reject) => {
33
+ this.server = spawn('node', [this.serverPath], {
34
+ env: { ...process.env, ...this.env },
35
+ stdio: ['pipe', 'pipe', 'pipe'],
36
+ });
37
+
38
+ this.server.stdout?.on('data', (data) => {
39
+ this.handleData(data.toString());
40
+ });
41
+
42
+ this.server.stderr?.on('data', (data) => {
43
+ console.error('Server stderr:', data.toString());
44
+ });
45
+
46
+ this.server.on('error', (error) => {
47
+ reject(error);
48
+ });
49
+
50
+ this.server.on('spawn', () => {
51
+ resolve();
52
+ });
53
+ });
54
+ }
55
+
56
+ async disconnect(): Promise<void> {
57
+ if (this.server) {
58
+ this.server.kill();
59
+ await new Promise((resolve) => {
60
+ this.server!.on('exit', resolve);
61
+ });
62
+ this.server = null;
63
+ }
64
+ }
65
+
66
+ private handleData(data: string): void {
67
+ this.buffer += data;
68
+ const lines = this.buffer.split('\n');
69
+ this.buffer = lines.pop() || '';
70
+
71
+ for (const line of lines) {
72
+ if (!line.trim()) continue;
73
+ try {
74
+ const response = JSON.parse(line);
75
+ if (response.id && this.pendingRequests.has(response.id)) {
76
+ const pending = this.pendingRequests.get(response.id)!;
77
+ this.pendingRequests.delete(response.id);
78
+ pending.resolve(response);
79
+ } else {
80
+ this.emit('notification', response);
81
+ }
82
+ } catch (error) {
83
+ console.error('Failed to parse response:', line, error);
84
+ }
85
+ }
86
+ }
87
+
88
+ async sendRequest(method: string, params?: any): Promise<any> {
89
+ const id = ++this.requestId;
90
+ const request = {
91
+ jsonrpc: '2.0',
92
+ method,
93
+ params,
94
+ id,
95
+ };
96
+
97
+ return new Promise((resolve, reject) => {
98
+ this.pendingRequests.set(id, { resolve, reject });
99
+
100
+ this.server?.stdin?.write(JSON.stringify(request) + '\n');
101
+
102
+ // Timeout after 30 seconds
103
+ setTimeout(() => {
104
+ if (this.pendingRequests.has(id)) {
105
+ this.pendingRequests.delete(id);
106
+ reject(new Error(`Request ${id} timed out`));
107
+ }
108
+ }, 30000);
109
+ });
110
+ }
111
+
112
+ async callTool(name: string, args: any): Promise<any> {
113
+ const response = await this.sendRequest('tools/call', {
114
+ name,
115
+ arguments: args,
116
+ });
117
+
118
+ if (response.error) {
119
+ throw new Error(`Tool call failed: ${response.error.message}`);
120
+ }
121
+
122
+ return response.result?.content;
123
+ }
124
+
125
+ async listTools(): Promise<any> {
126
+ const response = await this.sendRequest('tools/list');
127
+ return response.result?.tools || [];
128
+ }
129
+ }
@@ -0,0 +1,29 @@
1
+ import { ClaudeMock } from './claude-mock.js';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ let sharedMock: ClaudeMock | null = null;
6
+
7
+ export async function getSharedMock(): Promise<ClaudeMock> {
8
+ if (!sharedMock) {
9
+ sharedMock = new ClaudeMock('claudeMocked');
10
+ }
11
+
12
+ // Always ensure mock exists
13
+ const mockPath = join('/tmp', 'claude-code-test-mock', 'claudeMocked');
14
+ if (!existsSync(mockPath)) {
15
+ console.error(`[DEBUG] Mock not found at ${mockPath}, creating it...`);
16
+ await sharedMock.setup();
17
+ } else {
18
+ console.error(`[DEBUG] Mock already exists at ${mockPath}`);
19
+ }
20
+
21
+ return sharedMock;
22
+ }
23
+
24
+ export async function cleanupSharedMock(): Promise<void> {
25
+ if (sharedMock) {
26
+ await sharedMock.cleanup();
27
+ sharedMock = null;
28
+ }
29
+ }
@@ -0,0 +1,13 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export function verifyMockExists(binaryName: string): boolean {
5
+ const mockPath = join('/tmp', 'claude-code-test-mock', binaryName);
6
+ return existsSync(mockPath);
7
+ }
8
+
9
+ export async function ensureMockExists(mock: any): Promise<void> {
10
+ if (!verifyMockExists('claudeMocked')) {
11
+ await mock.setup();
12
+ }
13
+ }
@@ -0,0 +1,258 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { existsSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
+
7
+ // Mock dependencies
8
+ vi.mock('node:child_process', () => ({
9
+ spawn: vi.fn()
10
+ }));
11
+ vi.mock('node:fs');
12
+ vi.mock('node:os');
13
+ vi.mock('node:path', () => ({
14
+ resolve: vi.fn((path) => path),
15
+ join: vi.fn((...args) => args.join('/')),
16
+ isAbsolute: vi.fn((path) => path.startsWith('/'))
17
+ }));
18
+ vi.mock('@modelcontextprotocol/sdk/server/index.js', () => ({
19
+ Server: vi.fn().mockImplementation(function(this: any) {
20
+ this.setRequestHandler = vi.fn();
21
+ this.connect = vi.fn();
22
+ this.close = vi.fn();
23
+ this.onerror = undefined;
24
+ return this;
25
+ }),
26
+ }));
27
+
28
+ vi.mock('@modelcontextprotocol/sdk/types.js', () => ({
29
+ ListToolsRequestSchema: { name: 'listTools' },
30
+ CallToolRequestSchema: { name: 'callTool' },
31
+ ErrorCode: {
32
+ InternalError: 'InternalError',
33
+ MethodNotFound: 'MethodNotFound',
34
+ InvalidParams: 'InvalidParams'
35
+ },
36
+ McpError: vi.fn().mockImplementation((code, message) => {
37
+ const error = new Error(message);
38
+ (error as any).code = code;
39
+ return error;
40
+ })
41
+ }));
42
+
43
+ const mockExistsSync = vi.mocked(existsSync);
44
+ const mockHomedir = vi.mocked(homedir);
45
+
46
+ describe('Argument Validation Tests', () => {
47
+ let consoleErrorSpy: any;
48
+ let errorHandler: any = null;
49
+
50
+ function setupServerMock() {
51
+ errorHandler = null;
52
+ vi.mocked(Server).mockImplementation(function(this: any) {
53
+ this.setRequestHandler = vi.fn();
54
+ this.connect = vi.fn();
55
+ this.close = vi.fn();
56
+ Object.defineProperty(this, 'onerror', {
57
+ get() { return errorHandler; },
58
+ set(handler) { errorHandler = handler; },
59
+ enumerable: true,
60
+ configurable: true
61
+ });
62
+ return this;
63
+ });
64
+ }
65
+
66
+ beforeEach(() => {
67
+ vi.clearAllMocks();
68
+ vi.resetModules();
69
+ vi.unmock('../server.js');
70
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
71
+ // Set up process.env
72
+ process.env = { ...process.env };
73
+ });
74
+
75
+ describe('Tool Arguments Schema', () => {
76
+ it('should validate valid arguments', async () => {
77
+ mockHomedir.mockReturnValue('/home/user');
78
+ mockExistsSync.mockReturnValue(true);
79
+ setupServerMock();
80
+ const module = await import('../server.js');
81
+ // @ts-ignore
82
+ const { ClaudeCodeServer } = module;
83
+
84
+ const server = new ClaudeCodeServer();
85
+ const mockServerInstance = vi.mocked(Server).mock.results[0].value;
86
+
87
+ // Find tool definition
88
+ const listToolsCall = mockServerInstance.setRequestHandler.mock.calls.find(
89
+ (call: any[]) => call[0].name === 'listTools'
90
+ );
91
+
92
+ const listHandler = listToolsCall[1];
93
+ const tools = await listHandler();
94
+ const claudeCodeTool = tools.tools[0];
95
+
96
+ // Extract schema from tool definition
97
+ const schema = z.object({
98
+ prompt: z.string(),
99
+ workFolder: z.string(),
100
+ model: z.string().optional(),
101
+ session_id: z.string().optional()
102
+ });
103
+
104
+ // Test valid cases
105
+ expect(() => schema.parse({ prompt: 'test', workFolder: '/tmp' })).not.toThrow();
106
+ expect(() => schema.parse({ prompt: 'test', workFolder: '/tmp', model: 'sonnet' })).not.toThrow();
107
+ });
108
+
109
+ it('should reject invalid arguments', async () => {
110
+ mockHomedir.mockReturnValue('/home/user');
111
+ mockExistsSync.mockReturnValue(true);
112
+ setupServerMock();
113
+ const module = await import('../server.js');
114
+ // @ts-ignore
115
+ const { ClaudeCodeServer } = module;
116
+
117
+ const server = new ClaudeCodeServer();
118
+ const mockServerInstance = vi.mocked(Server).mock.results[0].value;
119
+
120
+ // Find tool definition
121
+ const listToolsCall = mockServerInstance.setRequestHandler.mock.calls.find(
122
+ (call: any[]) => call[0].name === 'listTools'
123
+ );
124
+
125
+ const listHandler = listToolsCall[1];
126
+ const tools = await listHandler();
127
+ const claudeCodeTool = tools.tools[0];
128
+
129
+ // Extract schema from tool definition
130
+ const schema = z.object({
131
+ prompt: z.string(),
132
+ workFolder: z.string(),
133
+ model: z.string().optional(),
134
+ session_id: z.string().optional()
135
+ });
136
+
137
+ // Test invalid cases
138
+ expect(() => schema.parse({})).toThrow(); // Missing prompt and workFolder
139
+ expect(() => schema.parse({ prompt: 'test' })).toThrow(); // Missing workFolder
140
+ expect(() => schema.parse({ prompt: 123, workFolder: '/tmp' })).toThrow(); // Wrong prompt type
141
+ expect(() => schema.parse({ prompt: 'test', workFolder: 123 })).toThrow(); // Wrong workFolder type
142
+ });
143
+
144
+ it('should handle missing required fields', async () => {
145
+ const schema = z.object({
146
+ prompt: z.string(),
147
+ workFolder: z.string(),
148
+ model: z.string().optional(),
149
+ session_id: z.string().optional()
150
+ });
151
+
152
+ try {
153
+ schema.parse({});
154
+ } catch (error: any) {
155
+ // Both prompt and workFolder are required
156
+ expect(error.errors.length).toBe(2);
157
+ expect(error.errors.some((e: any) => e.path[0] === 'prompt')).toBe(true);
158
+ expect(error.errors.some((e: any) => e.path[0] === 'workFolder')).toBe(true);
159
+ }
160
+ });
161
+
162
+ it('should allow optional fields to be undefined', async () => {
163
+ const schema = z.object({
164
+ prompt: z.string(),
165
+ workFolder: z.string(),
166
+ model: z.string().optional(),
167
+ session_id: z.string().optional()
168
+ });
169
+
170
+ const result = schema.parse({ prompt: 'test', workFolder: '/tmp' });
171
+ expect(result.model).toBeUndefined();
172
+ expect(result.session_id).toBeUndefined();
173
+ });
174
+
175
+ it('should handle extra fields gracefully', async () => {
176
+ const schema = z.object({
177
+ prompt: z.string(),
178
+ workFolder: z.string(),
179
+ model: z.string().optional(),
180
+ session_id: z.string().optional()
181
+ });
182
+
183
+ // By default, Zod strips unknown keys
184
+ const result = schema.parse({
185
+ prompt: 'test',
186
+ workFolder: '/tmp',
187
+ extraField: 'ignored'
188
+ });
189
+
190
+ expect(result).toEqual({ prompt: 'test', workFolder: '/tmp' });
191
+ expect(result).not.toHaveProperty('extraField');
192
+ });
193
+ });
194
+
195
+ describe('Runtime Argument Validation', () => {
196
+ it('should validate workFolder is a string when provided', async () => {
197
+ mockHomedir.mockReturnValue('/home/user');
198
+ mockExistsSync.mockReturnValue(true);
199
+ setupServerMock();
200
+ const module = await import('../server.js');
201
+ // @ts-ignore
202
+ const { ClaudeCodeServer } = module;
203
+
204
+ const server = new ClaudeCodeServer();
205
+ const mockServerInstance = vi.mocked(Server).mock.results[0].value;
206
+
207
+ const callToolCall = mockServerInstance.setRequestHandler.mock.calls.find(
208
+ (call: any[]) => call[0].name === 'callTool'
209
+ );
210
+
211
+ const handler = callToolCall[1];
212
+
213
+ // Test with non-string workFolder
214
+ await expect(
215
+ handler({
216
+ params: {
217
+ name: 'claude_code',
218
+ arguments: {
219
+ prompt: 'test',
220
+ workFolder: 123 // Invalid type
221
+ }
222
+ }
223
+ })
224
+ ).rejects.toThrow();
225
+ });
226
+
227
+ it('should reject empty string prompt', async () => {
228
+ mockHomedir.mockReturnValue('/home/user');
229
+ mockExistsSync.mockReturnValue(true);
230
+ setupServerMock();
231
+ const module = await import('../server.js');
232
+ // @ts-ignore
233
+ const { ClaudeCodeServer } = module;
234
+
235
+ const server = new ClaudeCodeServer();
236
+ const mockServerInstance = vi.mocked(Server).mock.results[0].value;
237
+
238
+ const callToolCall = mockServerInstance.setRequestHandler.mock.calls.find(
239
+ (call: any[]) => call[0].name === 'callTool'
240
+ );
241
+
242
+ const handler = callToolCall[1];
243
+
244
+ // Empty string prompt should be rejected
245
+ await expect(
246
+ handler({
247
+ params: {
248
+ name: 'claude_code',
249
+ arguments: {
250
+ prompt: '', // Empty prompt
251
+ workFolder: '/tmp'
252
+ }
253
+ }
254
+ })
255
+ ).rejects.toThrow('Missing or invalid required parameter: prompt');
256
+ });
257
+ });
258
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { MCPTestClient } from './utils/mcp-client.js';
6
+ import { getSharedMock } from './utils/persistent-mock.js';
7
+
8
+ describe('Version Print on First Use', () => {
9
+ let client: MCPTestClient;
10
+ let testDir: string;
11
+ let consoleErrorSpy: any;
12
+ const serverPath = 'dist/server.js';
13
+
14
+ beforeEach(async () => {
15
+ // Ensure mock exists
16
+ await getSharedMock();
17
+
18
+ // Create a temporary directory for test files
19
+ testDir = mkdtempSync(join(tmpdir(), 'claude-code-test-'));
20
+
21
+ // Spy on console.error
22
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
23
+
24
+ // Initialize MCP client with custom binary name using absolute path
25
+ client = new MCPTestClient(serverPath, {
26
+ CLAUDE_CLI_NAME: '/tmp/claude-code-test-mock/claudeMocked',
27
+ });
28
+
29
+ await client.connect();
30
+ });
31
+
32
+ afterEach(async () => {
33
+ // Disconnect client
34
+ await client.disconnect();
35
+
36
+ // Clean up test directory
37
+ rmSync(testDir, { recursive: true, force: true });
38
+
39
+ // Restore console.error spy
40
+ consoleErrorSpy.mockRestore();
41
+ });
42
+
43
+ it('should print version and startup time only on first use', async () => {
44
+ // First tool call
45
+ await client.callTool('claude_code', {
46
+ prompt: 'echo "test 1"',
47
+ workFolder: testDir,
48
+ });
49
+
50
+ // Find the version print in the console.error calls
51
+ const findVersionCall = (calls: any[][]) => {
52
+ return calls.find(call => {
53
+ const str = call[1] || call[0]; // message might be first or second param
54
+ return typeof str === 'string' && str.includes('claude_code v') && str.includes('started at');
55
+ });
56
+ };
57
+
58
+ // Check that version was printed on first use
59
+ const versionCall = findVersionCall(consoleErrorSpy.mock.calls);
60
+ expect(versionCall).toBeDefined();
61
+ expect(versionCall![1]).toMatch(/claude_code v[0-9]+\.[0-9]+\.[0-9]+ started at \d{4}-\d{2}-\d{2}T/);
62
+
63
+ // Clear the spy but keep the spy active
64
+ consoleErrorSpy.mockClear();
65
+
66
+ // Second tool call
67
+ await client.callTool('claude_code', {
68
+ prompt: 'echo "test 2"',
69
+ workFolder: testDir,
70
+ });
71
+
72
+ // Check that version was NOT printed on second use
73
+ const secondVersionCall = findVersionCall(consoleErrorSpy.mock.calls);
74
+ expect(secondVersionCall).toBeUndefined();
75
+
76
+ // Third tool call
77
+ await client.callTool('claude_code', {
78
+ prompt: 'echo "test 3"',
79
+ workFolder: testDir,
80
+ });
81
+
82
+ // Should still not have been called with version print
83
+ const thirdVersionCall = findVersionCall(consoleErrorSpy.mock.calls);
84
+ expect(thirdVersionCall).toBeUndefined();
85
+ });
86
+ });
package/src/parsers.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { debugLog } from './server.js';
2
+
3
+ /**
4
+ * Parse Codex NDJSON output to extract the last agent message and token count
5
+ */
6
+ export function parseCodexOutput(stdout: string): any {
7
+ if (!stdout) return null;
8
+
9
+ try {
10
+ const lines = stdout.trim().split('\n');
11
+ let lastMessage = null;
12
+ let tokenCount = null;
13
+
14
+ for (const line of lines) {
15
+ if (line.trim()) {
16
+ try {
17
+ const parsed = JSON.parse(line);
18
+ if (parsed.msg?.type === 'agent_message') {
19
+ lastMessage = parsed.msg.message;
20
+ } else if (parsed.msg?.type === 'token_count') {
21
+ tokenCount = parsed.msg;
22
+ }
23
+ } catch (e) {
24
+ // Skip invalid JSON lines
25
+ debugLog(`[Debug] Skipping invalid JSON line: ${line}`);
26
+ }
27
+ }
28
+ }
29
+
30
+ if (lastMessage || tokenCount) {
31
+ return {
32
+ message: lastMessage,
33
+ token_count: tokenCount
34
+ };
35
+ }
36
+ } catch (e) {
37
+ debugLog(`[Debug] Failed to parse Codex NDJSON output: ${e}`);
38
+ }
39
+
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Parse Claude JSON output
45
+ */
46
+ export function parseClaudeOutput(stdout: string): any {
47
+ if (!stdout) return null;
48
+
49
+ try {
50
+ return JSON.parse(stdout);
51
+ } catch (e) {
52
+ debugLog(`[Debug] Failed to parse Claude JSON output: ${e}`);
53
+ return null;
54
+ }
55
+ }