/ utils / completionCache.ts
completionCache.ts
  1  import chalk from 'chalk'
  2  import { mkdir, readFile, writeFile } from 'fs/promises'
  3  import { homedir } from 'os'
  4  import { dirname, join } from 'path'
  5  import { pathToFileURL } from 'url'
  6  import { color } from '../components/design-system/color.js'
  7  import { supportsHyperlinks } from '../ink/supports-hyperlinks.js'
  8  import { logForDebugging } from './debug.js'
  9  import { isENOENT } from './errors.js'
 10  import { execFileNoThrow } from './execFileNoThrow.js'
 11  import { logError } from './log.js'
 12  import type { ThemeName } from './theme.js'
 13  
 14  const EOL = '\n'
 15  
 16  type ShellInfo = {
 17    name: string
 18    rcFile: string
 19    cacheFile: string
 20    completionLine: string
 21    shellFlag: string
 22  }
 23  
 24  function detectShell(): ShellInfo | null {
 25    const shell = process.env.SHELL || ''
 26    const home = homedir()
 27    const claudeDir = join(home, '.claude')
 28  
 29    if (shell.endsWith('/zsh') || shell.endsWith('/zsh.exe')) {
 30      const cacheFile = join(claudeDir, 'completion.zsh')
 31      return {
 32        name: 'zsh',
 33        rcFile: join(home, '.zshrc'),
 34        cacheFile,
 35        completionLine: `[[ -f "${cacheFile}" ]] && source "${cacheFile}"`,
 36        shellFlag: 'zsh',
 37      }
 38    }
 39    if (shell.endsWith('/bash') || shell.endsWith('/bash.exe')) {
 40      const cacheFile = join(claudeDir, 'completion.bash')
 41      return {
 42        name: 'bash',
 43        rcFile: join(home, '.bashrc'),
 44        cacheFile,
 45        completionLine: `[ -f "${cacheFile}" ] && source "${cacheFile}"`,
 46        shellFlag: 'bash',
 47      }
 48    }
 49    if (shell.endsWith('/fish') || shell.endsWith('/fish.exe')) {
 50      const xdg = process.env.XDG_CONFIG_HOME || join(home, '.config')
 51      const cacheFile = join(claudeDir, 'completion.fish')
 52      return {
 53        name: 'fish',
 54        rcFile: join(xdg, 'fish', 'config.fish'),
 55        cacheFile,
 56        completionLine: `[ -f "${cacheFile}" ] && source "${cacheFile}"`,
 57        shellFlag: 'fish',
 58      }
 59    }
 60    return null
 61  }
 62  
 63  function formatPathLink(filePath: string): string {
 64    if (!supportsHyperlinks()) {
 65      return filePath
 66    }
 67    const fileUrl = pathToFileURL(filePath).href
 68    return `\x1b]8;;${fileUrl}\x07${filePath}\x1b]8;;\x07`
 69  }
 70  
 71  /**
 72   * Generate and cache the completion script, then add a source line to the
 73   * shell's rc file. Returns a user-facing status message.
 74   */
 75  export async function setupShellCompletion(theme: ThemeName): Promise<string> {
 76    const shell = detectShell()
 77    if (!shell) {
 78      return ''
 79    }
 80  
 81    // Ensure the cache directory exists
 82    try {
 83      await mkdir(dirname(shell.cacheFile), { recursive: true })
 84    } catch (e: unknown) {
 85      logError(e)
 86      return `${EOL}${color('warning', theme)(`Could not write ${shell.name} completion cache`)}${EOL}${chalk.dim(`Run manually: claude completion ${shell.shellFlag} > ${shell.cacheFile}`)}${EOL}`
 87    }
 88  
 89    // Generate the completion script by writing directly to the cache file.
 90    // Using --output avoids piping through stdout where process.exit() can
 91    // truncate output before the pipe buffer drains.
 92    const claudeBin = process.argv[1] || 'claude'
 93    const result = await execFileNoThrow(claudeBin, [
 94      'completion',
 95      shell.shellFlag,
 96      '--output',
 97      shell.cacheFile,
 98    ])
 99    if (result.code !== 0) {
100      return `${EOL}${color('warning', theme)(`Could not generate ${shell.name} shell completions`)}${EOL}${chalk.dim(`Run manually: claude completion ${shell.shellFlag} > ${shell.cacheFile}`)}${EOL}`
101    }
102  
103    // Check if rc file already sources completions
104    let existing = ''
105    try {
106      existing = await readFile(shell.rcFile, { encoding: 'utf-8' })
107      if (
108        existing.includes('claude completion') ||
109        existing.includes(shell.cacheFile)
110      ) {
111        return `${EOL}${color('success', theme)(`Shell completions updated for ${shell.name}`)}${EOL}${chalk.dim(`See ${formatPathLink(shell.rcFile)}`)}${EOL}`
112      }
113    } catch (e: unknown) {
114      if (!isENOENT(e)) {
115        logError(e)
116        return `${EOL}${color('warning', theme)(`Could not install ${shell.name} shell completions`)}${EOL}${chalk.dim(`Add this to ${formatPathLink(shell.rcFile)}:`)}${EOL}${chalk.dim(shell.completionLine)}${EOL}`
117      }
118    }
119  
120    // Append source line to rc file
121    try {
122      const configDir = dirname(shell.rcFile)
123      await mkdir(configDir, { recursive: true })
124  
125      const separator = existing && !existing.endsWith('\n') ? '\n' : ''
126      const content = `${existing}${separator}\n# Claude Code shell completions\n${shell.completionLine}\n`
127      await writeFile(shell.rcFile, content, { encoding: 'utf-8' })
128  
129      return `${EOL}${color('success', theme)(`Installed ${shell.name} shell completions`)}${EOL}${chalk.dim(`Added to ${formatPathLink(shell.rcFile)}`)}${EOL}${chalk.dim(`Run: source ${shell.rcFile}`)}${EOL}`
130    } catch (error) {
131      logError(error)
132      return `${EOL}${color('warning', theme)(`Could not install ${shell.name} shell completions`)}${EOL}${chalk.dim(`Add this to ${formatPathLink(shell.rcFile)}:`)}${EOL}${chalk.dim(shell.completionLine)}${EOL}`
133    }
134  }
135  
136  /**
137   * Regenerate cached shell completion scripts in ~/.claude/.
138   * Called after `claude update` so completions stay in sync with the new binary.
139   */
140  export async function regenerateCompletionCache(): Promise<void> {
141    const shell = detectShell()
142    if (!shell) {
143      return
144    }
145  
146    logForDebugging(`update: Regenerating ${shell.name} completion cache`)
147  
148    const claudeBin = process.argv[1] || 'claude'
149    const result = await execFileNoThrow(claudeBin, [
150      'completion',
151      shell.shellFlag,
152      '--output',
153      shell.cacheFile,
154    ])
155  
156    if (result.code !== 0) {
157      logForDebugging(
158        `update: Failed to regenerate ${shell.name} completion cache`,
159      )
160      return
161    }
162  
163    logForDebugging(
164      `update: Regenerated ${shell.name} completion cache at ${shell.cacheFile}`,
165    )
166  }