/ utils / shellConfig.ts
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  }