shellConfig.ts
1 /** 2 * Utilities for managing shell configuration files (like .bashrc, .zshrc) 3 * Used for managing claude aliases and PATH entries 4 */ 5 6 import { open, readFile, stat } from 'fs/promises' 7 import { homedir as osHomedir } from 'os' 8 import { join } from 'path' 9 import { isFsInaccessible } from './errors.js' 10 import { getLocalClaudePath } from './localInstaller.js' 11 12 export const CLAUDE_ALIAS_REGEX = /^\s*alias\s+claude\s*=/ 13 14 type EnvLike = Record<string, string | undefined> 15 16 type ShellConfigOptions = { 17 env?: EnvLike 18 homedir?: string 19 } 20 21 /** 22 * Get the paths to shell configuration files 23 * Respects ZDOTDIR for zsh users 24 * @param options Optional overrides for testing (env, homedir) 25 */ 26 export function getShellConfigPaths( 27 options?: ShellConfigOptions, 28 ): Record<string, string> { 29 const home = options?.homedir ?? osHomedir() 30 const env = options?.env ?? process.env 31 const zshConfigDir = env.ZDOTDIR || home 32 return { 33 zsh: join(zshConfigDir, '.zshrc'), 34 bash: join(home, '.bashrc'), 35 fish: join(home, '.config/fish/config.fish'), 36 } 37 } 38 39 /** 40 * Filter out installer-created claude aliases from an array of lines 41 * Only removes aliases pointing to $HOME/.claude/local/claude 42 * Preserves custom user aliases that point to other locations 43 * Returns the filtered lines and whether our default installer alias was found 44 */ 45 export function filterClaudeAliases(lines: string[]): { 46 filtered: string[] 47 hadAlias: boolean 48 } { 49 let hadAlias = false 50 const filtered = lines.filter(line => { 51 // Check if this is a claude alias 52 if (CLAUDE_ALIAS_REGEX.test(line)) { 53 // Extract the alias target - handle spaces, quotes, and various formats 54 // First try with quotes 55 let match = line.match(/alias\s+claude\s*=\s*["']([^"']+)["']/) 56 if (!match) { 57 // Try without quotes (capturing until end of line or comment) 58 match = line.match(/alias\s+claude\s*=\s*([^#\n]+)/) 59 } 60 61 if (match && match[1]) { 62 const target = match[1].trim() 63 // Only remove if it points to the installer location 64 // The installer always creates aliases with the full expanded path 65 if (target === getLocalClaudePath()) { 66 hadAlias = true 67 return false // Remove this line 68 } 69 } 70 // Keep custom aliases that don't point to the installer location 71 } 72 return true 73 }) 74 return { filtered, hadAlias } 75 } 76 77 /** 78 * Read a file and split it into lines 79 * Returns null if file doesn't exist or can't be read 80 */ 81 export async function readFileLines( 82 filePath: string, 83 ): Promise<string[] | null> { 84 try { 85 const content = await readFile(filePath, { encoding: 'utf8' }) 86 return content.split('\n') 87 } catch (e: unknown) { 88 if (isFsInaccessible(e)) return null 89 throw e 90 } 91 } 92 93 /** 94 * Write lines back to a file 95 */ 96 export async function writeFileLines( 97 filePath: string, 98 lines: string[], 99 ): Promise<void> { 100 const fh = await open(filePath, 'w') 101 try { 102 await fh.writeFile(lines.join('\n'), { encoding: 'utf8' }) 103 await fh.datasync() 104 } finally { 105 await fh.close() 106 } 107 } 108 109 /** 110 * Check if a claude alias exists in any shell config file 111 * Returns the alias target if found, null otherwise 112 * @param options Optional overrides for testing (env, homedir) 113 */ 114 export async function findClaudeAlias( 115 options?: ShellConfigOptions, 116 ): Promise<string | null> { 117 const configs = getShellConfigPaths(options) 118 119 for (const configPath of Object.values(configs)) { 120 const lines = await readFileLines(configPath) 121 if (!lines) continue 122 123 for (const line of lines) { 124 if (CLAUDE_ALIAS_REGEX.test(line)) { 125 // Extract the alias target 126 const match = line.match(/alias\s+claude=["']?([^"'\s]+)/) 127 if (match && match[1]) { 128 return match[1] 129 } 130 } 131 } 132 } 133 134 return null 135 } 136 137 /** 138 * Check if a claude alias exists and points to a valid executable 139 * Returns the alias target if valid, null otherwise 140 * @param options Optional overrides for testing (env, homedir) 141 */ 142 export async function findValidClaudeAlias( 143 options?: ShellConfigOptions, 144 ): Promise<string | null> { 145 const aliasTarget = await findClaudeAlias(options) 146 if (!aliasTarget) return null 147 148 const home = options?.homedir ?? osHomedir() 149 150 // Expand ~ to home directory 151 const expandedPath = aliasTarget.startsWith('~') 152 ? aliasTarget.replace('~', home) 153 : aliasTarget 154 155 // Check if the target exists and is executable 156 try { 157 const stats = await stat(expandedPath) 158 // Check if it's a file (could be executable or symlink) 159 if (stats.isFile() || stats.isSymbolicLink()) { 160 return aliasTarget 161 } 162 } catch { 163 // Target doesn't exist or can't be accessed 164 } 165 166 return null 167 }