Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { ClaudeCodeMCPClient } from '../claude-code';

jest.mock('child_process', () => ({
execSync: jest.fn(),
spawnSync: jest.fn(),
}));

jest.mock('fs', () => ({
existsSync: jest.fn().mockReturnValue(false),
rmSync: jest.fn(),
}));

jest.mock('../../../../utils/analytics', () => ({
Expand All @@ -17,9 +19,11 @@ jest.mock('../../../../utils/debug', () => ({
}));

describe('ClaudeCodeMCPClient — plugin methods', () => {
const { execSync } = require('child_process');
const { execSync, spawnSync } = require('child_process');
const fs = require('fs');
const { analytics } = require('../../../../utils/analytics');
const execSyncMock = execSync as jest.Mock;
const spawnSyncMock = spawnSync as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
Expand All @@ -28,6 +32,8 @@ describe('ClaudeCodeMCPClient — plugin methods', () => {
if (cmd === 'command -v claude') return Buffer.from('');
return Buffer.from('');
});
// Default: marketplace add succeeds
spawnSyncMock.mockReturnValue({ status: 0, stderr: '' });
});

describe('supportsPlugin', () => {
Expand Down Expand Up @@ -79,12 +85,96 @@ describe('ClaudeCodeMCPClient — plugin methods', () => {
});

describe('installPlugin', () => {
it('registers the PostHog marketplace before installing the plugin', async () => {
execSyncMock.mockImplementation(() => Buffer.from(''));
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({ success: true });
expect(spawnSyncMock).toHaveBeenCalledWith(
'claude',
['plugin', 'marketplace', 'add', 'PostHog/ai-plugin'],
{ encoding: 'utf-8' },
);
expect(execSyncMock).toHaveBeenCalledWith(
'claude plugin install posthog',
{ stdio: 'pipe' },
);
});

it('returns success on exit 0', async () => {
execSyncMock.mockImplementation(() => Buffer.from(''));
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({ success: true });
});

it('continues to install when marketplace add reports already added', async () => {
spawnSyncMock.mockReturnValue({
status: 1,
stderr: "marketplace 'posthog' already added",
});
execSyncMock.mockImplementation(() => Buffer.from(''));
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({ success: true });
expect(execSyncMock).toHaveBeenCalledWith(
'claude plugin install posthog',
{ stdio: 'pipe' },
);
expect(analytics.captureException).not.toHaveBeenCalled();
});

it('clears stale cache and retries when marketplace was already added from a different source', async () => {
spawnSyncMock
.mockReturnValueOnce({
status: 1,
stderr:
"Error: marketplace 'posthog' is already added from a different source",
})
.mockReturnValueOnce({ status: 0, stderr: '' });
execSyncMock.mockImplementation(() => Buffer.from(''));
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({ success: true });
expect(fs.rmSync).toHaveBeenCalledWith(
expect.stringContaining('marketplaces/posthog'),
{ recursive: true, force: true },
);
expect(spawnSyncMock).toHaveBeenCalledTimes(2);
});

it('surfaces a clearer message when the marketplace config file is corrupted', async () => {
spawnSyncMock.mockReturnValue({
status: 1,
stderr:
'Marketplace configuration file is corrupted: local.source.source: Invalid input',
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({ success: false });
expect(analytics.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('marketplace config is corrupted'),
}),
);
// Install must not run if the marketplace could not be registered
expect(execSyncMock).not.toHaveBeenCalledWith(
expect.stringContaining('plugin install'),
expect.anything(),
);
});

it('captures a marketplace add failure when stderr is some other error', async () => {
spawnSyncMock.mockReturnValue({
status: 1,
stderr: 'network unreachable',
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({ success: false });
expect(analytics.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining(
'Claude Code marketplace add failed',
),
}),
);
});

it('returns success with alreadyInstalled when stderr contains "already installed"', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('plugin install')) {
Expand Down
55 changes: 54 additions & 1 deletion src/steps/add-mcp-server-to-clients/clients/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { DefaultMCPClient } from '../MCPClient';
import { DefaultMCPClientConfig } from '../defaults';
import { PluginCapable, PluginInstallResult } from '../plugin-client';
import { z } from 'zod';
import { execSync } from 'child_process';
import { execSync, spawnSync } from 'child_process';
import { analytics } from '../../../utils/analytics';
import { debug } from '../../../utils/debug';
import * as os from 'os';
Expand Down Expand Up @@ -143,6 +143,59 @@ export class ClaudeCodeMCPClient
installPlugin(): Promise<PluginInstallResult> {
const binary = this.findClaudeBinary();
if (!binary) return Promise.resolve({ success: false });

// `plugin install posthog` fails with `not found in any configured
// marketplace` unless the PostHog marketplace is registered first.
const runMarketplaceAdd = () =>
spawnSync(binary, ['plugin', 'marketplace', 'add', 'PostHog/ai-plugin'], {
encoding: 'utf-8',
});

let marketplaceResult = runMarketplaceAdd();

// Stale cache directory with no marketplace entry — clear it and retry
if (
marketplaceResult.status !== 0 &&
(marketplaceResult.stderr ?? '').includes(
'already added from a different source',
)
) {
const staleDir = path.join(
os.homedir(),
'.claude',
'plugins',
'marketplaces',
'posthog',
);
try {
fs.rmSync(staleDir, { recursive: true, force: true });
} catch {
// ignore — retry anyway
}
marketplaceResult = runMarketplaceAdd();
}

if (marketplaceResult.status !== 0) {
const stderr = marketplaceResult.stderr ?? '';
const alreadyAdded =
stderr.includes('already added') || stderr.includes('already exists');

if (!alreadyAdded) {
if (stderr.includes('Marketplace configuration file is corrupted')) {
analytics.captureException(
new Error(
`Claude Code marketplace config is corrupted — user must repair ~/.claude/plugins/ config before retrying: ${stderr}`,
),
);
} else {
analytics.captureException(
new Error(`Claude Code marketplace add failed: ${stderr}`),
);
}
return Promise.resolve({ success: false });
}
}

try {
execSync(`${binary} plugin install posthog`, { stdio: 'pipe' });
return Promise.resolve({ success: true });
Expand Down
Loading