π File detail
utils/bash/ShellSnapshot.ts
π― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes createRipgrepShellIntegration, createFindGrepShellIntegration, and createAndSaveSnapshot β mainly functions, hooks, or classes. Dependencies touch subprocess spawning, child processes, Node filesystem, and Node OS/process metadata. It composes internal code from cleanupRegistry, cwd, debug, embeddedTools, and envUtils (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import { execFile } from 'child_process' import { execa } from 'execa' import { mkdir, stat } from 'fs/promises' import * as os from 'os' import { join } from 'path'
π€ Exports (heuristic)
createRipgrepShellIntegrationcreateFindGrepShellIntegrationcreateAndSaveSnapshot
π External import roots
Package roots from from "β¦" (relative paths omitted).
child_processexecafsospathsrc
π₯οΈ Source preview
import { execFile } from 'child_process'
import { execa } from 'execa'
import { mkdir, stat } from 'fs/promises'
import * as os from 'os'
import { join } from 'path'
import { logEvent } from 'src/services/analytics/index.js'
import { registerCleanup } from '../cleanupRegistry.js'
import { getCwd } from '../cwd.js'
import { logForDebugging } from '../debug.js'
import {
embeddedSearchToolsBinaryPath,
hasEmbeddedSearchTools,
} from '../embeddedTools.js'
import { getClaudeConfigHomeDir } from '../envUtils.js'
import { pathExists } from '../file.js'
import { getFsImplementation } from '../fsOperations.js'
import { logError } from '../log.js'
import { getPlatform } from '../platform.js'
import { ripgrepCommand } from '../ripgrep.js'
import { subprocessEnv } from '../subprocessEnv.js'
import { quote } from './shellQuote.js'
const LITERAL_BACKSLASH = '\\'
const SNAPSHOT_CREATION_TIMEOUT = 10000 // 10 seconds
/**
* Creates a shell function that invokes `binaryPath` with a specific argv[0].
* This uses the bun-internal ARGV0 dispatch trick: the bun binary checks its
* argv[0] and runs the embedded tool (rg, bfs, ugrep) that matches.
*
* @param prependArgs - Arguments to inject before the user's args (e.g.,
* default flags). Injected literally; each element must be a valid shell
* word (no spaces/special chars).
*/
function createArgv0ShellFunction(
funcName: string,
argv0: string,
binaryPath: string,
prependArgs: string[] = [],
): string {
const quotedPath = quote([binaryPath])
const argSuffix =
prependArgs.length > 0 ? `${prependArgs.join(' ')} "$@"` : '"$@"'
return [
`function ${funcName} {`,
' if [[ -n $ZSH_VERSION ]]; then',
` ARGV0=${argv0} ${quotedPath} ${argSuffix}`,
' elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "win32" ]]; then',
// On Windows (git bash), exec -a does not work, so use ARGV0 env var instead
// The bun binary reads from ARGV0 natively to set argv[0]
` ARGV0=${argv0} ${quotedPath} ${argSuffix}`,
' elif [[ $BASHPID != $$ ]]; then',
` exec -a ${argv0} ${quotedPath} ${argSuffix}`,
' else',
` (exec -a ${argv0} ${quotedPath} ${argSuffix})`,
' fi',
'}',
].join('\n')
}
/**
* Creates ripgrep shell integration (alias or function)
* @returns Object with type and the shell snippet to use
*/
export function createRipgrepShellIntegration(): {
type: 'alias' | 'function'
snippet: string
} {
const rgCommand = ripgrepCommand()
// For embedded ripgrep (bun-internal), we need a shell function that sets argv0
if (rgCommand.argv0) {
return {
type: 'function',
snippet: createArgv0ShellFunction(
'rg',
rgCommand.argv0,
rgCommand.rgPath,
),
}
}
// For regular ripgrep, use a simple alias target
const quotedPath = quote([rgCommand.rgPath])
const quotedArgs = rgCommand.rgArgs.map(arg => quote([arg]))
const aliasTarget =
rgCommand.rgArgs.length > 0
? `${quotedPath} ${quotedArgs.join(' ')}`
: quotedPath
return { type: 'alias', snippet: aliasTarget }
}
/**
* VCS directories to exclude from grep searches. Matches the list in
* GrepTool (see GrepTool.ts: VCS_DIRECTORIES_TO_EXCLUDE).
*/
const VCS_DIRECTORIES_TO_EXCLUDE = [
'.git',
'.svn',
'.hg',
'.bzr',
'.jj',
'.sl',
] as const
/**
* Creates shell integration for `find` and `grep`, backed by bfs and ugrep
* embedded in the bun binary (ant-native only). Unlike the rg integration,
* this always shadows the system find/grep since bfs/ugrep are drop-in
* replacements and we want consistent fast behavior.
*
* These wrappers replace the GlobTool/GrepTool dedicated tools (which are
* removed from the tool registry when embedded search tools are available),
* so they're tuned to match those tools' semantics, not GNU find/grep.
*
* `find` β GlobTool:
* - Inject `-regextype findutils-default`: bfs defaults to POSIX BRE for
* -regex, but GNU find defaults to emacs-flavor (which supports `\|`
* alternation). Without this, `find . -regex '.*\.\(js\|ts\)'` silently
* returns zero results. A later user-supplied -regextype still overrides.
* - No gitignore filtering: GlobTool passes `--no-ignore` to rg. bfs has no
* gitignore support anyway, so this matches by default.
* - Hidden files included: both GlobTool (`--hidden`) and bfs's default.
*
* Caveat: even with findutils-default, Oniguruma (bfs's regex engine) uses
* leftmost-first alternation, not POSIX leftmost-longest. Patterns where
* one alternative is a prefix of another (e.g., `\(ts\|tsx\)`) may miss
* matches that GNU find catches. Workaround: put the longer alternative first.
*
* `grep` β GrepTool (file filtering) + GNU grep (regex syntax):
* - `-G` (basic regex / BRE): GNU grep defaults to BRE where `\|` is
* alternation. ugrep defaults to ERE where `|` is alternation and `\|` is a
* literal pipe. Without -G, `grep "foo\|bar"` silently returns zero results.
* User-supplied `-E`, `-F`, or `-P` later in argv overrides this.
* - `--ignore-files`: respect .gitignore (GrepTool uses rg's default, which
* respects gitignore). Override with `grep --no-ignore-files`.
* - `--hidden`: include hidden files (GrepTool passes `--hidden` to rg).
* Override with `grep --no-hidden`.
* - `--exclude-dir` for VCS dirs: GrepTool passes `--glob '!.git'` etc. to rg.
* - `-I`: skip binary files. rg's recursion silently skips binary matches
* by default (different from direct-file-arg behavior); ugrep doesn't, so
* we inject -I to match. Override with `grep -a`.
*
* Not replicated from GrepTool:
* - `--max-columns 500`: ugrep's `--width` hard-truncates output which could
* break pipelines; rg's version replaces the line with a placeholder.
* - Read deny rules / plugin cache exclusions: require toolPermissionContext
* which isn't available at shell-snapshot creation time.
*
* Returns null if embedded search tools are not available in this build.
*/
export function createFindGrepShellIntegration(): string | null {
if (!hasEmbeddedSearchTools()) {
return null
}
const binaryPath = embeddedSearchToolsBinaryPath()
return [
// User shell configs may define aliases like `alias find=gfind` or
// `alias grep=ggrep` (common on macOS with Homebrew GNU tools). The
// snapshot sources user aliases before these function definitions, and
// bash expands aliases before function lookup β so a renaming alias
// would silently bypass the embedded bfs/ugrep dispatch. Clear them first
// (same fix the rg integration uses).
'unalias find 2>/dev/null || true',
'unalias grep 2>/dev/null || true',
createArgv0ShellFunction('find', 'bfs', binaryPath, [
'-regextype',
'findutils-default',
]),
createArgv0ShellFunction('grep', 'ugrep', binaryPath, [
'-G',
'--ignore-files',
'--hidden',
'-I',
...VCS_DIRECTORIES_TO_EXCLUDE.map(d => `--exclude-dir=${d}`),
]),
].join('\n')
}
function getConfigFile(shellPath: string): string {
const fileName = shellPath.includes('zsh')
? '.zshrc'
: shellPath.includes('bash')
? '.bashrc'
: '.profile'
const configPath = join(os.homedir(), fileName)
return configPath
}
/**
* Generates user-specific snapshot content (functions, options, aliases)
* This content is derived from the user's shell configuration file
*/
function getUserSnapshotContent(configFile: string): string {
const isZsh = configFile.endsWith('.zshrc')
let content = ''
// User functions
if (isZsh) {
content += `
echo "# Functions" >> "$SNAPSHOT_FILE"
# Force autoload all functions first
typeset -f > /dev/null 2>&1
# Now get user function names - filter completion functions (single underscore prefix)
# but keep double-underscore helpers (e.g. __zsh_like_cd from mise, __pyenv_init)
typeset +f | grep -vE '^_[^_]' | while read func; do
typeset -f "$func" >> "$SNAPSHOT_FILE"
done
`
} else {
content += `
echo "# Functions" >> "$SNAPSHOT_FILE"
# Force autoload all functions first
declare -f > /dev/null 2>&1
# Now get user function names - filter completion functions (single underscore prefix)
# but keep double-underscore helpers (e.g. __zsh_like_cd from mise, __pyenv_init)
declare -F | cut -d' ' -f3 | grep -vE '^_[^_]' | while read func; do
# Encode the function to base64, preserving all special characters
encoded_func=$(declare -f "$func" | base64 )
# Write the function definition to the snapshot
echo "eval ${LITERAL_BACKSLASH}"${LITERAL_BACKSLASH}$(echo '$encoded_func' | base64 -d)${LITERAL_BACKSLASH}" > /dev/null 2>&1" >> "$SNAPSHOT_FILE"
done
`
}
// Shell options
if (isZsh) {
content += `
echo "# Shell Options" >> "$SNAPSHOT_FILE"
setopt | sed 's/^/setopt /' | head -n 1000 >> "$SNAPSHOT_FILE"
`
} else {
content += `
echo "# Shell Options" >> "$SNAPSHOT_FILE"
shopt -p | head -n 1000 >> "$SNAPSHOT_FILE"
set -o | grep "on" | awk '{print "set -o " $1}' | head -n 1000 >> "$SNAPSHOT_FILE"
echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"
`
}
// User aliases
content += `
echo "# Aliases" >> "$SNAPSHOT_FILE"
# Filter out winpty aliases on Windows to avoid "stdin is not a tty" errors
# Git Bash automatically creates aliases like "alias node='winpty node.exe'" for
# programs that need Win32 Console in mintty, but winpty fails when there's no TTY
if [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]]; then
alias | grep -v "='winpty " | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE"
else
alias | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE"
fi
`
return content
}
/**
* Generates Claude Code specific snapshot content
* This content is always included regardless of user configuration
*/
async function getClaudeCodeSnapshotContent(): Promise<string> {
// Get the appropriate PATH based on platform
let pathValue = process.env.PATH
if (getPlatform() === 'windows') {
// On Windows with git-bash, read the Cygwin PATH
const cygwinResult = await execa('echo $PATH', {
shell: true,
reject: false,
})
if (cygwinResult.exitCode === 0 && cygwinResult.stdout) {
pathValue = cygwinResult.stdout.trim()
}
// Fall back to process.env.PATH if we can't get Cygwin PATH
}
const rgIntegration = createRipgrepShellIntegration()
let content = ''
// Check if rg is available, if not create an alias/function to bundled ripgrep
// We use a subshell to unalias rg before checking, so that user aliases like
// `alias rg='rg --smart-case'` don't shadow the real binary check. The subshell
// ensures we don't modify the user's aliases in the parent shell.
content += `
# Check for rg availability
echo "# Check for rg availability" >> "$SNAPSHOT_FILE"
echo "if ! (unalias rg 2>/dev/null; command -v rg) >/dev/null 2>&1; then" >> "$SNAPSHOT_FILE"
`
if (rgIntegration.type === 'function') {
// For embedded ripgrep, write the function definition using heredoc
content += `
cat >> "$SNAPSHOT_FILE" << 'RIPGREP_FUNC_END'
${rgIntegration.snippet}
RIPGREP_FUNC_END
`
} else {
// For regular ripgrep, write a simple alias
const escapedSnippet = rgIntegration.snippet.replace(/'/g, "'\\''")
content += `
echo ' alias rg='"'${escapedSnippet}'" >> "$SNAPSHOT_FILE"
`
}
content += `
echo "fi" >> "$SNAPSHOT_FILE"
`
// For ant-native builds, shadow find/grep with bfs/ugrep embedded in the bun
// binary. Unlike rg (which only activates if system rg is absent), we always
// shadow find/grep since bfs/ugrep are drop-in replacements and we want
// consistent fast behavior in Claude's shell.
const findGrepIntegration = createFindGrepShellIntegration()
if (findGrepIntegration !== null) {
content += `
# Shadow find/grep with embedded bfs/ugrep (ant-native only)
echo "# Shadow find/grep with embedded bfs/ugrep" >> "$SNAPSHOT_FILE"
cat >> "$SNAPSHOT_FILE" << 'FIND_GREP_FUNC_END'
${findGrepIntegration}
FIND_GREP_FUNC_END
`
}
// Add PATH to the file
content += `
# Add PATH to the file
echo "export PATH=${quote([pathValue || ''])}" >> "$SNAPSHOT_FILE"
`
return content
}
/**
* Creates the appropriate shell script for capturing environment
*/
async function getSnapshotScript(
shellPath: string,
snapshotFilePath: string,
configFileExists: boolean,
): Promise<string> {
const configFile = getConfigFile(shellPath)
const isZsh = configFile.endsWith('.zshrc')
// Generate the user content and Claude Code content
const userContent = configFileExists
? getUserSnapshotContent(configFile)
: !isZsh
? // we need to manually force alias expansion in bash - normally `getUserSnapshotContent` takes care of this
'echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"'
: ''
const claudeCodeContent = await getClaudeCodeSnapshotContent()
const script = `SNAPSHOT_FILE=${quote([snapshotFilePath])}
${configFileExists ? `source "${configFile}" < /dev/null` : '# No user config file to source'}
# First, create/clear the snapshot file
echo "# Snapshot file" >| "$SNAPSHOT_FILE"
# When this file is sourced, we first unalias to avoid conflicts
# This is necessary because aliases get "frozen" inside function definitions at definition time,
# which can cause unexpected behavior when functions use commands that conflict with aliases
echo "# Unset all aliases to avoid conflicts with functions" >> "$SNAPSHOT_FILE"
echo "unalias -a 2>/dev/null || true" >> "$SNAPSHOT_FILE"
${userContent}
${claudeCodeContent}
# Exit silently on success, only report errors
if [ ! -f "$SNAPSHOT_FILE" ]; then
echo "Error: Snapshot file was not created at $SNAPSHOT_FILE" >&2
exit 1
fi
`
return script
}
/**
* Creates and saves the shell environment snapshot by loading the user's shell configuration
*
* This function is a critical part of Claude CLI's shell integration strategy. It:
*
* 1. Identifies the user's shell config file (.zshrc, .bashrc, etc.)
* 2. Creates a temporary script that sources this configuration file
* 3. Captures the resulting shell environment state including:
* - Functions defined in the user's shell configuration
* - Shell options and settings that affect command behavior
* - Aliases that the user has defined
*
* The snapshot is saved to a temporary file that can be sourced by subsequent shell
* commands, ensuring they run with the user's expected environment, aliases, and functions.
*
* This approach allows Claude CLI to execute commands as if they were run in the user's
* interactive shell, while avoiding the overhead of creating a new login shell for each command.
* It handles both Bash and Zsh shells with their different syntax for functions, options, and aliases.
*
* If the snapshot creation fails (e.g., timeout, permissions issues), the CLI will still
* function but without the user's custom shell environment, potentially missing aliases
* and functions the user relies on.
*
* @returns Promise that resolves to the snapshot file path or undefined if creation failed
*/
export const createAndSaveSnapshot = async (
binShell: string,
): Promise<string | undefined> => {
const shellType = binShell.includes('zsh')
? 'zsh'
: binShell.includes('bash')
? 'bash'
: 'sh'
logForDebugging(`Creating shell snapshot for ${shellType} (${binShell})`)
return new Promise(async resolve => {
try {
const configFile = getConfigFile(binShell)
logForDebugging(`Looking for shell config file: ${configFile}`)
const configFileExists = await pathExists(configFile)
if (!configFileExists) {
logForDebugging(
`Shell config file not found: ${configFile}, creating snapshot with Claude Code defaults only`,
)
}
// Create unique snapshot path with timestamp and random ID
const timestamp = Date.now()
const randomId = Math.random().toString(36).substring(2, 8)
const snapshotsDir = join(getClaudeConfigHomeDir(), 'shell-snapshots')
logForDebugging(`Snapshots directory: ${snapshotsDir}`)
const shellSnapshotPath = join(
snapshotsDir,
`snapshot-${shellType}-${timestamp}-${randomId}.sh`,
)
// Ensure snapshots directory exists
await mkdir(snapshotsDir, { recursive: true })
const snapshotScript = await getSnapshotScript(
binShell,
shellSnapshotPath,
configFileExists,
)
logForDebugging(`Creating snapshot at: ${shellSnapshotPath}`)
logForDebugging(`Execution timeout: ${SNAPSHOT_CREATION_TIMEOUT}ms`)
execFile(
binShell,
['-c', '-l', snapshotScript],
{
env: {
...((process.env.CLAUDE_CODE_DONT_INHERIT_ENV
? {}
: subprocessEnv()) as typeof process.env),
SHELL: binShell,
GIT_EDITOR: 'true',
CLAUDECODE: '1',
},
timeout: SNAPSHOT_CREATION_TIMEOUT,
maxBuffer: 1024 * 1024, // 1MB buffer
encoding: 'utf8',
},
async (error, stdout, stderr) => {
if (error) {
const execError = error as Error & {
killed?: boolean
signal?: string
code?: number
}
logForDebugging(`Shell snapshot creation failed: ${error.message}`)
logForDebugging(`Error details:`)
logForDebugging(` - Error code: ${execError?.code}`)
logForDebugging(` - Error signal: ${execError?.signal}`)
logForDebugging(` - Error killed: ${execError?.killed}`)
logForDebugging(` - Shell path: ${binShell}`)
logForDebugging(` - Config file: ${getConfigFile(binShell)}`)
logForDebugging(` - Config file exists: ${configFileExists}`)
logForDebugging(` - Working directory: ${getCwd()}`)
logForDebugging(` - Claude home: ${getClaudeConfigHomeDir()}`)
logForDebugging(`Full snapshot script:\n${snapshotScript}`)
if (stdout) {
logForDebugging(
`stdout output (${stdout.length} chars):\n${stdout}`,
)
} else {
logForDebugging(`No stdout output captured`)
}
if (stderr) {
logForDebugging(
`stderr output (${stderr.length} chars): ${stderr}`,
)
} else {
logForDebugging(`No stderr output captured`)
}
logError(
new Error(`Failed to create shell snapshot: ${error.message}`),
)
// Convert signal name to number if present
const signalNumber = execError?.signal
? os.constants.signals[
execError.signal as keyof typeof os.constants.signals
]
: undefined
logEvent('tengu_shell_snapshot_failed', {
stderr_length: stderr?.length || 0,
has_error_code: !!execError?.code,
error_signal_number: signalNumber,
error_killed: execError?.killed,
})
resolve(undefined)
} else {
let snapshotSize: number | undefined
try {
snapshotSize = (await stat(shellSnapshotPath)).size
} catch {
// Snapshot file not found
}
if (snapshotSize !== undefined) {
logForDebugging(
`Shell snapshot created successfully (${snapshotSize} bytes)`,
)
// Register cleanup to remove snapshot on graceful shutdown
registerCleanup(async () => {
try {
await getFsImplementation().unlink(shellSnapshotPath)
logForDebugging(
`Cleaned up session snapshot: ${shellSnapshotPath}`,
)
} catch (error) {
logForDebugging(
`Error cleaning up session snapshot: ${error}`,
)
}
})
resolve(shellSnapshotPath)
} else {
logForDebugging(
`Shell snapshot file not found after creation: ${shellSnapshotPath}`,
)
logForDebugging(
`Checking if parent directory still exists: ${snapshotsDir}`,
)
try {
const dirContents =
await getFsImplementation().readdir(snapshotsDir)
logForDebugging(
`Directory contains ${dirContents.length} files`,
)
} catch {
logForDebugging(
`Parent directory does not exist or is not accessible: ${snapshotsDir}`,
)
}
logEvent('tengu_shell_unknown_error', {})
resolve(undefined)
}
}
},
)
} catch (error) {
logForDebugging(`Unexpected error during snapshot creation: ${error}`)
if (error instanceof Error) {
logForDebugging(`Error stack trace: ${error.stack}`)
}
logError(error)
logEvent('tengu_shell_snapshot_error', {})
resolve(undefined)
}
})
}