diff --git a/src/steps/add-mcp-server-to-clients/clients/__tests__/codex.test.ts b/src/steps/add-mcp-server-to-clients/clients/__tests__/codex.test.ts index 8256997d7..80ea31235 100644 --- a/src/steps/add-mcp-server-to-clients/clients/__tests__/codex.test.ts +++ b/src/steps/add-mcp-server-to-clients/clients/__tests__/codex.test.ts @@ -166,9 +166,19 @@ describe('CodexMCPClient', () => { }); describe('supportsPlugin', () => { - it('returns true when codex is in PATH', () => { + it('returns true when codex knows the plugin marketplace subcommand', () => { + spawnSyncMock.mockReturnValue({ + status: 0, + stdout: 'Usage: codex plugin marketplace ', + stderr: '', + }); const client = new CodexMCPClient(); expect(client.supportsPlugin()).toBe(true); + expect(spawnSyncMock).toHaveBeenCalledWith( + CODEX_PATH, + ['plugin', 'marketplace', '--help'], + { encoding: 'utf-8' }, + ); }); it('returns false when codex binary is not found', () => { @@ -178,6 +188,29 @@ describe('CodexMCPClient', () => { const client = new CodexMCPClient(); expect(client.supportsPlugin()).toBe(false); }); + + it('returns false when codex is too old to know the marketplace subcommand', () => { + spawnSyncMock.mockReturnValue({ + status: 2, + stdout: '', + stderr: "error: unexpected argument 'marketplace' found", + }); + const client = new CodexMCPClient(); + expect(client.supportsPlugin()).toBe(false); + }); + + it('caches the feature-detection result across calls', () => { + spawnSyncMock.mockReturnValue({ + status: 0, + stdout: 'Usage: codex plugin marketplace ', + stderr: '', + }); + const client = new CodexMCPClient(); + expect(client.supportsPlugin()).toBe(true); + expect(client.supportsPlugin()).toBe(true); + // Only one probe despite two calls. + expect(spawnSyncMock).toHaveBeenCalledTimes(1); + }); }); describe('installPlugin', () => { @@ -219,5 +252,16 @@ describe('CodexMCPClient', () => { }), ); }); + + it('skips cleanly without capturing an exception on older Codex versions', async () => { + spawnSyncMock.mockReturnValue({ + status: 2, + stdout: '', + stderr: "error: unexpected argument 'marketplace' found", + }); + const client = new CodexMCPClient(); + await expect(client.installPlugin()).resolves.toEqual({ success: false }); + expect(analytics.captureException).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/steps/add-mcp-server-to-clients/clients/codex.ts b/src/steps/add-mcp-server-to-clients/clients/codex.ts index 4c06a6bc9..20f5af4d8 100644 --- a/src/steps/add-mcp-server-to-clients/clients/codex.ts +++ b/src/steps/add-mcp-server-to-clients/clients/codex.ts @@ -23,6 +23,7 @@ export type CodexMCPConfig = z.infer; export class CodexMCPClient extends DefaultMCPClient implements PluginCapable { name = 'Codex'; private codexBinaryPath: string | null = null; + private pluginMarketplaceSupported: boolean | null = null; constructor() { super(); @@ -111,8 +112,40 @@ export class CodexMCPClient extends DefaultMCPClient implements PluginCapable { return Promise.resolve({ success: true }); } + /** + * Codex CLI versions that predate the `plugin marketplace` subcommand parse + * `plugin` as a prompt and reject the rest of the arguments, e.g. + * `error: unexpected argument 'marketplace' found`. Detect that shape so we + * can treat those versions as "plugin not supported" instead of a hard error. + */ + private isPluginUnsupportedOutput(output: string): boolean { + const lower = output.toLowerCase(); + return ( + lower.includes('unexpected argument') || + lower.includes('unrecognized subcommand') + ); + } + supportsPlugin(): boolean { - return this.findCodexBinary() !== null; + const binary = this.findCodexBinary(); + if (!binary) return false; + + if (this.pluginMarketplaceSupported !== null) { + return this.pluginMarketplaceSupported; + } + + // Feature-detect the `plugin marketplace` subcommand before we try to use + // it. Old Codex versions don't know it and would fail the install outright. + const result = spawnSync(binary, ['plugin', 'marketplace', '--help'], { + encoding: 'utf-8', + }); + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + this.pluginMarketplaceSupported = + result.error == null && + result.status === 0 && + !this.isPluginUnsupportedOutput(output); + + return this.pluginMarketplaceSupported; } isPluginInstalled(): Promise { @@ -160,6 +193,14 @@ export class CodexMCPClient extends DefaultMCPClient implements PluginCapable { } if (result.status !== 0) { + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + // Older Codex versions lack the `plugin marketplace` subcommand and + // reject the arguments (e.g. "unexpected argument 'marketplace' found"). + // Skip cleanly rather than surfacing a hard error. + if (this.isPluginUnsupportedOutput(output)) { + this.pluginMarketplaceSupported = false; + return Promise.resolve({ success: false }); + } analytics.captureException( new Error(`Codex plugin install failed: ${result.stderr ?? ''}`), );