Skip to content

Commit 557ec08

Browse files
authored
chore: plugin interface for claude and codex (#428)
1 parent 8008343 commit 557ec08

11 files changed

Lines changed: 608 additions & 200 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { ClaudeCodeMCPClient } from '../claude-code';
2+
3+
jest.mock('child_process', () => ({
4+
execSync: jest.fn(),
5+
}));
6+
7+
jest.mock('fs', () => ({
8+
existsSync: jest.fn().mockReturnValue(false),
9+
}));
10+
11+
jest.mock('../../../../utils/analytics', () => ({
12+
analytics: { captureException: jest.fn() },
13+
}));
14+
15+
jest.mock('../../../../utils/debug', () => ({
16+
debug: jest.fn(),
17+
}));
18+
19+
describe('ClaudeCodeMCPClient — plugin methods', () => {
20+
const { execSync } = require('child_process');
21+
const { analytics } = require('../../../../utils/analytics');
22+
const execSyncMock = execSync as jest.Mock;
23+
24+
beforeEach(() => {
25+
jest.clearAllMocks();
26+
// Make binary discoverable via PATH by default
27+
execSyncMock.mockImplementation((cmd: string) => {
28+
if (cmd === 'command -v claude') return Buffer.from('');
29+
return Buffer.from('');
30+
});
31+
});
32+
33+
describe('supportsPlugin', () => {
34+
it('returns true when claude binary is found', () => {
35+
const client = new ClaudeCodeMCPClient();
36+
expect(client.supportsPlugin()).toBe(true);
37+
});
38+
39+
it('returns false when no binary is found', () => {
40+
execSyncMock.mockImplementation(() => {
41+
throw new Error('not found');
42+
});
43+
const client = new ClaudeCodeMCPClient();
44+
expect(client.supportsPlugin()).toBe(false);
45+
});
46+
});
47+
48+
describe('isPluginInstalled', () => {
49+
it('returns true when posthog appears in plugin list output', async () => {
50+
execSyncMock.mockImplementation((cmd: string) => {
51+
if (cmd === 'command -v claude') return Buffer.from('');
52+
if (String(cmd).includes('plugin list'))
53+
return Buffer.from('posthog 1.0.0\n');
54+
return Buffer.from('');
55+
});
56+
const client = new ClaudeCodeMCPClient();
57+
await expect(client.isPluginInstalled()).resolves.toBe(true);
58+
});
59+
60+
it('returns false when posthog is absent from plugin list output', async () => {
61+
execSyncMock.mockImplementation((cmd: string) => {
62+
if (cmd === 'command -v claude') return Buffer.from('');
63+
if (String(cmd).includes('plugin list'))
64+
return Buffer.from('other-plugin 2.0.0\n');
65+
return Buffer.from('');
66+
});
67+
const client = new ClaudeCodeMCPClient();
68+
await expect(client.isPluginInstalled()).resolves.toBe(false);
69+
});
70+
71+
it('returns false when plugin list command throws', async () => {
72+
execSyncMock.mockImplementation((cmd: string) => {
73+
if (cmd === 'command -v claude') return Buffer.from('');
74+
throw new Error('command failed');
75+
});
76+
const client = new ClaudeCodeMCPClient();
77+
await expect(client.isPluginInstalled()).resolves.toBe(false);
78+
});
79+
});
80+
81+
describe('installPlugin', () => {
82+
it('returns success on exit 0', async () => {
83+
execSyncMock.mockImplementation(() => Buffer.from(''));
84+
const client = new ClaudeCodeMCPClient();
85+
await expect(client.installPlugin()).resolves.toEqual({ success: true });
86+
});
87+
88+
it('returns success with alreadyInstalled when stderr contains "already installed"', async () => {
89+
execSyncMock.mockImplementation((cmd: string) => {
90+
if (String(cmd).includes('plugin install')) {
91+
throw new Error('already installed');
92+
}
93+
return Buffer.from('');
94+
});
95+
const client = new ClaudeCodeMCPClient();
96+
await expect(client.installPlugin()).resolves.toEqual({
97+
success: true,
98+
alreadyInstalled: true,
99+
});
100+
});
101+
102+
it('returns success with alreadyInstalled when stderr contains "already exists"', async () => {
103+
execSyncMock.mockImplementation((cmd: string) => {
104+
if (String(cmd).includes('plugin install')) {
105+
throw new Error('already exists');
106+
}
107+
return Buffer.from('');
108+
});
109+
const client = new ClaudeCodeMCPClient();
110+
await expect(client.installPlugin()).resolves.toEqual({
111+
success: true,
112+
alreadyInstalled: true,
113+
});
114+
});
115+
116+
it('returns failure and captures exception on unexpected error', async () => {
117+
execSyncMock.mockImplementation((cmd: string) => {
118+
if (String(cmd).includes('plugin install')) {
119+
throw new Error('network timeout');
120+
}
121+
return Buffer.from('');
122+
});
123+
const client = new ClaudeCodeMCPClient();
124+
await expect(client.installPlugin()).resolves.toEqual({ success: false });
125+
expect(analytics.captureException).toHaveBeenCalledWith(
126+
expect.objectContaining({
127+
message: expect.stringContaining('network timeout'),
128+
}),
129+
);
130+
});
131+
132+
it('returns failure when no binary is found', async () => {
133+
execSyncMock.mockImplementation(() => {
134+
throw new Error('not found');
135+
});
136+
const client = new ClaudeCodeMCPClient();
137+
await expect(client.installPlugin()).resolves.toEqual({ success: false });
138+
});
139+
});
140+
});

src/steps/add-mcp-server-to-clients/clients/__tests__/codex.test.ts

Lines changed: 105 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -5,141 +5,175 @@ jest.mock('node:child_process', () => ({
55
spawnSync: jest.fn(),
66
}));
77

8+
jest.mock('node:fs', () => ({
9+
existsSync: jest.fn(),
10+
readFileSync: jest.fn(),
11+
rmSync: jest.fn(),
12+
}));
13+
814
jest.mock('../../../../utils/analytics', () => ({
9-
analytics: {
10-
captureException: jest.fn(),
11-
},
15+
analytics: { captureException: jest.fn() },
1216
}));
1317

1418
describe('CodexMCPClient', () => {
1519
const { execSync, spawnSync } = require('node:child_process');
20+
const fs = require('node:fs');
1621
const analytics = require('../../../../utils/analytics').analytics;
1722

1823
const spawnSyncMock = spawnSync as jest.Mock;
1924
const execSyncMock = execSync as jest.Mock;
25+
const readFileSyncMock = fs.readFileSync as jest.Mock;
26+
27+
const CODEX_PATH = '/usr/local/bin/codex';
2028

2129
beforeEach(() => {
2230
jest.clearAllMocks();
31+
// Default: codex found via command -v
32+
execSyncMock.mockReturnValue(Buffer.from(CODEX_PATH + '\n'));
2333
});
2434

2535
describe('isClientSupported', () => {
26-
it('returns true when codex binary is available', async () => {
27-
execSyncMock.mockReturnValue(undefined);
28-
36+
it('returns true when codex is in PATH', async () => {
2937
const client = new CodexMCPClient();
3038
await expect(client.isClientSupported()).resolves.toBe(true);
31-
expect(execSyncMock).toHaveBeenCalledWith('codex --version', {
32-
stdio: 'ignore',
39+
expect(execSyncMock).toHaveBeenCalledWith('command -v codex', {
40+
stdio: 'pipe',
3341
});
3442
});
3543

36-
it('returns false when codex binary is missing', async () => {
44+
it('returns false when codex is not in PATH', async () => {
3745
execSyncMock.mockImplementation(() => {
3846
throw new Error('not found');
3947
});
40-
4148
const client = new CodexMCPClient();
4249
await expect(client.isClientSupported()).resolves.toBe(false);
4350
});
4451
});
4552

46-
describe('isServerInstalled', () => {
47-
it('returns true when posthog server exists', async () => {
48-
spawnSyncMock.mockReturnValue({
49-
status: 0,
50-
stdout: JSON.stringify([{ name: 'posthog' }, { name: 'other' }]),
51-
});
53+
describe('isPluginInstalled', () => {
54+
it('returns true when posthog marketplace section exists in config.toml', async () => {
55+
readFileSyncMock.mockReturnValue(
56+
'[marketplaces.posthog]\nsource_type = "git"\n',
57+
);
58+
const client = new CodexMCPClient();
59+
await expect(client.isPluginInstalled()).resolves.toBe(true);
60+
});
5261

62+
it('returns false when posthog is absent from config.toml', async () => {
63+
readFileSyncMock.mockReturnValue(
64+
'[marketplaces.openai-bundled]\nsource_type = "local"\n',
65+
);
5366
const client = new CodexMCPClient();
54-
await expect(client.isServerInstalled()).resolves.toBe(true);
67+
await expect(client.isPluginInstalled()).resolves.toBe(false);
5568
});
5669

57-
it('returns false when command fails', async () => {
58-
spawnSyncMock.mockReturnValue({ status: 1, stdout: '' });
70+
it('returns false when config.toml cannot be read', async () => {
71+
readFileSyncMock.mockImplementation(() => {
72+
throw new Error('ENOENT');
73+
});
74+
const client = new CodexMCPClient();
75+
await expect(client.isPluginInstalled()).resolves.toBe(false);
76+
});
77+
});
5978

79+
describe('isServerInstalled', () => {
80+
it('delegates to isPluginInstalled', async () => {
81+
readFileSyncMock.mockReturnValue(
82+
'[marketplaces.posthog]\nsource_type = "git"\n',
83+
);
6084
const client = new CodexMCPClient();
61-
await expect(client.isServerInstalled()).resolves.toBe(false);
85+
await expect(client.isServerInstalled()).resolves.toBe(true);
6286
});
6387
});
6488

6589
describe('addServer', () => {
66-
it('invokes codex mcp add with --url and --bearer-token-env-var', async () => {
67-
spawnSyncMock.mockReturnValue({ status: 0 });
68-
90+
it('delegates to installPlugin — returns success when plugin installs', async () => {
91+
spawnSyncMock.mockReturnValue({ status: 0, stderr: '' });
6992
const client = new CodexMCPClient();
70-
const result = await client.addServer('phx_example');
93+
await expect(client.addServer()).resolves.toEqual({ success: true });
94+
});
7195

72-
expect(result).toEqual({ success: true });
73-
expect(spawnSyncMock).toHaveBeenCalledWith(
74-
'codex',
75-
[
76-
'mcp',
77-
'add',
78-
'posthog',
79-
'--url',
80-
'https://mcp.posthog.com/mcp',
81-
'--bearer-token-env-var',
82-
'POSTHOG_API_KEY',
83-
],
84-
expect.objectContaining({
85-
stdio: 'ignore',
86-
env: expect.objectContaining({
87-
POSTHOG_API_KEY: 'phx_example',
88-
}),
89-
}),
90-
);
96+
it('delegates to installPlugin — returns failure when plugin fails', async () => {
97+
spawnSyncMock.mockReturnValue({ status: 1, stderr: 'network timeout' });
98+
const client = new CodexMCPClient();
99+
await expect(client.addServer()).resolves.toEqual({ success: false });
91100
});
101+
});
92102

93-
it('omits auth in OAuth mode', async () => {
103+
describe('removeServer', () => {
104+
it('invokes the resolved binary with mcp remove and returns success', async () => {
94105
spawnSyncMock.mockReturnValue({ status: 0 });
95-
96106
const client = new CodexMCPClient();
97-
const result = await client.addServer(undefined);
98-
99-
expect(result).toEqual({ success: true });
107+
await expect(client.removeServer()).resolves.toEqual({ success: true });
100108
expect(spawnSyncMock).toHaveBeenCalledWith(
101-
'codex',
102-
['mcp', 'add', 'posthog', '--url', 'https://mcp.posthog.com/mcp'],
103-
expect.objectContaining({ stdio: 'ignore' }),
109+
CODEX_PATH,
110+
['mcp', 'remove', 'posthog'],
111+
{ stdio: 'ignore' },
104112
);
105113
});
106114

107115
it('returns false and captures exception on failure', async () => {
108116
spawnSyncMock.mockReturnValue({ status: 1 });
109-
110117
const client = new CodexMCPClient();
111-
const result = await client.addServer('phx_example');
112-
113-
expect(result).toEqual({ success: false });
118+
await expect(client.removeServer()).resolves.toEqual({ success: false });
114119
expect(analytics.captureException).toHaveBeenCalled();
115120
});
116121
});
117122

118-
describe('removeServer', () => {
119-
it('invokes codex mcp remove and returns success', async () => {
120-
spawnSyncMock.mockReturnValue({ status: 0 });
123+
describe('supportsPlugin', () => {
124+
it('returns true when codex is in PATH', () => {
125+
const client = new CodexMCPClient();
126+
expect(client.supportsPlugin()).toBe(true);
127+
});
121128

129+
it('returns false when codex binary is not found', () => {
130+
execSyncMock.mockImplementation(() => {
131+
throw new Error('not found');
132+
});
122133
const client = new CodexMCPClient();
123-
const result = await client.removeServer();
134+
expect(client.supportsPlugin()).toBe(false);
135+
});
136+
});
124137

125-
expect(result).toEqual({ success: true });
138+
describe('installPlugin', () => {
139+
it('returns success on exit 0 using resolved binary path', async () => {
140+
spawnSyncMock.mockReturnValue({ status: 0, stderr: '' });
141+
const client = new CodexMCPClient();
142+
await expect(client.installPlugin()).resolves.toEqual({ success: true });
126143
expect(spawnSyncMock).toHaveBeenCalledWith(
127-
'codex',
128-
['mcp', 'remove', 'posthog'],
129-
{
130-
stdio: 'ignore',
131-
},
144+
CODEX_PATH,
145+
['plugin', 'marketplace', 'add', 'PostHog/ai-plugin'],
146+
{ encoding: 'utf-8' },
132147
);
133148
});
134149

135-
it('returns false and captures exception on failure', async () => {
136-
spawnSyncMock.mockReturnValue({ status: 1 });
137-
150+
it('clears stale cache and retries when marketplace is already added from a different source', async () => {
151+
const { rmSync } = require('node:fs');
152+
spawnSyncMock
153+
.mockReturnValueOnce({
154+
status: 1,
155+
stderr:
156+
"Error: marketplace 'posthog' is already added from a different source",
157+
})
158+
.mockReturnValueOnce({ status: 0, stderr: '' });
138159
const client = new CodexMCPClient();
139-
const result = await client.removeServer();
160+
await expect(client.installPlugin()).resolves.toEqual({ success: true });
161+
expect(rmSync).toHaveBeenCalledWith(
162+
expect.stringContaining('marketplaces/posthog'),
163+
{ recursive: true, force: true },
164+
);
165+
expect(spawnSyncMock).toHaveBeenCalledTimes(2);
166+
});
140167

141-
expect(result).toEqual({ success: false });
142-
expect(analytics.captureException).toHaveBeenCalled();
168+
it('returns failure and captures exception on unexpected error', async () => {
169+
spawnSyncMock.mockReturnValue({ status: 1, stderr: 'network timeout' });
170+
const client = new CodexMCPClient();
171+
await expect(client.installPlugin()).resolves.toEqual({ success: false });
172+
expect(analytics.captureException).toHaveBeenCalledWith(
173+
expect.objectContaining({
174+
message: expect.stringContaining('network timeout'),
175+
}),
176+
);
143177
});
144178
});
145179
});

0 commit comments

Comments
 (0)