/** * Utilities for managing shell configuration files (like .bashrc, .zshrc) * Used for managing claude aliases and PATH entries */ import { open, readFile, stat } from 'fs/promises' import { homedir as osHomedir } from 'os' import { join } from 'path' import { isFsInaccessible } from './errors.js' import { getLocalClaudePath } from './localInstaller.js' export const CLAUDE_ALIAS_REGEX = /^\s*alias\s+claude\s*=/ type EnvLike = Record type ShellConfigOptions = { env?: EnvLike homedir?: string } /** * Get the paths to shell configuration files * Respects ZDOTDIR for zsh users * @param options Optional overrides for testing (env, homedir) */ export function getShellConfigPaths( options?: ShellConfigOptions, ): Record { const home = options?.homedir ?? osHomedir() const env = options?.env ?? process.env const zshConfigDir = env.ZDOTDIR || home return { zsh: join(zshConfigDir, '.zshrc'), bash: join(home, '.bashrc'), fish: join(home, '.config/fish/config.fish'), } } /** * Filter out installer-created claude aliases from an array of lines * Only removes aliases pointing to $HOME/.claude/local/claude * Preserves custom user aliases that point to other locations * Returns the filtered lines and whether our default installer alias was found */ export function filterClaudeAliases(lines: string[]): { filtered: string[] hadAlias: boolean } { let hadAlias = false const filtered = lines.filter(line => { // Check if this is a claude alias if (CLAUDE_ALIAS_REGEX.test(line)) { // Extract the alias target - handle spaces, quotes, and various formats // First try with quotes let match = line.match(/alias\s+claude\s*=\s*["']([^"']+)["']/) if (!match) { // Try without quotes (capturing until end of line or comment) match = line.match(/alias\s+claude\s*=\s*([^#\n]+)/) } if (match && match[1]) { const target = match[1].trim() // Only remove if it points to the installer location // The installer always creates aliases with the full expanded path if (target === getLocalClaudePath()) { hadAlias = true return false // Remove this line } } // Keep custom aliases that don't point to the installer location } return true }) return { filtered, hadAlias } } /** * Read a file and split it into lines * Returns null if file doesn't exist or can't be read */ export async function readFileLines( filePath: string, ): Promise { try { const content = await readFile(filePath, { encoding: 'utf8' }) return content.split('\n') } catch (e: unknown) { if (isFsInaccessible(e)) return null throw e } } /** * Write lines back to a file */ export async function writeFileLines( filePath: string, lines: string[], ): Promise { const fh = await open(filePath, 'w') try { await fh.writeFile(lines.join('\n'), { encoding: 'utf8' }) await fh.datasync() } finally { await fh.close() } } /** * Check if a claude alias exists in any shell config file * Returns the alias target if found, null otherwise * @param options Optional overrides for testing (env, homedir) */ export async function findClaudeAlias( options?: ShellConfigOptions, ): Promise { const configs = getShellConfigPaths(options) for (const configPath of Object.values(configs)) { const lines = await readFileLines(configPath) if (!lines) continue for (const line of lines) { if (CLAUDE_ALIAS_REGEX.test(line)) { // Extract the alias target const match = line.match(/alias\s+claude=["']?([^"'\s]+)/) if (match && match[1]) { return match[1] } } } } return null } /** * Check if a claude alias exists and points to a valid executable * Returns the alias target if valid, null otherwise * @param options Optional overrides for testing (env, homedir) */ export async function findValidClaudeAlias( options?: ShellConfigOptions, ): Promise { const aliasTarget = await findClaudeAlias(options) if (!aliasTarget) return null const home = options?.homedir ?? osHomedir() // Expand ~ to home directory const expandedPath = aliasTarget.startsWith('~') ? aliasTarget.replace('~', home) : aliasTarget // Check if the target exists and is executable try { const stats = await stat(expandedPath) // Check if it's a file (could be executable or symlink) if (stats.isFile() || stats.isSymbolicLink()) { return aliasTarget } } catch { // Target doesn't exist or can't be accessed } return null }