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.
This commit is contained in:
ZhaoJY
2026-09-25 18:02:57 +08:00
parent dc815fc15f
commit 3a95791f61
+31 -4
View File
@@ -6,20 +6,38 @@ import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const KNOWN_BINS = [
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,16 +54,25 @@ export function clearDevinBinCache(): void {
export async function whichDevin(): Promise<string | null> {
const known = findDevinBin();
if (known) return known;
// 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("/usr/bin/which", ["devin"], { timeout: 5_000 });
const path = stdout.trim();
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
}
}
return null;
}