Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/commands/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { expect, test } from "bun:test";
import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { hasProjectScmSkills, shouldAutoInitChat } from "./chat.ts";
import {
hasProjectScmSkills,
shouldAutoInitChat,
} from "./chat.ts";

async function withTempDir(run: (dir: string) => Promise<void>): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), "atomic-chat-test-"));
Expand Down
13 changes: 7 additions & 6 deletions src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,12 +231,13 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num

if (agentType === "claude") {
configPrepTasks.push(
import("../utils/claude-config.ts").then(async ({ prepareClaudeConfigDir }) => {
const mergedClaudeConfigDir = await prepareClaudeConfigDir();
if (mergedClaudeConfigDir) {
process.env.CLAUDE_CONFIG_DIR = mergedClaudeConfigDir;
}
})
(async () => {
// Always keep ~/.atomic/.claude merged with ~/.claude for Atomic-managed
// global config continuity. Do not set CLAUDE_CONFIG_DIR at runtime,
// because that can break Claude auth resolution on macOS.
const { prepareClaudeConfigDir } = await import("../utils/claude-config.ts");
await prepareClaudeConfigDir();
})()
);
}

Expand Down
145 changes: 145 additions & 0 deletions src/sdk/clients/claude.executable-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, expect, test } from "bun:test";
import { resolveClaudeCodeExecutablePath } from "./claude.ts";

interface FakeFs {
pathExists: (path: string) => boolean;
resolveRealPath: (path: string) => string;
}

function createFakeFs(
existingPaths: string[],
realPathMap: Record<string, string> = {},
): FakeFs {
const existing = new Set(existingPaths);
return {
pathExists: (path: string) => existing.has(path),
resolveRealPath: (path: string) => {
const resolved = realPathMap[path];
if (!resolved) {
return path;
}
if (!existing.has(resolved)) {
throw new Error(`Missing fake path: ${resolved}`);
}
return resolved;
},
};
}

describe("resolveClaudeCodeExecutablePath", () => {
test("prefers native macOS install over Bun/npm shim", () => {
const homeDir = "/Users/tester";
const fs = createFakeFs(
[
"/opt/homebrew/bin/claude",
"/opt/homebrew/Caskroom/claude-code/2.0.0/claude",
`${homeDir}/.bun/bin/claude`,
`${homeDir}/.bun/install/global/node_modules/@anthropic-ai/claude-code/cli.js`,
"/repo/node_modules/@anthropic-ai/claude-agent-sdk/cli.js",
],
{
"/opt/homebrew/bin/claude":
"/opt/homebrew/Caskroom/claude-code/2.0.0/claude",
[`${homeDir}/.bun/bin/claude`]:
`${homeDir}/.bun/install/global/node_modules/@anthropic-ai/claude-code/cli.js`,
},
);

const resolved = resolveClaudeCodeExecutablePath({
platform: "darwin",
homeDir,
claudeFromPath: `${homeDir}/.bun/bin/claude`,
sdkCliPath: "/repo/node_modules/@anthropic-ai/claude-agent-sdk/cli.js",
envOverridePath: null,
pathExists: fs.pathExists,
resolveRealPath: fs.resolveRealPath,
});

expect(resolved).toBe("/opt/homebrew/bin/claude");
});

test("falls back to PATH Claude shim on macOS when no native install exists", () => {
const homeDir = "/Users/tester";
const fs = createFakeFs(
[
`${homeDir}/.bun/bin/claude`,
`${homeDir}/.bun/install/global/node_modules/@anthropic-ai/claude-code/cli.js`,
"/repo/node_modules/@anthropic-ai/claude-agent-sdk/cli.js",
],
{
[`${homeDir}/.bun/bin/claude`]:
`${homeDir}/.bun/install/global/node_modules/@anthropic-ai/claude-code/cli.js`,
},
);

const resolved = resolveClaudeCodeExecutablePath({
platform: "darwin",
homeDir,
claudeFromPath: `${homeDir}/.bun/bin/claude`,
sdkCliPath: "/repo/node_modules/@anthropic-ai/claude-agent-sdk/cli.js",
envOverridePath: null,
pathExists: fs.pathExists,
resolveRealPath: fs.resolveRealPath,
});

expect(resolved).toBe(`${homeDir}/.bun/bin/claude`);
});

test("prefers SDK bundled CLI on non-macOS", () => {
const fs = createFakeFs([
"/usr/bin/claude",
"/repo/node_modules/@anthropic-ai/claude-agent-sdk/cli.js",
]);

const resolved = resolveClaudeCodeExecutablePath({
platform: "linux",
homeDir: "/home/tester",
claudeFromPath: "/usr/bin/claude",
sdkCliPath: "/repo/node_modules/@anthropic-ai/claude-agent-sdk/cli.js",
envOverridePath: null,
pathExists: fs.pathExists,
resolveRealPath: fs.resolveRealPath,
});

expect(resolved).toBe("/repo/node_modules/@anthropic-ai/claude-agent-sdk/cli.js");
});

test("uses non-node_modules PATH Claude on macOS when available", () => {
const fs = createFakeFs([
"/usr/local/bin/claude",
"/repo/node_modules/@anthropic-ai/claude-agent-sdk/cli.js",
]);

const resolved = resolveClaudeCodeExecutablePath({
platform: "darwin",
homeDir: "/Users/tester",
claudeFromPath: "/usr/local/bin/claude",
sdkCliPath: "/repo/node_modules/@anthropic-ai/claude-agent-sdk/cli.js",
envOverridePath: null,
pathExists: fs.pathExists,
resolveRealPath: fs.resolveRealPath,
});

expect(resolved).toBe("/usr/local/bin/claude");
});

test("honors explicit ATOMIC_CLAUDE_CODE_EXECUTABLE override", () => {
const fs = createFakeFs([
"/custom/claude",
"/usr/local/bin/claude",
"/repo/node_modules/@anthropic-ai/claude-agent-sdk/cli.js",
]);

const resolved = resolveClaudeCodeExecutablePath({
platform: "darwin",
homeDir: "/Users/tester",
claudeFromPath: "/usr/local/bin/claude",
sdkCliPath: "/repo/node_modules/@anthropic-ai/claude-agent-sdk/cli.js",
envOverridePath: "/custom/claude",
pathExists: fs.pathExists,
resolveRealPath: fs.resolveRealPath,
});

expect(resolved).toBe("/custom/claude");
});
});
187 changes: 147 additions & 40 deletions src/sdk/clients/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { stripProviderPrefix } from "../types.ts";
import { initClaudeOptions } from "../init.ts";
import { loadCopilotAgents } from "../../config/copilot-manual.ts";
import { existsSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
Expand Down Expand Up @@ -2470,6 +2471,132 @@ export function createClaudeAgentClient(): ClaudeAgentClient {
return new ClaudeAgentClient();
}

/**
* Dependencies used to resolve the Claude Code executable path.
* Exported for deterministic unit testing.
*/
export interface ClaudeExecutablePathResolutionOptions {
platform: NodeJS.Platform;
homeDir: string;
claudeFromPath: string | null;
sdkCliPath: string | null;
envOverridePath: string | null;
pathExists: (path: string) => boolean;
resolveRealPath: (path: string) => string;
}

interface ClaudeExecutableCandidate {
invokePath: string;
canonicalPath: string;
}

function isLikelyNodeModulesClaudePath(path: string): boolean {
const normalized = path.replaceAll("\\", "/").toLowerCase();
return (
normalized.includes("/node_modules/") ||
normalized.includes("/.bun/install/") ||
normalized.endsWith("/cli.js")
);
}

function resolveClaudeExecutableCandidate(
candidate: string | null,
options: Pick<
ClaudeExecutablePathResolutionOptions,
"pathExists" | "resolveRealPath"
>,
): ClaudeExecutableCandidate | null {
if (!candidate) {
return null;
}
if (!options.pathExists(candidate)) {
return null;
}

try {
const canonicalPath = options.resolveRealPath(candidate);
if (options.pathExists(canonicalPath)) {
return {
invokePath: candidate,
canonicalPath,
};
}
} catch {
// Fall through to returning the original candidate.
}

return {
invokePath: candidate,
canonicalPath: candidate,
};
}

/**
* Resolve the best Claude Code executable path for the active runtime.
*/
export function resolveClaudeCodeExecutablePath(
options: ClaudeExecutablePathResolutionOptions,
): string | null {
const claudeFromPath = resolveClaudeExecutableCandidate(
options.claudeFromPath,
options,
);
const sdkCliPath = resolveClaudeExecutableCandidate(options.sdkCliPath, options);
const envOverridePath = resolveClaudeExecutableCandidate(
options.envOverridePath,
options,
);

if (envOverridePath) {
return envOverridePath.invokePath;
}

if (options.platform === "darwin") {
// On macOS, prefer native installs first so Claude desktop/Homebrew auth
// state is reused even when PATH points to a Bun/npm shim.
const macNativeCandidates = [
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
"/Applications/Claude Code.app/Contents/MacOS/claude",
join(options.homeDir, ".local", "bin", "claude"),
join(options.homeDir, ".claude", "local", "claude"),
join(options.homeDir, "bin", "claude"),
"/Applications/Claude.app/Contents/MacOS/claude",
join(options.homeDir, "Applications", "Claude.app", "Contents", "MacOS", "claude"),
join(
options.homeDir,
"Applications",
"Claude Code.app",
"Contents",
"MacOS",
"claude",
),
];

for (const candidate of macNativeCandidates) {
const resolved = resolveClaudeExecutableCandidate(candidate, options);
if (resolved && !isLikelyNodeModulesClaudePath(resolved.canonicalPath)) {
return resolved.invokePath;
}
}

if (
claudeFromPath &&
!isLikelyNodeModulesClaudePath(claudeFromPath.canonicalPath)
) {
return claudeFromPath.invokePath;
}

if (sdkCliPath && !isLikelyNodeModulesClaudePath(sdkCliPath.canonicalPath)) {
return sdkCliPath.invokePath;
}

return claudeFromPath?.invokePath ?? sdkCliPath?.invokePath ?? null;
}

return sdkCliPath?.invokePath ?? claudeFromPath?.invokePath ?? null;
}

/**
* Get the path to the Claude Code CLI entry point.
*
Expand All @@ -2480,55 +2607,35 @@ export function createClaudeAgentClient(): ClaudeAgentClient {
* - npm package (@anthropic-ai/claude-agent-sdk, dev only)
*
* Resolution order:
* 1. On macOS, prefer globally-installed claude CLI on $PATH
* 2. import.meta.resolve (works in dev when @anthropic-ai/claude-agent-sdk is available)
* 3. Find globally-installed claude CLI on $PATH
* 1. On macOS, prefer native install locations and non-node_modules binaries
* 2. SDK bundled cli.js (import.meta.resolve)
* 3. PATH fallback (including Bun/npm shims)
*/
export function getBundledClaudeCodePath(): string {
// Shared strategy: Find claude CLI on $PATH.
// For npm global installs, the symlink resolves into the package with cli.js.
// For standalone installs (native install, Homebrew, WinGet), return the binary
// directly — the SDK handles executable paths by spawning them directly.
const resolveClaudeFromPath = (): string | null => {
try {
const claudeBin = Bun.which("claude");
if (claudeBin) {
const realPath = realpathSync(claudeBin);
// Check if it's an npm package with cli.js
const pkgDir = dirname(realPath);
const cliPath = join(pkgDir, "cli.js");
if (existsSync(cliPath)) return cliPath;
// Standalone binary (native install, Homebrew, WinGet) — no cli.js
if (existsSync(realPath)) return realPath;
}
} catch {
// Falls through
}
return null;
};

// On macOS, prefer the system Claude binary first so auth state from
// native installs (including Homebrew/curl) is used consistently.
if (process.platform === "darwin") {
const claudeFromPath = resolveClaudeFromPath();
if (claudeFromPath) return claudeFromPath;
}

// Strategy 2: import.meta.resolve (works in dev, fails in compiled binary)
const envOverridePath = process.env.ATOMIC_CLAUDE_CODE_EXECUTABLE?.trim() ||
null;
let sdkCliPath: string | null = null;
try {
const sdkUrl = import.meta.resolve("@anthropic-ai/claude-agent-sdk");
const sdkPath = fileURLToPath(sdkUrl);
const pkgDir = dirname(sdkPath);
const cliPath = join(pkgDir, "cli.js");
if (existsSync(cliPath)) return cliPath;
sdkCliPath = join(pkgDir, "cli.js");
} catch {
// Falls through
// Falls through.
}

// Strategy 3: PATH fallback for non-macOS (or when Strategy 1 is unavailable).
{
const claudeFromPath = resolveClaudeFromPath();
if (claudeFromPath) return claudeFromPath;
const resolvedPath = resolveClaudeCodeExecutablePath({
platform: process.platform,
homeDir: homedir(),
claudeFromPath: Bun.which("claude") ?? Bun.which("claude-code"),
sdkCliPath,
envOverridePath,
pathExists: existsSync,
resolveRealPath: realpathSync,
});

if (resolvedPath) {
return resolvedPath;
}

throw new Error(
Expand Down
Loading
Loading