feat: locate CLI binary
Search PATH, then standard install dirs, since the packaged app often starts with a minimal PATH.
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
type BinaryLookup = {
|
||||||
|
env: Record<string, string | undefined>;
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
homeDir: string;
|
||||||
|
isExecutable: (path: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchSpec = {
|
||||||
|
paths: path.PlatformPath;
|
||||||
|
names: string[];
|
||||||
|
knownDirs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec {
|
||||||
|
const paths = path.win32;
|
||||||
|
const appData = env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming");
|
||||||
|
return {
|
||||||
|
paths,
|
||||||
|
names: ["claude.cmd", "claude.exe"],
|
||||||
|
knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), env.npm_config_prefix ?? paths.join(appData, "npm")],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec {
|
||||||
|
const paths = path.posix;
|
||||||
|
const npmBin = env.npm_config_prefix ? paths.join(env.npm_config_prefix, "bin") : paths.join(homeDir, ".npm-global", "bin");
|
||||||
|
return {
|
||||||
|
paths,
|
||||||
|
names: ["claude"],
|
||||||
|
knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), npmBin, "/opt/homebrew/bin", "/usr/local/bin"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: BinaryLookup): string | null {
|
||||||
|
const { paths, names, knownDirs } = platform === "win32" ? getWindowsSearchSpec(env, homeDir) : getPosixSearchSpec(env, homeDir);
|
||||||
|
const pathDirs = (env.PATH ?? "").split(paths.delimiter).filter(Boolean);
|
||||||
|
for (const dir of [...pathDirs, ...knownDirs]) {
|
||||||
|
for (const name of names) {
|
||||||
|
const candidate = paths.join(dir, name);
|
||||||
|
if (isExecutable(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExecutableFile(filePath: string): boolean {
|
||||||
|
try {
|
||||||
|
fs.accessSync(filePath, fs.constants.X_OK);
|
||||||
|
return fs.statSync(filePath).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClaudeBinary(): string | null {
|
||||||
|
return resolveClaudeBinary({ env: process.env, platform: process.platform, homeDir: os.homedir(), isExecutable: isExecutableFile });
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Claude Code binary lookup (src/agent/claude-code/binary.ts).
|
||||||
|
*
|
||||||
|
* The packaged desktop app often starts with a minimal PATH, so lookup falls
|
||||||
|
* back to the standard install locations. A fake isExecutable set stands in
|
||||||
|
* for the filesystem.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { resolveClaudeBinary } from "../src/agent/claude-code/binary.ts";
|
||||||
|
|
||||||
|
const HOME = "/home/u";
|
||||||
|
|
||||||
|
function resolveWith(executables: string[], env: Record<string, string> = {}, platform: NodeJS.Platform = "linux", homeDir = HOME) {
|
||||||
|
const found = new Set(executables);
|
||||||
|
return resolveClaudeBinary({ env, platform, homeDir, isExecutable: (p) => found.has(p) });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("prefers PATH hit", () => {
|
||||||
|
const binary = resolveWith(["/usr/bin/claude", `${HOME}/.local/bin/claude`], { PATH: "/opt/x:/usr/bin" });
|
||||||
|
assert.equal(binary, "/usr/bin/claude");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to ~/.local/bin when PATH misses", () => {
|
||||||
|
const binary = resolveWith([`${HOME}/.local/bin/claude`, "/usr/local/bin/claude"], { PATH: "/usr/bin" });
|
||||||
|
assert.equal(binary, `${HOME}/.local/bin/claude`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checks ~/.claude/local, npm global bin, /opt/homebrew/bin, /usr/local/bin in that order", () => {
|
||||||
|
const all = [`${HOME}/.claude/local/claude`, `${HOME}/.npm-global/bin/claude`, "/opt/homebrew/bin/claude", "/usr/local/bin/claude"];
|
||||||
|
for (let i = 0; i < all.length; i++) {
|
||||||
|
assert.equal(resolveWith(all.slice(i), { PATH: "" }), all[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("npm global bin follows npm_config_prefix when set", () => {
|
||||||
|
const binary = resolveWith(["/opt/npm/bin/claude", `${HOME}/.npm-global/bin/claude`], { npm_config_prefix: "/opt/npm" });
|
||||||
|
assert.equal(binary, "/opt/npm/bin/claude");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("windows looks for claude.cmd and claude.exe", () => {
|
||||||
|
const winHome = "C:\\Users\\u";
|
||||||
|
const env = { PATH: "C:\\tools;C:\\bin", APPDATA: `${winHome}\\AppData\\Roaming` };
|
||||||
|
assert.equal(resolveWith(["C:\\bin\\claude.cmd"], env, "win32", winHome), "C:\\bin\\claude.cmd");
|
||||||
|
assert.equal(resolveWith(["C:\\bin\\claude.exe"], env, "win32", winHome), "C:\\bin\\claude.exe");
|
||||||
|
assert.equal(resolveWith([`${winHome}\\.local\\bin\\claude.exe`], env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`);
|
||||||
|
assert.equal(resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome), `${winHome}\\AppData\\Roaming\\npm\\claude.cmd`);
|
||||||
|
assert.equal(resolveWith(["C:\\bin\\claude"], env, "win32", winHome), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when every location misses", () => {
|
||||||
|
assert.equal(resolveWith([], { PATH: "/usr/bin:/bin" }), null);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user