SPB Git

Repository Public

fsx-claude-env.ts 5.3 KB 130 lines typescript

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
Files main
Menu

Theme