diff --git a/src/commands/chat.test.ts b/src/commands/chat.test.ts index 6635b2307..70e2057fc 100644 --- a/src/commands/chat.test.ts +++ b/src/commands/chat.test.ts @@ -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): Promise { const dir = await mkdtemp(join(tmpdir(), "atomic-chat-test-")); diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 2be87848d..e9e544db6 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -231,12 +231,13 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise { - 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(); + })() ); } diff --git a/src/sdk/clients/claude.executable-path.test.ts b/src/sdk/clients/claude.executable-path.test.ts new file mode 100644 index 000000000..e4ed3fdf9 --- /dev/null +++ b/src/sdk/clients/claude.executable-path.test.ts @@ -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 = {}, +): 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"); + }); +}); diff --git a/src/sdk/clients/claude.ts b/src/sdk/clients/claude.ts index 24a462c5b..6fdaab35d 100644 --- a/src/sdk/clients/claude.ts +++ b/src/sdk/clients/claude.ts @@ -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 { @@ -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. * @@ -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( diff --git a/src/utils/claude-config.test.ts b/src/utils/claude-config.test.ts index e06798cf9..bfc23f404 100644 --- a/src/utils/claude-config.test.ts +++ b/src/utils/claude-config.test.ts @@ -3,6 +3,7 @@ import { join } from "path"; import { tmpdir } from "os"; import { mkdtemp, mkdir, rm, writeFile, readFile } from "fs/promises"; import { prepareClaudeConfigDir } from "./claude-config.ts"; +import { pathExists } from "./copy.ts"; describe("prepareClaudeConfigDir", () => { test("returns null when ~/.atomic/.claude is missing", async () => { @@ -13,57 +14,124 @@ describe("prepareClaudeConfigDir", () => { await mkdir(homeDir, { recursive: true }); try { - const result = await prepareClaudeConfigDir({ - homeDir, - mergedDir, - }); + const result = await prepareClaudeConfigDir({ homeDir, mergedDir }); expect(result).toBeNull(); } finally { await rm(root, { recursive: true, force: true }); } }); - test("loads ~/.atomic/.claude and overlays ~/.claude", async () => { + test("overlays only agents/skills/commands from ~/.claude", async () => { const root = await mkdtemp(join(tmpdir(), "atomic-claude-config-")); const homeDir = join(root, "home"); const mergedDir = join(root, "merged"); - const atomicSkill = join(homeDir, ".atomic", ".claude", "skills", "prompt-engineer", "SKILL.md"); - const legacySkill = join(homeDir, ".claude", "skills", "prompt-engineer", "SKILL.md"); - const atomicOnlySkill = join(homeDir, ".atomic", ".claude", "skills", "research-codebase", "SKILL.md"); - await mkdir(join(homeDir, ".atomic", ".claude", "skills", "prompt-engineer"), { recursive: true, }); await mkdir(join(homeDir, ".claude", "skills", "prompt-engineer"), { recursive: true, }); - await mkdir(join(homeDir, ".atomic", ".claude", "skills", "research-codebase"), { + await mkdir(join(homeDir, ".atomic", ".claude", "commands"), { + recursive: true, + }); + await mkdir(join(homeDir, ".claude", "commands"), { recursive: true, }); - await writeFile(atomicSkill, "atomic", "utf-8"); - await writeFile(legacySkill, "legacy", "utf-8"); - await writeFile(atomicOnlySkill, "atomic-only", "utf-8"); + await writeFile( + join(homeDir, ".atomic", ".claude", "skills", "prompt-engineer", "SKILL.md"), + "atomic-skill", + "utf-8", + ); + await writeFile( + join(homeDir, ".claude", "skills", "prompt-engineer", "SKILL.md"), + "legacy-skill", + "utf-8", + ); + await writeFile(join(homeDir, ".atomic", ".claude", "settings.json"), "atomic-settings", "utf-8"); + await writeFile(join(homeDir, ".claude", "settings.json"), "legacy-settings", "utf-8"); + await writeFile(join(homeDir, ".claude", "commands", "my-command.md"), "legacy-command", "utf-8"); try { - const result = await prepareClaudeConfigDir({ - homeDir, - mergedDir, - }); + const result = await prepareClaudeConfigDir({ homeDir, mergedDir }); expect(result).toBe(mergedDir); const mergedSkill = await readFile( join(mergedDir, "skills", "prompt-engineer", "SKILL.md"), "utf-8", ); - const mergedAtomicOnlySkill = await readFile( - join(mergedDir, "skills", "research-codebase", "SKILL.md"), - "utf-8", - ); + const mergedSettings = await readFile(join(mergedDir, "settings.json"), "utf-8"); + const mergedCommand = await readFile(join(mergedDir, "commands", "my-command.md"), "utf-8"); - expect(mergedSkill).toBe("legacy"); - expect(mergedAtomicOnlySkill).toBe("atomic-only"); + expect(mergedSkill).toBe("legacy-skill"); + expect(mergedSettings).toBe("atomic-settings"); + expect(mergedCommand).toBe("legacy-command"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("defaults merged output to ~/.atomic/.claude", async () => { + const root = await mkdtemp(join(tmpdir(), "atomic-claude-config-")); + const homeDir = join(root, "home"); + + await mkdir(join(homeDir, ".atomic", ".claude", "agents"), { recursive: true }); + await mkdir(join(homeDir, ".claude", "agents"), { recursive: true }); + await writeFile(join(homeDir, ".claude", "agents", "example.md"), "legacy-agent", "utf-8"); + + try { + const result = await prepareClaudeConfigDir({ homeDir }); + const expectedMergedPath = join(homeDir, ".atomic", ".claude"); + expect(result).toBe(expectedMergedPath); + expect(await readFile(join(expectedMergedPath, "agents", "example.md"), "utf-8")).toBe("legacy-agent"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("does not copy root ~/.claude.json into merged output", async () => { + const root = await mkdtemp(join(tmpdir(), "atomic-claude-config-")); + const homeDir = join(root, "home"); + const mergedDir = join(root, "merged"); + + await mkdir(join(homeDir, ".atomic", ".claude"), { recursive: true }); + await writeFile(join(homeDir, ".claude.json"), JSON.stringify({ key: "value" }), "utf-8"); + + try { + const result = await prepareClaudeConfigDir({ homeDir, mergedDir }); + expect(result).toBe(mergedDir); + expect(await pathExists(join(mergedDir, ".claude.json"))).toBe(false); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("excludes nested .git directories from selected sync paths", async () => { + const root = await mkdtemp(join(tmpdir(), "atomic-claude-config-")); + const homeDir = join(root, "home"); + const mergedDir = join(root, "merged"); + + await mkdir(join(homeDir, ".atomic", ".claude"), { recursive: true }); + await mkdir(join(homeDir, ".claude", "skills", "my-skill", ".git", "objects"), { + recursive: true, + }); + await writeFile( + join(homeDir, ".claude", "skills", "my-skill", ".git", "objects", "packfile"), + "git-pack", + "utf-8", + ); + await writeFile(join(homeDir, ".claude", "skills", "my-skill", "SKILL.md"), "skill", "utf-8"); + + try { + const result = await prepareClaudeConfigDir({ homeDir, mergedDir }); + expect(result).toBe(mergedDir); + expect(await pathExists(join(mergedDir, "skills", "my-skill", "SKILL.md"))).toBe(true); + expect( + await pathExists( + join(mergedDir, "skills", "my-skill", ".git", "objects", "packfile"), + ), + ).toBe(false); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/src/utils/claude-config.ts b/src/utils/claude-config.ts index 36f35c85d..ae663679d 100644 --- a/src/utils/claude-config.ts +++ b/src/utils/claude-config.ts @@ -1,7 +1,10 @@ import { homedir } from "os"; import { join } from "path"; import { mkdir, rm } from "fs/promises"; -import { copyDir, pathExists } from "./copy"; +import { copyDir, copyFile, isDirectory, pathExists } from "./copy"; + +const CLAUDE_CONFIG_MERGE_EXCLUDES = [".git"]; +const CLAUDE_CONFIG_SYNC_ENTRIES = ["agents", "skills", "commands"] as const; export interface PrepareClaudeConfigOptions { homeDir?: string; @@ -13,7 +16,7 @@ export interface PrepareClaudeConfigOptions { * * Precedence (low -> high): * 1) ~/.atomic/.claude (Atomic-managed defaults) - * 2) ~/.claude (legacy user config) + * 2) ~/.claude/{agents,skills,commands} (legacy user config) * * @returns merged directory path, or null when ~/.atomic/.claude is missing */ @@ -22,22 +25,45 @@ export async function prepareClaudeConfigDir( ): Promise { const homeDir = options.homeDir ?? homedir(); const atomicBaseDir = join(homeDir, ".atomic", ".claude"); - const mergedDir = - options.mergedDir ?? join(homeDir, ".atomic", ".tmp", "claude-config-merged"); + const mergedDir = options.mergedDir ?? atomicBaseDir; + const stagingDir = join(homeDir, ".atomic", ".tmp", "claude-config-merge-staging"); if (!(await pathExists(atomicBaseDir))) { return null; } - await rm(mergedDir, { recursive: true, force: true }); - await mkdir(mergedDir, { recursive: true }); + await rm(stagingDir, { recursive: true, force: true }); + await mkdir(stagingDir, { recursive: true }); - await copyDir(atomicBaseDir, mergedDir); + await copyDir(atomicBaseDir, stagingDir, { + exclude: CLAUDE_CONFIG_MERGE_EXCLUDES, + }); const legacyUserDir = join(homeDir, ".claude"); if (await pathExists(legacyUserDir)) { - await copyDir(legacyUserDir, mergedDir); + for (const entryName of CLAUDE_CONFIG_SYNC_ENTRIES) { + const sourcePath = join(legacyUserDir, entryName); + if (!(await pathExists(sourcePath))) { + continue; + } + + const destinationPath = join(stagingDir, entryName); + if (await isDirectory(sourcePath)) { + await copyDir(sourcePath, destinationPath, { + exclude: CLAUDE_CONFIG_MERGE_EXCLUDES, + }); + } else { + await copyFile(sourcePath, destinationPath); + } + } } + await rm(mergedDir, { recursive: true, force: true }); + await mkdir(mergedDir, { recursive: true }); + await copyDir(stagingDir, mergedDir, { + exclude: CLAUDE_CONFIG_MERGE_EXCLUDES, + }); + await rm(stagingDir, { recursive: true, force: true }); + return mergedDir; }