/ utils / bash / ShellSnapshot.ts
ShellSnapshot.ts
  1  import { execFile } from 'child_process'
  2  import { execa } from 'execa'
  3  import { mkdir, stat } from 'fs/promises'
  4  import * as os from 'os'
  5  import { join } from 'path'
  6  import { logEvent } from 'src/services/analytics/index.js'
  7  import { registerCleanup } from '../cleanupRegistry.js'
  8  import { getCwd } from '../cwd.js'
  9  import { logForDebugging } from '../debug.js'
 10  import {
 11    embeddedSearchToolsBinaryPath,
 12    hasEmbeddedSearchTools,
 13  } from '../embeddedTools.js'
 14  import { getClaudeConfigHomeDir } from '../envUtils.js'
 15  import { pathExists } from '../file.js'
 16  import { getFsImplementation } from '../fsOperations.js'
 17  import { logError } from '../log.js'
 18  import { getPlatform } from '../platform.js'
 19  import { ripgrepCommand } from '../ripgrep.js'
 20  import { subprocessEnv } from '../subprocessEnv.js'
 21  import { quote } from './shellQuote.js'
 22  
 23  const LITERAL_BACKSLASH = '\\'
 24  const SNAPSHOT_CREATION_TIMEOUT = 10000 // 10 seconds
 25  
 26  /**
 27   * Creates a shell function that invokes `binaryPath` with a specific argv[0].
 28   * This uses the bun-internal ARGV0 dispatch trick: the bun binary checks its
 29   * argv[0] and runs the embedded tool (rg, bfs, ugrep) that matches.
 30   *
 31   * @param prependArgs - Arguments to inject before the user's args (e.g.,
 32   *   default flags). Injected literally; each element must be a valid shell
 33   *   word (no spaces/special chars).
 34   */
 35  function createArgv0ShellFunction(
 36    funcName: string,
 37    argv0: string,
 38    binaryPath: string,
 39    prependArgs: string[] = [],
 40  ): string {
 41    const quotedPath = quote([binaryPath])
 42    const argSuffix =
 43      prependArgs.length > 0 ? `${prependArgs.join(' ')} "$@"` : '"$@"'
 44    return [
 45      `function ${funcName} {`,
 46      '  if [[ -n $ZSH_VERSION ]]; then',
 47      `    ARGV0=${argv0} ${quotedPath} ${argSuffix}`,
 48      '  elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "win32" ]]; then',
 49      // On Windows (git bash), exec -a does not work, so use ARGV0 env var instead
 50      // The bun binary reads from ARGV0 natively to set argv[0]
 51      `    ARGV0=${argv0} ${quotedPath} ${argSuffix}`,
 52      '  elif [[ $BASHPID != $$ ]]; then',
 53      `    exec -a ${argv0} ${quotedPath} ${argSuffix}`,
 54      '  else',
 55      `    (exec -a ${argv0} ${quotedPath} ${argSuffix})`,
 56      '  fi',
 57      '}',
 58    ].join('\n')
 59  }
 60  
 61  /**
 62   * Creates ripgrep shell integration (alias or function)
 63   * @returns Object with type and the shell snippet to use
 64   */
 65  export function createRipgrepShellIntegration(): {
 66    type: 'alias' | 'function'
 67    snippet: string
 68  } {
 69    const rgCommand = ripgrepCommand()
 70  
 71    // For embedded ripgrep (bun-internal), we need a shell function that sets argv0
 72    if (rgCommand.argv0) {
 73      return {
 74        type: 'function',
 75        snippet: createArgv0ShellFunction(
 76          'rg',
 77          rgCommand.argv0,
 78          rgCommand.rgPath,
 79        ),
 80      }
 81    }
 82  
 83    // For regular ripgrep, use a simple alias target
 84    const quotedPath = quote([rgCommand.rgPath])
 85    const quotedArgs = rgCommand.rgArgs.map(arg => quote([arg]))
 86    const aliasTarget =
 87      rgCommand.rgArgs.length > 0
 88        ? `${quotedPath} ${quotedArgs.join(' ')}`
 89        : quotedPath
 90  
 91    return { type: 'alias', snippet: aliasTarget }
 92  }
 93  
 94  /**
 95   * VCS directories to exclude from grep searches. Matches the list in
 96   * GrepTool (see GrepTool.ts: VCS_DIRECTORIES_TO_EXCLUDE).
 97   */
 98  const VCS_DIRECTORIES_TO_EXCLUDE = [
 99    '.git',
100    '.svn',
101    '.hg',
102    '.bzr',
103    '.jj',
104    '.sl',
105  ] as const
106  
107  /**
108   * Creates shell integration for `find` and `grep`, backed by bfs and ugrep
109   * embedded in the bun binary (ant-native only). Unlike the rg integration,
110   * this always shadows the system find/grep since bfs/ugrep are drop-in
111   * replacements and we want consistent fast behavior.
112   *
113   * These wrappers replace the GlobTool/GrepTool dedicated tools (which are
114   * removed from the tool registry when embedded search tools are available),
115   * so they're tuned to match those tools' semantics, not GNU find/grep.
116   *
117   * `find` ↔ GlobTool:
118   * - Inject `-regextype findutils-default`: bfs defaults to POSIX BRE for
119   *   -regex, but GNU find defaults to emacs-flavor (which supports `\|`
120   *   alternation). Without this, `find . -regex '.*\.\(js\|ts\)'` silently
121   *   returns zero results. A later user-supplied -regextype still overrides.
122   * - No gitignore filtering: GlobTool passes `--no-ignore` to rg. bfs has no
123   *   gitignore support anyway, so this matches by default.
124   * - Hidden files included: both GlobTool (`--hidden`) and bfs's default.
125   *
126   * Caveat: even with findutils-default, Oniguruma (bfs's regex engine) uses
127   * leftmost-first alternation, not POSIX leftmost-longest. Patterns where
128   * one alternative is a prefix of another (e.g., `\(ts\|tsx\)`) may miss
129   * matches that GNU find catches. Workaround: put the longer alternative first.
130   *
131   * `grep` ↔ GrepTool (file filtering) + GNU grep (regex syntax):
132   * - `-G` (basic regex / BRE): GNU grep defaults to BRE where `\|` is
133   *   alternation. ugrep defaults to ERE where `|` is alternation and `\|` is a
134   *   literal pipe. Without -G, `grep "foo\|bar"` silently returns zero results.
135   *   User-supplied `-E`, `-F`, or `-P` later in argv overrides this.
136   * - `--ignore-files`: respect .gitignore (GrepTool uses rg's default, which
137   *   respects gitignore). Override with `grep --no-ignore-files`.
138   * - `--hidden`: include hidden files (GrepTool passes `--hidden` to rg).
139   *   Override with `grep --no-hidden`.
140   * - `--exclude-dir` for VCS dirs: GrepTool passes `--glob '!.git'` etc. to rg.
141   * - `-I`: skip binary files. rg's recursion silently skips binary matches
142   *   by default (different from direct-file-arg behavior); ugrep doesn't, so
143   *   we inject -I to match. Override with `grep -a`.
144   *
145   * Not replicated from GrepTool:
146   * - `--max-columns 500`: ugrep's `--width` hard-truncates output which could
147   *   break pipelines; rg's version replaces the line with a placeholder.
148   * - Read deny rules / plugin cache exclusions: require toolPermissionContext
149   *   which isn't available at shell-snapshot creation time.
150   *
151   * Returns null if embedded search tools are not available in this build.
152   */
153  export function createFindGrepShellIntegration(): string | null {
154    if (!hasEmbeddedSearchTools()) {
155      return null
156    }
157    const binaryPath = embeddedSearchToolsBinaryPath()
158    return [
159      // User shell configs may define aliases like `alias find=gfind` or
160      // `alias grep=ggrep` (common on macOS with Homebrew GNU tools). The
161      // snapshot sources user aliases before these function definitions, and
162      // bash expands aliases before function lookup — so a renaming alias
163      // would silently bypass the embedded bfs/ugrep dispatch. Clear them first
164      // (same fix the rg integration uses).
165      'unalias find 2>/dev/null || true',
166      'unalias grep 2>/dev/null || true',
167      createArgv0ShellFunction('find', 'bfs', binaryPath, [
168        '-regextype',
169        'findutils-default',
170      ]),
171      createArgv0ShellFunction('grep', 'ugrep', binaryPath, [
172        '-G',
173        '--ignore-files',
174        '--hidden',
175        '-I',
176        ...VCS_DIRECTORIES_TO_EXCLUDE.map(d => `--exclude-dir=${d}`),
177      ]),
178    ].join('\n')
179  }
180  
181  function getConfigFile(shellPath: string): string {
182    const fileName = shellPath.includes('zsh')
183      ? '.zshrc'
184      : shellPath.includes('bash')
185        ? '.bashrc'
186        : '.profile'
187  
188    const configPath = join(os.homedir(), fileName)
189  
190    return configPath
191  }
192  
193  /**
194   * Generates user-specific snapshot content (functions, options, aliases)
195   * This content is derived from the user's shell configuration file
196   */
197  function getUserSnapshotContent(configFile: string): string {
198    const isZsh = configFile.endsWith('.zshrc')
199  
200    let content = ''
201  
202    // User functions
203    if (isZsh) {
204      content += `
205        echo "# Functions" >> "$SNAPSHOT_FILE"
206  
207        # Force autoload all functions first
208        typeset -f > /dev/null 2>&1
209  
210        # Now get user function names - filter completion functions (single underscore prefix)
211        # but keep double-underscore helpers (e.g. __zsh_like_cd from mise, __pyenv_init)
212        typeset +f | grep -vE '^_[^_]' | while read func; do
213          typeset -f "$func" >> "$SNAPSHOT_FILE"
214        done
215      `
216    } else {
217      content += `
218        echo "# Functions" >> "$SNAPSHOT_FILE"
219  
220        # Force autoload all functions first
221        declare -f > /dev/null 2>&1
222  
223        # Now get user function names - filter completion functions (single underscore prefix)
224        # but keep double-underscore helpers (e.g. __zsh_like_cd from mise, __pyenv_init)
225        declare -F | cut -d' ' -f3 | grep -vE '^_[^_]' | while read func; do
226          # Encode the function to base64, preserving all special characters
227          encoded_func=$(declare -f "$func" | base64 )
228          # Write the function definition to the snapshot
229          echo "eval ${LITERAL_BACKSLASH}"${LITERAL_BACKSLASH}$(echo '$encoded_func' | base64 -d)${LITERAL_BACKSLASH}" > /dev/null 2>&1" >> "$SNAPSHOT_FILE"
230        done
231      `
232    }
233  
234    // Shell options
235    if (isZsh) {
236      content += `
237        echo "# Shell Options" >> "$SNAPSHOT_FILE"
238        setopt | sed 's/^/setopt /' | head -n 1000 >> "$SNAPSHOT_FILE"
239      `
240    } else {
241      content += `
242        echo "# Shell Options" >> "$SNAPSHOT_FILE"
243        shopt -p | head -n 1000 >> "$SNAPSHOT_FILE"
244        set -o | grep "on" | awk '{print "set -o " $1}' | head -n 1000 >> "$SNAPSHOT_FILE"
245        echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"
246      `
247    }
248  
249    // User aliases
250    content += `
251        echo "# Aliases" >> "$SNAPSHOT_FILE"
252        # Filter out winpty aliases on Windows to avoid "stdin is not a tty" errors
253        # Git Bash automatically creates aliases like "alias node='winpty node.exe'" for
254        # programs that need Win32 Console in mintty, but winpty fails when there's no TTY
255        if [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]]; then
256          alias | grep -v "='winpty " | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE"
257        else
258          alias | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE"
259        fi
260    `
261  
262    return content
263  }
264  
265  /**
266   * Generates Claude Code specific snapshot content
267   * This content is always included regardless of user configuration
268   */
269  async function getClaudeCodeSnapshotContent(): Promise<string> {
270    // Get the appropriate PATH based on platform
271    let pathValue = process.env.PATH
272    if (getPlatform() === 'windows') {
273      // On Windows with git-bash, read the Cygwin PATH
274      const cygwinResult = await execa('echo $PATH', {
275        shell: true,
276        reject: false,
277      })
278      if (cygwinResult.exitCode === 0 && cygwinResult.stdout) {
279        pathValue = cygwinResult.stdout.trim()
280      }
281      // Fall back to process.env.PATH if we can't get Cygwin PATH
282    }
283  
284    const rgIntegration = createRipgrepShellIntegration()
285  
286    let content = ''
287  
288    // Check if rg is available, if not create an alias/function to bundled ripgrep
289    // We use a subshell to unalias rg before checking, so that user aliases like
290    // `alias rg='rg --smart-case'` don't shadow the real binary check. The subshell
291    // ensures we don't modify the user's aliases in the parent shell.
292    content += `
293        # Check for rg availability
294        echo "# Check for rg availability" >> "$SNAPSHOT_FILE"
295        echo "if ! (unalias rg 2>/dev/null; command -v rg) >/dev/null 2>&1; then" >> "$SNAPSHOT_FILE"
296    `
297  
298    if (rgIntegration.type === 'function') {
299      // For embedded ripgrep, write the function definition using heredoc
300      content += `
301        cat >> "$SNAPSHOT_FILE" << 'RIPGREP_FUNC_END'
302    ${rgIntegration.snippet}
303  RIPGREP_FUNC_END
304      `
305    } else {
306      // For regular ripgrep, write a simple alias
307      const escapedSnippet = rgIntegration.snippet.replace(/'/g, "'\\''")
308      content += `
309        echo '  alias rg='"'${escapedSnippet}'" >> "$SNAPSHOT_FILE"
310      `
311    }
312  
313    content += `
314        echo "fi" >> "$SNAPSHOT_FILE"
315    `
316  
317    // For ant-native builds, shadow find/grep with bfs/ugrep embedded in the bun
318    // binary. Unlike rg (which only activates if system rg is absent), we always
319    // shadow find/grep since bfs/ugrep are drop-in replacements and we want
320    // consistent fast behavior in Claude's shell.
321    const findGrepIntegration = createFindGrepShellIntegration()
322    if (findGrepIntegration !== null) {
323      content += `
324        # Shadow find/grep with embedded bfs/ugrep (ant-native only)
325        echo "# Shadow find/grep with embedded bfs/ugrep" >> "$SNAPSHOT_FILE"
326        cat >> "$SNAPSHOT_FILE" << 'FIND_GREP_FUNC_END'
327  ${findGrepIntegration}
328  FIND_GREP_FUNC_END
329      `
330    }
331  
332    // Add PATH to the file
333    content += `
334  
335        # Add PATH to the file
336        echo "export PATH=${quote([pathValue || ''])}" >> "$SNAPSHOT_FILE"
337    `
338  
339    return content
340  }
341  
342  /**
343   * Creates the appropriate shell script for capturing environment
344   */
345  async function getSnapshotScript(
346    shellPath: string,
347    snapshotFilePath: string,
348    configFileExists: boolean,
349  ): Promise<string> {
350    const configFile = getConfigFile(shellPath)
351    const isZsh = configFile.endsWith('.zshrc')
352  
353    // Generate the user content and Claude Code content
354    const userContent = configFileExists
355      ? getUserSnapshotContent(configFile)
356      : !isZsh
357        ? // we need to manually force alias expansion in bash - normally `getUserSnapshotContent` takes care of this
358          'echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"'
359        : ''
360    const claudeCodeContent = await getClaudeCodeSnapshotContent()
361  
362    const script = `SNAPSHOT_FILE=${quote([snapshotFilePath])}
363        ${configFileExists ? `source "${configFile}" < /dev/null` : '# No user config file to source'}
364  
365        # First, create/clear the snapshot file
366        echo "# Snapshot file" >| "$SNAPSHOT_FILE"
367  
368        # When this file is sourced, we first unalias to avoid conflicts
369        # This is necessary because aliases get "frozen" inside function definitions at definition time,
370        # which can cause unexpected behavior when functions use commands that conflict with aliases
371        echo "# Unset all aliases to avoid conflicts with functions" >> "$SNAPSHOT_FILE"
372        echo "unalias -a 2>/dev/null || true" >> "$SNAPSHOT_FILE"
373  
374        ${userContent}
375  
376        ${claudeCodeContent}
377  
378        # Exit silently on success, only report errors
379        if [ ! -f "$SNAPSHOT_FILE" ]; then
380          echo "Error: Snapshot file was not created at $SNAPSHOT_FILE" >&2
381          exit 1
382        fi
383      `
384  
385    return script
386  }
387  
388  /**
389   * Creates and saves the shell environment snapshot by loading the user's shell configuration
390   *
391   * This function is a critical part of Claude CLI's shell integration strategy. It:
392   *
393   * 1. Identifies the user's shell config file (.zshrc, .bashrc, etc.)
394   * 2. Creates a temporary script that sources this configuration file
395   * 3. Captures the resulting shell environment state including:
396   *    - Functions defined in the user's shell configuration
397   *    - Shell options and settings that affect command behavior
398   *    - Aliases that the user has defined
399   *
400   * The snapshot is saved to a temporary file that can be sourced by subsequent shell
401   * commands, ensuring they run with the user's expected environment, aliases, and functions.
402   *
403   * This approach allows Claude CLI to execute commands as if they were run in the user's
404   * interactive shell, while avoiding the overhead of creating a new login shell for each command.
405   * It handles both Bash and Zsh shells with their different syntax for functions, options, and aliases.
406   *
407   * If the snapshot creation fails (e.g., timeout, permissions issues), the CLI will still
408   * function but without the user's custom shell environment, potentially missing aliases
409   * and functions the user relies on.
410   *
411   * @returns Promise that resolves to the snapshot file path or undefined if creation failed
412   */
413  export const createAndSaveSnapshot = async (
414    binShell: string,
415  ): Promise<string | undefined> => {
416    const shellType = binShell.includes('zsh')
417      ? 'zsh'
418      : binShell.includes('bash')
419        ? 'bash'
420        : 'sh'
421  
422    logForDebugging(`Creating shell snapshot for ${shellType} (${binShell})`)
423  
424    return new Promise(async resolve => {
425      try {
426        const configFile = getConfigFile(binShell)
427        logForDebugging(`Looking for shell config file: ${configFile}`)
428        const configFileExists = await pathExists(configFile)
429  
430        if (!configFileExists) {
431          logForDebugging(
432            `Shell config file not found: ${configFile}, creating snapshot with Claude Code defaults only`,
433          )
434        }
435  
436        // Create unique snapshot path with timestamp and random ID
437        const timestamp = Date.now()
438        const randomId = Math.random().toString(36).substring(2, 8)
439        const snapshotsDir = join(getClaudeConfigHomeDir(), 'shell-snapshots')
440        logForDebugging(`Snapshots directory: ${snapshotsDir}`)
441        const shellSnapshotPath = join(
442          snapshotsDir,
443          `snapshot-${shellType}-${timestamp}-${randomId}.sh`,
444        )
445  
446        // Ensure snapshots directory exists
447        await mkdir(snapshotsDir, { recursive: true })
448  
449        const snapshotScript = await getSnapshotScript(
450          binShell,
451          shellSnapshotPath,
452          configFileExists,
453        )
454        logForDebugging(`Creating snapshot at: ${shellSnapshotPath}`)
455        logForDebugging(`Execution timeout: ${SNAPSHOT_CREATION_TIMEOUT}ms`)
456        execFile(
457          binShell,
458          ['-c', '-l', snapshotScript],
459          {
460            env: {
461              ...((process.env.CLAUDE_CODE_DONT_INHERIT_ENV
462                ? {}
463                : subprocessEnv()) as typeof process.env),
464              SHELL: binShell,
465              GIT_EDITOR: 'true',
466              CLAUDECODE: '1',
467            },
468            timeout: SNAPSHOT_CREATION_TIMEOUT,
469            maxBuffer: 1024 * 1024, // 1MB buffer
470            encoding: 'utf8',
471          },
472          async (error, stdout, stderr) => {
473            if (error) {
474              const execError = error as Error & {
475                killed?: boolean
476                signal?: string
477                code?: number
478              }
479              logForDebugging(`Shell snapshot creation failed: ${error.message}`)
480              logForDebugging(`Error details:`)
481              logForDebugging(`  - Error code: ${execError?.code}`)
482              logForDebugging(`  - Error signal: ${execError?.signal}`)
483              logForDebugging(`  - Error killed: ${execError?.killed}`)
484              logForDebugging(`  - Shell path: ${binShell}`)
485              logForDebugging(`  - Config file: ${getConfigFile(binShell)}`)
486              logForDebugging(`  - Config file exists: ${configFileExists}`)
487              logForDebugging(`  - Working directory: ${getCwd()}`)
488              logForDebugging(`  - Claude home: ${getClaudeConfigHomeDir()}`)
489              logForDebugging(`Full snapshot script:\n${snapshotScript}`)
490              if (stdout) {
491                logForDebugging(
492                  `stdout output (${stdout.length} chars):\n${stdout}`,
493                )
494              } else {
495                logForDebugging(`No stdout output captured`)
496              }
497              if (stderr) {
498                logForDebugging(
499                  `stderr output (${stderr.length} chars): ${stderr}`,
500                )
501              } else {
502                logForDebugging(`No stderr output captured`)
503              }
504              logError(
505                new Error(`Failed to create shell snapshot: ${error.message}`),
506              )
507              // Convert signal name to number if present
508              const signalNumber = execError?.signal
509                ? os.constants.signals[
510                    execError.signal as keyof typeof os.constants.signals
511                  ]
512                : undefined
513              logEvent('tengu_shell_snapshot_failed', {
514                stderr_length: stderr?.length || 0,
515                has_error_code: !!execError?.code,
516                error_signal_number: signalNumber,
517                error_killed: execError?.killed,
518              })
519              resolve(undefined)
520            } else {
521              let snapshotSize: number | undefined
522              try {
523                snapshotSize = (await stat(shellSnapshotPath)).size
524              } catch {
525                // Snapshot file not found
526              }
527  
528              if (snapshotSize !== undefined) {
529                logForDebugging(
530                  `Shell snapshot created successfully (${snapshotSize} bytes)`,
531                )
532  
533                // Register cleanup to remove snapshot on graceful shutdown
534                registerCleanup(async () => {
535                  try {
536                    await getFsImplementation().unlink(shellSnapshotPath)
537                    logForDebugging(
538                      `Cleaned up session snapshot: ${shellSnapshotPath}`,
539                    )
540                  } catch (error) {
541                    logForDebugging(
542                      `Error cleaning up session snapshot: ${error}`,
543                    )
544                  }
545                })
546  
547                resolve(shellSnapshotPath)
548              } else {
549                logForDebugging(
550                  `Shell snapshot file not found after creation: ${shellSnapshotPath}`,
551                )
552                logForDebugging(
553                  `Checking if parent directory still exists: ${snapshotsDir}`,
554                )
555                try {
556                  const dirContents =
557                    await getFsImplementation().readdir(snapshotsDir)
558                  logForDebugging(
559                    `Directory contains ${dirContents.length} files`,
560                  )
561                } catch {
562                  logForDebugging(
563                    `Parent directory does not exist or is not accessible: ${snapshotsDir}`,
564                  )
565                }
566                logEvent('tengu_shell_unknown_error', {})
567                resolve(undefined)
568              }
569            }
570          },
571        )
572      } catch (error) {
573        logForDebugging(`Unexpected error during snapshot creation: ${error}`)
574        if (error instanceof Error) {
575          logForDebugging(`Error stack trace: ${error.stack}`)
576        }
577        logError(error)
578        logEvent('tengu_shell_snapshot_error', {})
579        resolve(undefined)
580      }
581    })
582  }