168 lines
4.6 KiB
TypeScript
168 lines
4.6 KiB
TypeScript
/**
|
|
* 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<string, string | undefined>
|
|
|
|
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<string, string> {
|
|
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<string[] | null> {
|
|
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<void> {
|
|
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<string | null> {
|
|
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<string | null> {
|
|
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
|
|
}
|