From 3a95791f613613b04deb5b57af49f63cfa6aa0f5 Mon Sep 17 00:00:00 2001 From: ZhaoJY Date: Fri, 25 Sep 2026 18:02:57 +0800 Subject: [PATCH] fix: locate the Devin CLI on Windows KNOWN_BINS only listed Unix/macOS paths, and whichDevin() always shelled out to /usr/bin/which. On Windows neither resolves, so the provider could not find `devin.exe` even when it was installed and on PATH. The model catalog therefore never loaded and the provider silently fell back to the four hard-coded FALLBACK_MODELS entries. - Probe the documented Windows install locations (%LOCALAPPDATA%\devin\ cli\bin\devin.exe, the Devin Desktop bundled CLI for both per-user and machine-wide installs, plus two fallbacks). - Build the candidate list lazily so a DEVIN_CLI value set after module load is still honoured. - Use `where` instead of /usr/bin/which on Windows, and accept multiple result lines, since `where` prints one path per line. All existing Unix/macOS paths are preserved. Tested on Windows 11, Node 24.5.0, pi 0.87.1, Devin CLI 3000.11.3: `pi --list-models devin` goes from 4 models to 52 with DEVIN_CLI unset. `tsc --noEmit` and the existing test suite both pass. --- src/cli.ts | 61 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index f8627ba..282ddc0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,20 +6,38 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); -const KNOWN_BINS = [ - process.env.DEVIN_CLI, - join(homedir(), ".local/bin/devin"), - join(homedir(), ".devin/bin/devin"), - "/opt/homebrew/bin/devin", - "/usr/local/bin/devin", - "/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin", -].filter((p): p is string => Boolean(p)); +function candidateBins(): string[] { + const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), "AppData/Local"); + return [ + // Explicit override always wins. Read lazily so a value set after module + // load (e.g. by the host process) is still honoured. + process.env.DEVIN_CLI, + // ── Windows ────────────────────────────────────────────────────────── + // Official installer location (%LOCALAPPDATA%\devin\cli\bin\devin.exe) + join(localAppData, "devin/cli/bin/devin.exe"), + // Devin Desktop (Windsurf-based) bundled CLI, per-user install + join(localAppData, "Programs/Devin/resources/app/extensions/windsurf/devin/bin/devin.exe"), + // Devin Desktop bundled CLI, machine-wide install + process.env.ProgramFiles && + join(process.env.ProgramFiles, "Devin/resources/app/extensions/windsurf/devin/bin/devin.exe"), + // Set by the official Windows installer when it completes normally. + process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "devin/bin/devin.exe"), + // Manual fallback for installs that could not finish while Devin was running. + join(homedir(), ".devin-cli/devin.exe"), + // ── Unix / macOS ───────────────────────────────────────────────────── + join(homedir(), ".local/bin/devin"), + join(homedir(), ".devin/bin/devin"), + "/opt/homebrew/bin/devin", + "/usr/local/bin/devin", + "/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin", + ].filter((p): p is string => Boolean(p)); +} let cachedBin: string | null | undefined; export function findDevinBin(): string | null { if (cachedBin !== undefined) return cachedBin; - for (const bin of KNOWN_BINS) { + for (const bin of candidateBins()) { if (existsSync(bin)) { cachedBin = bin; return bin; @@ -36,15 +54,24 @@ export function clearDevinBinCache(): void { export async function whichDevin(): Promise { const known = findDevinBin(); if (known) return known; - try { - const { stdout } = await execFileAsync("/usr/bin/which", ["devin"], { timeout: 5_000 }); - const path = stdout.trim(); - if (path && existsSync(path)) { - cachedBin = path; - return path; + // Windows has no `which`; use `where` and probe the returned paths directly. + const isWindows = process.platform === "win32"; + const locator = isWindows ? "where" : "/usr/bin/which"; + const candidates = isWindows ? ["devin.exe", "devin.cmd", "devin"] : ["devin"]; + for (const candidate of candidates) { + try { + const { stdout } = await execFileAsync(locator, [candidate], { timeout: 5_000 }); + // `where` may return several lines; take the first existing one. + for (const line of stdout.split(/\r?\n/)) { + const path = line.trim(); + if (path && existsSync(path)) { + cachedBin = path; + return path; + } + } + } catch { + // not on PATH } - } catch { - // not on PATH } return null; }