Repository Public
1/**2 * Where Claude Code keeps its configuration, and how to run the `claude` binary.3 * Everything is injectable so tests never read or write the real `~/.claude*`.4 *5 * Observed with claude 2.1.x: with CLAUDE_CONFIG_DIR set, the global config file is6 * `$CLAUDE_CONFIG_DIR/.claude.json`; otherwise it is `$HOME/.claude.json` and the config7 * directory is `$HOME/.claude`.8 */9import { promises as fsp } from 'node:fs';10import os from 'node:os';11import path from 'node:path';12import type { RuntimeConfig } from './config.js';13import { run, type RunOptions, type RunResult } from './fsx-exec.js';1415export interface ClaudeEnv {16 home: string;17 /** `~/.claude` or `$CLAUDE_CONFIG_DIR`. */18 configDir: string;19 /** Global config file holding user/local MCP servers. */20 claudeJson: string;21 /** Codex home (`$CODEX_HOME` or `~/.codex`); used by the codex profile only. */22 codexHome: string;23 /** Environment for agent CLI child processes. */24 childEnv: NodeJS.ProcessEnv;25 /** Absolute binary path override (tests); otherwise resolved from config.claudeBin. */26 claudeBin?: string;27}2829export function defaultClaudeEnv(env: NodeJS.ProcessEnv = process.env): ClaudeEnv {30 const home = env.HOME || os.homedir();31 const custom = env.CLAUDE_CONFIG_DIR;32 const configDir = custom ? path.resolve(custom) : path.join(home, '.claude');33 return {34 home,35 configDir,36 claudeJson: custom ? path.join(configDir, '.claude.json') : path.join(home, '.claude.json'),37 codexHome: env.CODEX_HOME && env.CODEX_HOME.trim() !== '' ? path.resolve(env.CODEX_HOME) : path.join(home, '.codex'),38 childEnv: { ...env },39 };40}4142/** Isolated environment (tests): HOME, CLAUDE_CONFIG_DIR and CODEX_HOME all point into `home`. */43export function isolatedClaudeEnv(home: string, base: NodeJS.ProcessEnv = process.env): ClaudeEnv {44 const configDir = path.join(home, '.claude');45 return {46 home,47 configDir,48 claudeJson: path.join(configDir, '.claude.json'),49 codexHome: path.join(home, '.codex'),50 childEnv: { ...base, HOME: home, CLAUDE_CONFIG_DIR: configDir, CODEX_HOME: path.join(home, '.codex') },51 };52}5354/** Replace the home directory prefix by `~` for display labels. */55export function tildify(p: string, home: string): string {56 if (p === home) return '~';57 return p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;58}5960export interface ClaudeBinary {61 path: string | null;62 /** PATH of the user's login shell (so `claude` finds the same tools as in a terminal). */63 loginPath: string | null;64}6566const binCache = new Map<string, Promise<ClaudeBinary>>();6768async function isExecutable(p: string): Promise<boolean> {69 try {70 const st = await fsp.stat(p);71 if (!st.isFile()) return false;72 await fsp.access(p, fsp.constants.X_OK);73 return true;74 } catch {75 return false;76 }77}7879/** Resolve `config.claudeBin` once (login-shell PATH lookup, then well-known locations). */80export function resolveClaudeBinary(config: RuntimeConfig, env: ClaudeEnv): Promise<ClaudeBinary> {81 if (env.claudeBin) return Promise.resolve({ path: env.claudeBin, loginPath: null });82 const key = `${config.shell}\0${config.claudeBin}`;83 let cached = binCache.get(key);84 if (!cached) {85 cached = (async (): Promise<ClaudeBinary> => {86 const bin = config.claudeBin;87 // The binary name is passed as a positional parameter, never interpolated in the script.88 // Prefixed lines so noise printed by shell rc files cannot be mistaken for the answer.89 const script = 'printf "CR_PATH=%s\\n" "$PATH"; printf "CR_BIN=%s\\n" "$(command -v -- "$1")"';90 const r = await run(config.shell, ['-lc', script, 'cr-which', bin], { env: process.env, timeoutMs: 10_000, maxStdout: 64 * 1024 });91 const lines = r.stdout.split('\n');92 const pick = (prefix: string): string | null => {93 const line = lines.find((l) => l.startsWith(prefix));94 return line ? line.slice(prefix.length).trim() || null : null;95 };96 const loginPath = pick('CR_PATH=');97 const found = pick('CR_BIN=');98 if (path.isAbsolute(bin) && (await isExecutable(bin))) return { path: bin, loginPath };99 if (found && path.isAbsolute(found) && (await isExecutable(found))) return { path: found, loginPath };100 if (!bin.includes('/')) {101 const home = os.homedir();102 for (const dir of ['/opt/homebrew/bin', '/usr/local/bin', path.join(home, '.local', 'bin'), path.join(home, '.claude', 'local')]) {103 const candidate = path.join(dir, bin);104 if (await isExecutable(candidate)) return { path: candidate, loginPath };105 }106 }107 return { path: null, loginPath };108 })();109 binCache.set(key, cached);110 }111 return cached;112}113114/** Run `claude <argv>` with the resolved binary. argv is never logged. Returns null when no binary. */115export async function runClaude(116 config: RuntimeConfig,117 env: ClaudeEnv,118 argv: readonly string[],119 opts: Pick<RunOptions, 'timeoutMs' | 'cwd' | 'maxStdout'>,120): Promise<RunResult | null> {121 const bin = await resolveClaudeBinary(config, env);122 if (!bin.path) return null;123 const childEnv: NodeJS.ProcessEnv = { ...env.childEnv, NO_COLOR: '1', FORCE_COLOR: '0' };124 if (bin.loginPath) childEnv.PATH = bin.loginPath;125 // Never let a nested call believe it runs inside an interactive Claude Code session.126 delete childEnv.CLAUDECODE;127 delete childEnv.CLAUDE_CODE_ENTRYPOINT;128 return run(bin.path, argv, { ...opts, env: childEnv });129}130