/** * Utilities for handling local installation */ import { access, chmod, writeFile } from 'fs/promises' import { join } from 'path' import { type ReleaseChannel, saveGlobalConfig } from './config.js' import { getClaudeConfigHomeDir } from './envUtils.js' import { getErrnoCode } from './errors.js' import { execFileNoThrowWithCwd } from './execFileNoThrow.js' import { getFsImplementation } from './fsOperations.js' import { logError } from './log.js' import { jsonStringify } from './slowOperations.js' // Lazy getters: getClaudeConfigHomeDir() is memoized and reads process.env. // Evaluating at module scope would capture the value before entrypoints like // hfi.tsx get a chance to set CLAUDE_CONFIG_DIR in main(), and would also // populate the memoize cache with that stale value for all 150+ other callers. function getLocalInstallDir(): string { return join(getClaudeConfigHomeDir(), 'local') } export function getLocalClaudePath(): string { return join(getLocalInstallDir(), 'claude') } /** * Check if we're running from our managed local installation */ export function isRunningFromLocalInstallation(): boolean { const execPath = process.argv[1] || '' return execPath.includes('/.claude/local/node_modules/') } /** * Write `content` to `path` only if the file does not already exist. * Uses O_EXCL ('wx') for atomic create-if-missing. */ async function writeIfMissing( path: string, content: string, mode?: number, ): Promise { try { await writeFile(path, content, { encoding: 'utf8', flag: 'wx', mode }) return true } catch (e) { if (getErrnoCode(e) === 'EEXIST') return false throw e } } /** * Ensure the local package environment is set up * Creates the directory, package.json, and wrapper script */ export async function ensureLocalPackageEnvironment(): Promise { try { const localInstallDir = getLocalInstallDir() // Create installation directory (recursive, idempotent) await getFsImplementation().mkdir(localInstallDir) // Create package.json if it doesn't exist await writeIfMissing( join(localInstallDir, 'package.json'), jsonStringify( { name: 'claude-local', version: '0.0.1', private: true }, null, 2, ), ) // Create the wrapper script if it doesn't exist const wrapperPath = join(localInstallDir, 'claude') const created = await writeIfMissing( wrapperPath, `#!/bin/sh\nexec "${localInstallDir}/node_modules/.bin/claude" "$@"`, 0o755, ) if (created) { // Mode in writeFile is masked by umask; chmod to ensure executable bit. await chmod(wrapperPath, 0o755) } return true } catch (error) { logError(error) return false } } /** * Install or update Claude CLI package in the local directory * @param channel - Release channel to use (latest or stable) * @param specificVersion - Optional specific version to install (overrides channel) */ export async function installOrUpdateClaudePackage( channel: ReleaseChannel, specificVersion?: string | null, ): Promise<'in_progress' | 'success' | 'install_failed'> { try { // First ensure the environment is set up if (!(await ensureLocalPackageEnvironment())) { return 'install_failed' } // Use specific version if provided, otherwise use channel tag const versionSpec = specificVersion ? specificVersion : channel === 'stable' ? 'stable' : 'latest' const result = await execFileNoThrowWithCwd( 'npm', ['install', `${MACRO.PACKAGE_URL}@${versionSpec}`], { cwd: getLocalInstallDir(), maxBuffer: 1000000 }, ) if (result.code !== 0) { const error = new Error( `Failed to install Claude CLI package: ${result.stderr}`, ) logError(error) return result.code === 190 ? 'in_progress' : 'install_failed' } // Set installMethod to 'local' to prevent npm permission warnings saveGlobalConfig(current => ({ ...current, installMethod: 'local', })) return 'success' } catch (error) { logError(error) return 'install_failed' } } /** * Check if local installation exists. * Pure existence probe — callers use this to choose update path / UI hints. */ export async function localInstallationExists(): Promise { try { await access(join(getLocalInstallDir(), 'node_modules', '.bin', 'claude')) return true } catch { return false } } /** * Get shell type to determine appropriate path setup */ export function getShellType(): string { const shellPath = process.env.SHELL || '' if (shellPath.includes('zsh')) return 'zsh' if (shellPath.includes('bash')) return 'bash' if (shellPath.includes('fish')) return 'fish' return 'unknown' }