diff --git a/src/agent/claude-code/binary.ts b/src/agent/claude-code/binary.ts new file mode 100644 index 0000000..44becc3 --- /dev/null +++ b/src/agent/claude-code/binary.ts @@ -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; + 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 }); +} diff --git a/tests/claude-code-binary.test.ts b/tests/claude-code-binary.test.ts new file mode 100644 index 0000000..05adf28 --- /dev/null +++ b/tests/claude-code-binary.test.ts @@ -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 = {}, 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); +});