Skip to content

Commit 8dcb001

Browse files
authored
Merge pull request #110 from AxmeAI/fix/find-claude-path-fallback-20260417
fix(agents): findClaudePath resolves via env vars, standard paths, nvm (B-009)
2 parents 0917efd + bdeaffa commit 8dcb001

4 files changed

Lines changed: 234 additions & 20 deletions

File tree

benchmarks/package-lock.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/utils/agent-options.ts

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,97 @@
33
*/
44

55
import { execSync } from "node:child_process";
6+
import { existsSync, readdirSync } from "node:fs";
7+
import { join } from "node:path";
8+
import { homedir } from "node:os";
69
import { resolveAuthMode } from "./auth-config.js";
710

811
type Options = import("@anthropic-ai/claude-agent-sdk").Options;
912

1013
/**
11-
* Find claude binary path. Cached after first lookup.
14+
* Find the `claude` CLI binary path. Cached after first successful lookup.
1215
*
13-
* Exported because the SDK resolves its own path via `import.meta.url`, which
14-
* returns undefined inside the bundled CJS build and crashes with
15-
* `fileURLToPath(undefined)` (B-006 / D-121). Every direct `sdk.query()` call
16-
* site must set `pathToClaudeCodeExecutable` to the result of this function.
16+
* The Claude Agent SDK resolves its own executable via `import.meta.url`,
17+
* which is `undefined` inside our bundled CJS builds and crashes with
18+
* `fileURLToPath(undefined)` (B-006 / D-121). Every `sdk.query()` call must
19+
* set `pathToClaudeCodeExecutable` to the result of this function.
20+
*
21+
* Resolution order (B-009 — first match wins):
22+
* 1. `AXME_CLAUDE_EXECUTABLE` env var — explicit override for CI / unusual installs
23+
* 2. `CLAUDE_CODE_ENTRYPOINT` env var — set by Claude Code itself in some contexts
24+
* 3. `which claude` — standard PATH lookup (works on most dev machines)
25+
* 4. Standard install locations (no PATH dependency):
26+
* - ~/.local/bin/claude
27+
* - /usr/local/bin/claude
28+
* - /opt/homebrew/bin/claude (macOS Apple Silicon)
29+
* - /usr/bin/claude
30+
* 5. nvm-managed installs: ~/.nvm/versions/node/* /bin/claude
31+
*
32+
* Returns undefined only if none of the above yields a readable file.
33+
* Callers should treat undefined as "claude not installed" and either
34+
* fail-fast with an actionable message or skip the LLM call.
1735
*/
1836
let _claudePath: string | undefined;
1937
export function findClaudePath(): string | undefined {
2038
if (_claudePath !== undefined) return _claudePath || undefined;
39+
40+
// 1. Explicit override
41+
if (process.env.AXME_CLAUDE_EXECUTABLE && existsSync(process.env.AXME_CLAUDE_EXECUTABLE)) {
42+
_claudePath = process.env.AXME_CLAUDE_EXECUTABLE;
43+
return _claudePath;
44+
}
45+
46+
// 2. SDK's own env var
47+
if (process.env.CLAUDE_CODE_ENTRYPOINT && existsSync(process.env.CLAUDE_CODE_ENTRYPOINT)) {
48+
_claudePath = process.env.CLAUDE_CODE_ENTRYPOINT;
49+
return _claudePath;
50+
}
51+
52+
// 3. which claude (PATH lookup)
2153
try {
22-
_claudePath = execSync("which claude", { encoding: "utf-8" }).trim();
23-
} catch {
24-
_claudePath = "";
54+
const p = execSync("which claude", { encoding: "utf-8", timeout: 5000 }).trim();
55+
if (p && existsSync(p)) {
56+
_claudePath = p;
57+
return _claudePath;
58+
}
59+
} catch { /* not in PATH — continue to standard locations */ }
60+
61+
// 4. Standard install locations
62+
const home = homedir();
63+
const standardPaths = [
64+
join(home, ".local", "bin", "claude"),
65+
"/usr/local/bin/claude",
66+
"/opt/homebrew/bin/claude",
67+
"/usr/bin/claude",
68+
];
69+
for (const candidate of standardPaths) {
70+
if (existsSync(candidate)) {
71+
_claudePath = candidate;
72+
return _claudePath;
73+
}
2574
}
26-
return _claudePath || undefined;
75+
76+
// 5. nvm-managed installs (common on dev machines)
77+
try {
78+
const nvmDir = join(home, ".nvm", "versions", "node");
79+
if (existsSync(nvmDir)) {
80+
for (const ver of readdirSync(nvmDir)) {
81+
const candidate = join(nvmDir, ver, "bin", "claude");
82+
if (existsSync(candidate)) {
83+
_claudePath = candidate;
84+
return _claudePath;
85+
}
86+
}
87+
}
88+
} catch { /* nvm not present — fine */ }
89+
90+
_claudePath = "";
91+
return undefined;
92+
}
93+
94+
/** @internal Reset cached claude path. Used in tests only. */
95+
export function _resetFindClaudePath(): void {
96+
_claudePath = undefined;
2797
}
2898

2999
export type AgentRole = "scanner" | "tester" | "reviewer" | "engineer" | "architect" | "auditor";

test/find-claude-path.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* Tests for findClaudePath() resolver (B-009).
3+
*
4+
* The resolver has 5 steps: env override, SDK env var, `which claude`,
5+
* standard install locations, nvm glob. We test the env-based steps
6+
* (1, 2) directly and verify cache + reset behavior.
7+
*/
8+
9+
import { describe, it, beforeEach, afterEach } from "node:test";
10+
import assert from "node:assert/strict";
11+
import { writeFileSync, mkdirSync, unlinkSync, chmodSync } from "node:fs";
12+
import { join } from "node:path";
13+
import { tmpdir } from "node:os";
14+
import { findClaudePath, _resetFindClaudePath } from "../src/utils/agent-options.ts";
15+
16+
// Save original env
17+
let savedAxme: string | undefined;
18+
let savedEntrypoint: string | undefined;
19+
20+
function createTmpExecutable(name: string): string {
21+
const dir = join(tmpdir(), `axme-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
22+
mkdirSync(dir, { recursive: true });
23+
const file = join(dir, name);
24+
writeFileSync(file, "#!/bin/sh\necho mock", "utf-8");
25+
try { chmodSync(file, 0o755); } catch {}
26+
return file;
27+
}
28+
29+
function removeTmp(path: string): void {
30+
try { unlinkSync(path); } catch {}
31+
}
32+
33+
describe("findClaudePath — resolution order (B-009)", () => {
34+
beforeEach(() => {
35+
_resetFindClaudePath();
36+
savedAxme = process.env.AXME_CLAUDE_EXECUTABLE;
37+
savedEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT;
38+
delete process.env.AXME_CLAUDE_EXECUTABLE;
39+
delete process.env.CLAUDE_CODE_ENTRYPOINT;
40+
});
41+
42+
afterEach(() => {
43+
_resetFindClaudePath();
44+
if (savedAxme !== undefined) process.env.AXME_CLAUDE_EXECUTABLE = savedAxme;
45+
else delete process.env.AXME_CLAUDE_EXECUTABLE;
46+
if (savedEntrypoint !== undefined) process.env.CLAUDE_CODE_ENTRYPOINT = savedEntrypoint;
47+
else delete process.env.CLAUDE_CODE_ENTRYPOINT;
48+
});
49+
50+
it("step 1: AXME_CLAUDE_EXECUTABLE env var takes priority over everything", () => {
51+
const tmp = createTmpExecutable("claude");
52+
try {
53+
process.env.AXME_CLAUDE_EXECUTABLE = tmp;
54+
const result = findClaudePath();
55+
assert.equal(result, tmp);
56+
} finally {
57+
removeTmp(tmp);
58+
}
59+
});
60+
61+
it("step 1: AXME_CLAUDE_EXECUTABLE with non-existent path is skipped", () => {
62+
process.env.AXME_CLAUDE_EXECUTABLE = "/nonexistent/path/to/claude";
63+
const result = findClaudePath();
64+
// Should fall through to step 2+ (not crash, not return the bogus path)
65+
assert.notEqual(result, "/nonexistent/path/to/claude");
66+
});
67+
68+
it("step 2: CLAUDE_CODE_ENTRYPOINT used when AXME_CLAUDE_EXECUTABLE absent", () => {
69+
const tmp = createTmpExecutable("claude");
70+
try {
71+
process.env.CLAUDE_CODE_ENTRYPOINT = tmp;
72+
const result = findClaudePath();
73+
assert.equal(result, tmp);
74+
} finally {
75+
removeTmp(tmp);
76+
}
77+
});
78+
79+
it("step 2: CLAUDE_CODE_ENTRYPOINT with non-existent path is skipped", () => {
80+
process.env.CLAUDE_CODE_ENTRYPOINT = "/nonexistent/entrypoint";
81+
const result = findClaudePath();
82+
assert.notEqual(result, "/nonexistent/entrypoint");
83+
});
84+
85+
it("step 1 beats step 2 when both are set", () => {
86+
const tmp1 = createTmpExecutable("claude-axme");
87+
const tmp2 = createTmpExecutable("claude-sdk");
88+
try {
89+
process.env.AXME_CLAUDE_EXECUTABLE = tmp1;
90+
process.env.CLAUDE_CODE_ENTRYPOINT = tmp2;
91+
const result = findClaudePath();
92+
assert.equal(result, tmp1, "AXME_CLAUDE_EXECUTABLE should win over CLAUDE_CODE_ENTRYPOINT");
93+
} finally {
94+
removeTmp(tmp1);
95+
removeTmp(tmp2);
96+
}
97+
});
98+
99+
it("result is cached after first successful lookup", () => {
100+
const tmp = createTmpExecutable("claude");
101+
try {
102+
process.env.AXME_CLAUDE_EXECUTABLE = tmp;
103+
const first = findClaudePath();
104+
assert.equal(first, tmp);
105+
106+
// Change env — cached result should still be the first one
107+
delete process.env.AXME_CLAUDE_EXECUTABLE;
108+
const second = findClaudePath();
109+
assert.equal(second, tmp, "Should return cached value, not re-resolve");
110+
} finally {
111+
removeTmp(tmp);
112+
}
113+
});
114+
115+
it("_resetFindClaudePath clears the cache", () => {
116+
const tmp1 = createTmpExecutable("claude-a");
117+
const tmp2 = createTmpExecutable("claude-b");
118+
try {
119+
process.env.AXME_CLAUDE_EXECUTABLE = tmp1;
120+
assert.equal(findClaudePath(), tmp1);
121+
122+
_resetFindClaudePath();
123+
process.env.AXME_CLAUDE_EXECUTABLE = tmp2;
124+
assert.equal(findClaudePath(), tmp2, "After reset, should re-resolve");
125+
} finally {
126+
removeTmp(tmp1);
127+
removeTmp(tmp2);
128+
}
129+
});
130+
131+
it("returns a string (not undefined) on a dev machine with claude installed", () => {
132+
// This test runs on our dev machine where `claude` IS in PATH via nvm.
133+
// On CI without claude installed, this test will find it via nvm glob
134+
// or standard paths — if neither exist, we just skip the assertion.
135+
const result = findClaudePath();
136+
if (result) {
137+
assert.equal(typeof result, "string");
138+
assert.ok(result.length > 0);
139+
assert.ok(result.includes("claude"), `Expected path containing 'claude', got: ${result}`);
140+
}
141+
// If result is undefined (bare CI runner), that's OK — the important
142+
// tests are the env-var ones above which we control.
143+
});
144+
});

0 commit comments

Comments
 (0)