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 @@ -21,13 +21,36 @@ describe('ClaudeCodeMCPClient — plugin methods', () => {
const { analytics } = require('../../../../utils/analytics');
const execSyncMock = execSync as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
// Make binary discoverable via PATH by default
// Default version returned by --version. Above MIN_PLUGIN_VERSION (1.0.88).
const SUPPORTED_VERSION_OUTPUT = '1.0.123 (Claude Code)\n';

// Wire up execSync with a handler that returns sane defaults for each
// command the client may issue, plus a per-test override map for the
// commands we want to control. `overrides` maps a substring of the
// command to either a Buffer to return, or an Error to throw.
function setupExec(overrides: Record<string, Buffer | Error> = {}): void {
execSyncMock.mockImplementation((cmd: string) => {
if (cmd === 'command -v claude') return Buffer.from('');
const str = String(cmd);
for (const [substr, value] of Object.entries(overrides)) {
if (str.includes(substr)) {
if (value instanceof Error) throw value;
return value;
}
}
if (str === 'command -v claude') return Buffer.from('');
if (str.includes('--version')) {
return Buffer.from(SUPPORTED_VERSION_OUTPUT);
}
if (str.includes('plugin marketplace add')) return Buffer.from('');
if (str.includes('plugin install')) return Buffer.from('');
if (str.includes('plugin list')) return Buffer.from('');
return Buffer.from('');
});
}

beforeEach(() => {
jest.clearAllMocks();
setupExec();
});

describe('supportsPlugin', () => {
Expand All @@ -47,79 +70,138 @@ describe('ClaudeCodeMCPClient — plugin methods', () => {

describe('isPluginInstalled', () => {
it('returns true when posthog appears in plugin list output', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (cmd === 'command -v claude') return Buffer.from('');
if (String(cmd).includes('plugin list'))
return Buffer.from('posthog 1.0.0\n');
return Buffer.from('');
});
setupExec({ 'plugin list': Buffer.from('posthog 1.0.0\n') });
const client = new ClaudeCodeMCPClient();
await expect(client.isPluginInstalled()).resolves.toBe(true);
});

it('returns false when posthog is absent from plugin list output', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (cmd === 'command -v claude') return Buffer.from('');
if (String(cmd).includes('plugin list'))
return Buffer.from('other-plugin 2.0.0\n');
return Buffer.from('');
});
setupExec({ 'plugin list': Buffer.from('other-plugin 2.0.0\n') });
const client = new ClaudeCodeMCPClient();
await expect(client.isPluginInstalled()).resolves.toBe(false);
});

it('returns false when plugin list command throws', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (cmd === 'command -v claude') return Buffer.from('');
throw new Error('command failed');
});
setupExec({ 'plugin list': new Error('command failed') });
const client = new ClaudeCodeMCPClient();
await expect(client.isPluginInstalled()).resolves.toBe(false);
});
});

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

const commands = execSyncMock.mock.calls.map((c) => String(c[0]));
const marketplaceCall = commands.find((c) =>
c.includes('plugin marketplace add'),
);
const installCall = commands.find((c) => c.includes('plugin install'));
expect(marketplaceCall).toBeDefined();
expect(marketplaceCall).toContain('PostHog/ai-plugin');
expect(installCall).toBeDefined();
expect(installCall).toMatch(/plugin install posthog/);
// Marketplace registration must precede install.
expect(commands.indexOf(marketplaceCall!)).toBeLessThan(
commands.indexOf(installCall!),
);
});

it('returns success with alreadyInstalled when stderr contains "already installed"', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('plugin install')) {
throw new Error('already installed');
}
return Buffer.from('');
it('tolerates an "already added" marketplace and still installs', async () => {
setupExec({
'plugin marketplace add': new Error(
"marketplace 'posthog' is already added",
),
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({ success: true });
expect(analytics.captureException).not.toHaveBeenCalled();
});

it('returns success with alreadyInstalled when install reports "already installed"', async () => {
setupExec({ 'plugin install': new Error('already installed') });
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({
success: true,
alreadyInstalled: true,
});
});

it('returns success with alreadyInstalled when stderr contains "already exists"', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('plugin install')) {
throw new Error('already exists');
}
return Buffer.from('');
});
it('returns success with alreadyInstalled when install reports "already exists"', async () => {
setupExec({ 'plugin install': new Error('already exists') });
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({
success: true,
alreadyInstalled: true,
});
});

it('returns failure and captures exception on unexpected error', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (String(cmd).includes('plugin install')) {
throw new Error('network timeout');
}
return Buffer.from('');
it('returns unsupported (and skips marketplace add) when --version is below 1.0.88', async () => {
setupExec({ '--version': Buffer.from('1.0.35 (Claude Code)\n') });
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({
success: false,
unsupported: true,
});
const commands = execSyncMock.mock.calls.map((c) => String(c[0]));
expect(commands.some((c) => c.includes('plugin marketplace'))).toBe(
false,
);
expect(commands.some((c) => c.includes('plugin install'))).toBe(false);
expect(analytics.captureException).not.toHaveBeenCalled();
});

it('returns unsupported when --version output cannot be parsed', async () => {
setupExec({ '--version': Buffer.from('garbled output\n') });
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({
success: false,
unsupported: true,
});
expect(analytics.captureException).not.toHaveBeenCalled();
});

it('returns unsupported on "unknown command" without reporting an exception', async () => {
setupExec({
'plugin install': new Error("error: unknown command 'install'"),
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({
success: false,
unsupported: true,
});
expect(analytics.captureException).not.toHaveBeenCalled();
});

it('returns unsupported on "needs an update" without reporting an exception', async () => {
setupExec({
'plugin install': new Error('Claude Code needs an update to >=1.0.88'),
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({
success: false,
unsupported: true,
});
expect(analytics.captureException).not.toHaveBeenCalled();
});

it('returns unsupported on "not found in any configured marketplace" without reporting', async () => {
setupExec({
'plugin install': new Error(
'Plugin "posthog" not found in any configured marketplace',
),
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({
success: false,
unsupported: true,
});
expect(analytics.captureException).not.toHaveBeenCalled();
});

it('returns failure and captures exception on unexpected install error', async () => {
setupExec({ 'plugin install': new Error('network timeout') });
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({ success: false });
expect(analytics.captureException).toHaveBeenCalledWith(
Expand All @@ -129,6 +211,19 @@ describe('ClaudeCodeMCPClient — plugin methods', () => {
);
});

it('returns failure and captures exception on unexpected marketplace error', async () => {
setupExec({
'plugin marketplace add': new Error('network unreachable'),
});
const client = new ClaudeCodeMCPClient();
await expect(client.installPlugin()).resolves.toEqual({ success: false });
expect(analytics.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('network unreachable'),
}),
);
});

it('returns failure when no binary is found', async () => {
execSyncMock.mockImplementation(() => {
throw new Error('not found');
Expand Down
76 changes: 76 additions & 0 deletions src/steps/add-mcp-server-to-clients/clients/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,36 @@ export const ClaudeCodeMCPConfig = DefaultMCPClientConfig;

export type ClaudeCodeMCPConfig = z.infer<typeof DefaultMCPClientConfig>;

// Plugin subcommand and the marketplace flow both require Claude Code >= 1.0.88.
const MIN_PLUGIN_VERSION: readonly [number, number, number] = [1, 0, 88];
const POSTHOG_MARKETPLACE = 'PostHog/ai-plugin';

function parseSemver(s: string): [number, number, number] | null {
const m = s.match(/(\d+)\.(\d+)\.(\d+)/);
if (!m) return null;
return [Number(m[1]), Number(m[2]), Number(m[3])];
}

function isAtLeast(
v: [number, number, number],
min: readonly [number, number, number],
): boolean {
if (v[0] !== min[0]) return v[0] > min[0];
if (v[1] !== min[1]) return v[1] > min[1];
return v[2] >= min[2];
}

// Errors that mean "this Claude Code can't install the plugin" — not a bug to report.
function isUnsupportedPluginError(msg: string): boolean {
return (
msg.includes("unknown command 'install'") ||
msg.includes("unknown command 'plugin'") ||
msg.includes('unknown command') ||
msg.includes('needs an update') ||
msg.includes('not found in any configured marketplace')
);
}

export class ClaudeCodeMCPClient
extends DefaultMCPClient
implements PluginCapable
Expand Down Expand Up @@ -140,9 +170,52 @@ export class ClaudeCodeMCPClient
}
}

private getVersion(binary: string): [number, number, number] | null {
try {
const out = execSync(`${binary} --version`, { stdio: 'pipe' }).toString();
return parseSemver(out);
} catch {
return null;
}
}

private addMarketplace(binary: string): PluginInstallResult | null {
try {
execSync(`${binary} plugin marketplace add ${POSTHOG_MARKETPLACE}`, {
stdio: 'pipe',
});
return null;
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
// Re-running after a successful add — fine.
if (msg.toLowerCase().includes('already')) return null;
if (isUnsupportedPluginError(msg)) {
return { success: false, unsupported: true };
}
analytics.captureException(
new Error(`Claude Code plugin marketplace add failed: ${msg}`),
);
return { success: false };
}
}

installPlugin(): Promise<PluginInstallResult> {
const binary = this.findClaudeBinary();
if (!binary) return Promise.resolve({ success: false });

const version = this.getVersion(binary);
if (!version || !isAtLeast(version, MIN_PLUGIN_VERSION)) {
debug(
` Claude Code ${
version ? version.join('.') : 'version unknown'
} is below ${MIN_PLUGIN_VERSION.join('.')} — skipping plugin install`,
);
return Promise.resolve({ success: false, unsupported: true });
}

const marketplaceFailure = this.addMarketplace(binary);
if (marketplaceFailure) return Promise.resolve(marketplaceFailure);

try {
execSync(`${binary} plugin install posthog`, { stdio: 'pipe' });
return Promise.resolve({ success: true });
Expand All @@ -151,6 +224,9 @@ export class ClaudeCodeMCPClient
if (msg.includes('already installed') || msg.includes('already exists')) {
return Promise.resolve({ success: true, alreadyInstalled: true });
}
if (isUnsupportedPluginError(msg)) {
return Promise.resolve({ success: false, unsupported: true });
}
analytics.captureException(
new Error(`Claude Code plugin install failed: ${msg}`),
);
Expand Down
2 changes: 2 additions & 0 deletions src/steps/add-mcp-server-to-clients/plugin-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export interface PluginInstallResult {
success: boolean;
alreadyInstalled?: boolean;
/** Client binary is too old or otherwise can't accept the plugin. Not an error worth reporting. */
unsupported?: boolean;
}

export interface PluginCapable {
Expand Down
Loading