diff --git a/README.md b/README.md index 5c94ed80..b7c058a9 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ All of Claude Code's tools work — bash, file read/write/edit, grep, glob, agen npm install -g @gitlawb/openclaude ``` +If you install via npm and later see `ripgrep not found`, install ripgrep +system-wide and confirm `rg --version` works in the same terminal before +starting OpenClaude. + ### Option B: From source (requires Bun) Use Bun `1.3.11` or newer for source builds on Windows. Older Bun versions such as `1.3.4` can fail with a large batch of unresolved module errors during `bun run build`. diff --git a/src/utils/ripgrep.test.ts b/src/utils/ripgrep.test.ts new file mode 100644 index 00000000..6a17d753 --- /dev/null +++ b/src/utils/ripgrep.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'bun:test' + +import { wrapRipgrepUnavailableError } from './ripgrep.ts' + +test('wrapRipgrepUnavailableError explains missing packaged fallback', () => { + const error = wrapRipgrepUnavailableError( + { code: 'ENOENT', message: 'spawn rg ENOENT' }, + { mode: 'builtin', command: 'C:\\fake\\vendor\\ripgrep\\rg.exe' }, + 'win32', + ) + + expect(error.name).toBe('RipgrepUnavailableError') + expect(error.code).toBe('ENOENT') + expect(error.message).toContain('packaged ripgrep fallback') + expect(error.message).toContain('winget install BurntSushi.ripgrep.MSVC') +}) + +test('wrapRipgrepUnavailableError explains missing system ripgrep', () => { + const error = wrapRipgrepUnavailableError( + { code: 'ENOENT', message: 'spawn rg ENOENT' }, + { mode: 'system', command: 'rg' }, + 'linux', + ) + + expect(error.message).toContain('system ripgrep binary was not found on PATH') + expect(error.message).toContain('apt install ripgrep') +}) diff --git a/src/utils/ripgrep.ts b/src/utils/ripgrep.ts index 683da051..4bd95894 100644 --- a/src/utils/ripgrep.ts +++ b/src/utils/ripgrep.ts @@ -28,6 +28,8 @@ type RipgrepConfig = { argv0?: string } +type RipgrepErrorLike = Pick + const getRipgrepConfig = memoize((): RipgrepConfig => { const userWantsSystemRipgrep = isEnvDefinedFalsy( process.env.USE_BUILTIN_RIPGREP, @@ -105,6 +107,52 @@ export class RipgrepTimeoutError extends Error { } } +export class RipgrepUnavailableError extends Error { + code?: string | number + + constructor( + message: string, + public readonly config: Pick, + code?: string | number, + ) { + super(message) + this.name = 'RipgrepUnavailableError' + this.code = code + } +} + +function getRipgrepInstallHint(platform = process.platform): string { + switch (platform) { + case 'win32': + return 'Install ripgrep and confirm `rg --version` works in the same terminal. Windows: `winget install BurntSushi.ripgrep.MSVC` or `choco install ripgrep`.' + case 'darwin': + return 'Install ripgrep and confirm `rg --version` works in the same terminal. macOS: `brew install ripgrep`.' + default: + return 'Install ripgrep and confirm `rg --version` works in the same terminal. Linux: use your distro package manager, for example `apt install ripgrep`.' + } +} + +export function wrapRipgrepUnavailableError( + error: RipgrepErrorLike, + config = getRipgrepConfig(), + platform = process.platform, +): RipgrepUnavailableError { + const modeExplanation = + config.mode === 'builtin' + ? 'This install could not locate its packaged ripgrep fallback.' + : config.mode === 'system' + ? 'A working system ripgrep binary was not found on PATH.' + : 'The embedded ripgrep binary could not be started.' + + const originalMessage = error.message ? ` Original error: ${error.message}` : '' + + return new RipgrepUnavailableError( + `ripgrep (rg) is required for file search but could not be started. ${modeExplanation} ${getRipgrepInstallHint(platform)}${originalMessage}`, + config, + error.code, + ) +} + function ripGrepRaw( args: string[], target: string, @@ -275,7 +323,9 @@ async function ripGrepFileCount( child.on('error', err => { if (settled) return settled = true - reject(err) + reject( + err.code === 'ENOENT' ? wrapRipgrepUnavailableError(err) : err, + ) }) }) } @@ -337,7 +387,9 @@ export async function ripGrepStream( child.on('error', err => { if (settled) return settled = true - reject(err) + reject( + err.code === 'ENOENT' ? wrapRipgrepUnavailableError(err) : err, + ) }) }) } @@ -383,7 +435,9 @@ export async function ripGrep( // These should be surfaced to the user rather than silently returning empty results const CRITICAL_ERROR_CODES = ['ENOENT', 'EACCES', 'EPERM'] if (CRITICAL_ERROR_CODES.includes(error.code as string)) { - reject(error) + reject( + error.code === 'ENOENT' ? wrapRipgrepUnavailableError(error) : error, + ) return }