From 6d31f975c214abe35cbcf28329ef30f04a5cb9c0 Mon Sep 17 00:00:00 2001 From: Nathan Burg Date: Thu, 16 Apr 2026 15:36:50 -0700 Subject: [PATCH] fix: gemini cli detection --- src/commands/init/agent-definitions.test.ts | 123 ++++++++++++++++++++ src/commands/init/agent-definitions.ts | 29 +++-- src/commands/init/init.test.ts | 8 +- 3 files changed, 147 insertions(+), 13 deletions(-) diff --git a/src/commands/init/agent-definitions.test.ts b/src/commands/init/agent-definitions.test.ts index 8c381fef..45c14d19 100644 --- a/src/commands/init/agent-definitions.test.ts +++ b/src/commands/init/agent-definitions.test.ts @@ -271,6 +271,97 @@ describe("detectPaths", () => { expect(await agent.detectBinary!(exec)).toBe(false); }); + it("claude-code detectBinary returns true when binary found", async () => { + const exec = createMockExecService({ + exec: mock(async () => ({ + exitCode: 0, + stdout: "/usr/bin/claude\n", + stderr: "", + })), + }); + const agent = agentDefinitions.find((a) => a.id === "claude-code")!; + expect(await agent.detectBinary!(exec)).toBe(true); + }); + + it("binary detectors use correct command and binary name on linux", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + value: "linux", + configurable: true, + }); + + try { + const testCases = [ + { id: "claude-code", binary: "claude" }, + { id: "codex-cli", binary: "codex" }, + { id: "gemini-cli", binary: "gemini" }, + { id: "opencode", binary: "opencode" }, + ]; + + for (const testCase of testCases) { + const exec = createMockExecService({ + exec: mock(async () => ({ + exitCode: 0, + stdout: "/usr/bin/mock\n", + stderr: "", + })), + }); + const agent = agentDefinitions.find((a) => a.id === testCase.id)!; + expect(await agent.detectBinary!(exec)).toBe(true); + expect(exec.exec).toHaveBeenCalledWith("which", [testCase.binary]); + } + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + } + }); + + it("binary detectors use where on win32", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + + try { + const exec = createMockExecService({ + exec: mock(async () => ({ + exitCode: 0, + stdout: "C:\\Program Files\\Claude\\claude.exe\n", + stderr: "", + })), + }); + const agent = agentDefinitions.find((a) => a.id === "claude-code")!; + expect(await agent.detectBinary!(exec)).toBe(true); + expect(exec.exec).toHaveBeenCalledWith("where", ["claude"]); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + } + }); + + it("claude-code detectBinary returns false when binary not found", async () => { + const exec = createMockExecService({ + exec: mock(async () => ({ exitCode: 1, stdout: "", stderr: "" })), + }); + const agent = agentDefinitions.find((a) => a.id === "claude-code")!; + expect(await agent.detectBinary!(exec)).toBe(false); + }); + + it("claude-code detectBinary returns false on exec error", async () => { + const exec = createMockExecService({ + exec: mock(async () => { + throw new Error("spawn ENOENT"); + }), + }); + const agent = agentDefinitions.find((a) => a.id === "claude-code")!; + expect(await agent.detectBinary!(exec)).toBe(false); + }); + it("all agents use FileSystemService.getHomeDir (not hardcoded)", () => { const originalPlatform = process.platform; const originalAppdata = process.env.APPDATA; @@ -841,6 +932,38 @@ describe("scanAgents", () => { expect(result.notDetected.some((a) => a.id === "opencode")).toBe(false); }); + it("detects codex via detectBinary when directory does not exist", async () => { + const { fs, execService } = createScanMocks({ + detectedDirs: [], + execResults: { + "which codex": { + exitCode: 0, + stdout: "/usr/bin/codex\n", + stderr: "", + }, + }, + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.needsSetup.some((a) => a.id === "codex-cli")).toBe(true); + expect(result.notDetected.some((a) => a.id === "codex-cli")).toBe(false); + }); + + it("falls back to codex directory detection when binary is not on PATH", async () => { + const { fs, execService } = createScanMocks({ + detectedDirs: ["/home/test/.codex"], + execResults: { + "which codex": { + exitCode: 1, + stdout: "", + stderr: "", + }, + }, + }); + const result = await scanAgents(agentDefinitions, fs, execService); + expect(result.needsSetup.some((a) => a.id === "codex-cli")).toBe(true); + expect(result.notDetected.some((a) => a.id === "codex-cli")).toBe(false); + }); + it("detects opencode via directory fallback when binary not on PATH", async () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { diff --git a/src/commands/init/agent-definitions.ts b/src/commands/init/agent-definitions.ts index 6a8509b4..c655681d 100644 --- a/src/commands/init/agent-definitions.ts +++ b/src/commands/init/agent-definitions.ts @@ -85,11 +85,27 @@ function getAppDataPath(fs: FileSystemService, appName: string): string { } } +/** Creates a cross-platform detector that checks if a binary exists on PATH. */ +function createBinaryDetector( + binaryName: string, +): (exec: ExecService) => Promise { + return async (exec: ExecService): Promise => { + try { + const cmd = process.platform === "win32" ? "where" : "which"; + const result = await exec.exec(cmd, [binaryName]); + return result.exitCode === 0; + } catch { + return false; + } + }; +} + /** Claude Code: detected by ~/.claude/ directory, configured via plugin install */ const claudeCode: AgentDefinition = { name: "Claude Code", id: "claude-code", setupMethod: "cli", + detectBinary: createBinaryDetector("claude"), detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".claude")], getSetupConfig: () => ({ method: "cli", @@ -193,6 +209,7 @@ const codexCli: AgentDefinition = { name: "Codex CLI", id: "codex-cli", setupMethod: "cli", + detectBinary: createBinaryDetector("codex"), detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".codex")], getSetupConfig: () => ({ method: "cli", @@ -267,6 +284,7 @@ const geminiCli: AgentDefinition = { name: "Gemini CLI", id: "gemini-cli", setupMethod: "cli", + detectBinary: createBinaryDetector("gemini"), detectPaths: (fs) => [fs.joinPath(fs.getHomeDir(), ".gemini")], getSetupConfig: () => ({ method: "cli", @@ -325,15 +343,7 @@ const openCode: AgentDefinition = { } return [fs.joinPath(home, ".config", "opencode")]; }, - detectBinary: async (exec) => { - try { - const cmd = process.platform === "win32" ? "where" : "which"; - const result = await exec.exec(cmd, ["opencode"]); - return result.exitCode === 0; - } catch { - return false; - } - }, + detectBinary: createBinaryDetector("opencode"), getSetupConfig: (fs) => ({ method: "config-file", configPath: fs.joinPath( @@ -355,6 +365,7 @@ const openCode: AgentDefinition = { /** * All supported agent definitions, ordered by popularity/likelihood. * New agents should be added here. + * @deprecated Use scanAgents() instead, which also checks configuration status. */ export const agentDefinitions: AgentDefinition[] = [ claudeCode, diff --git a/src/commands/init/init.test.ts b/src/commands/init/init.test.ts index 04ff7216..1166ac4d 100644 --- a/src/commands/init/init.test.ts +++ b/src/commands/init/init.test.ts @@ -252,8 +252,8 @@ describe("initAction", () => { }, ); - // 1 binary detection (which opencode) + 1 check + 2 setup commands = 4 exec calls - expect(execService.exec).toHaveBeenCalledTimes(4); + // 4 binary detections (claude/codex/gemini/opencode) + 1 check + 2 setup = 7 + expect(execService.exec).toHaveBeenCalledTimes(7); expect(execService.exec).toHaveBeenCalledWith("claude", expect.any(Array)); }); @@ -334,8 +334,8 @@ describe("initAction", () => { }, ); - // Only exec call should be the opencode binary detection (which opencode) - expect(execService.exec).toHaveBeenCalledTimes(1); + // scanAgents performs binary detection for 4 agents (claude/codex/gemini/opencode) + expect(execService.exec).toHaveBeenCalledTimes(4); const logCalls = getLogOutput(); expect( logCalls.some((msg) => msg.includes("No coding agents detected")),