Skip to content

Commit 6c63374

Browse files
claude-code-bestglm-5.2
andauthored
Fix/ripgrep fallback (#1273)
* fix: tmp 目录改用 os.tmpdir() + ripgrep 缺失时自动 fallback 系统 rg 1. Shell.ts / imagePaste.ts / filesystem.ts: Linux/macOS 默认 tmp 路径 从硬编码 '/tmp' 改为 os.tmpdir(),自动适配 Termux/Android 等无 /tmp 的环境;macOS 桌面零变化;CLAUDE_CODE_TMPDIR 仍优先级最高。 2. ripgrep.ts: builtin rg 二进制缺失时(Android/Termux、不完整安装) 自动 fallback 到 PATH 上的系统 rg,通过 note 字段携带人读提示; /doctor 渲染 note;init 启动时写一行 stderr warning。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * fix: review fix — ripgrep note 文案修正 + init catch 加调试日志 - ripgrep "no ripgrep available" note 去掉无意义的 USE_BUILTIN_RIPGREP=0 建议 - init.ts ripgrep status check 的空 catch 加 logForDebugging Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> --------- Co-authored-by: glm-5.2 <zai-org@claude-code-best.win>
1 parent bb100b1 commit 6c63374

10 files changed

Lines changed: 787 additions & 9 deletions

File tree

docs/superpowers/plans/2026-06-15-ripgrep-system-fallback.md

Lines changed: 492 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Ripgrep System Fallback — Design
2+
3+
**Date:** 2026-06-15
4+
**Status:** Approved (pending spec review)
5+
**Topic:** Make ripgrep gracefully degrade to system `rg` when the bundled/builtin binary is unavailable on the current platform (e.g. Android/Termux).
6+
7+
## Problem
8+
9+
`src/utils/ripgrep.ts` `getRipgrepConfig()` has three resolution branches:
10+
11+
1. `USE_BUILTIN_RIPGREP=0` → look up `rg` on `PATH`
12+
2. `isInBundledMode()` → bun-internal embedded rg
13+
3. Otherwise → `vendor/ripgrep/<arch>-<platform>/rg` (builtin)
14+
15+
On Android/Termux, all three fail:
16+
17+
- The user has not opted into system rg.
18+
- Bun does not publish Android builds, so `isInBundledMode()` is false.
19+
- `scripts/postinstall.cjs:81` throws `Unsupported platform: android`, so no builtin binary is ever downloaded. `vendor/ripgrep/` contains no `arm64-android` directory.
20+
21+
Net effect: spawn of a nonexistent path → `ENOENT` → user sees "ripgrep 缺失" with no recovery path other than manually setting `USE_BUILTIN_RIPGREP=0`. The discovery pipeline (`Grep`/`Glob` tools, file suggestions, hooks) all fail in the same way.
22+
23+
More generally, the same breakage occurs on any platform where the builtin binary is missing for any reason (incomplete install, custom platform, deleted vendor directory). The current code has no graceful degradation.
24+
25+
## Goals
26+
27+
- On any platform, when the builtin/bundled ripgrep is unavailable, automatically fall back to `rg` on `PATH`.
28+
- Surface the fallback clearly to the user via `/doctor` and a one-line startup warning, so they understand why they are not on the bundled rg and what to do if the system rg is also missing.
29+
- Do not change behavior on platforms where the builtin rg works (macOS, Linux, Windows).
30+
31+
## Non-Goals
32+
33+
- Downloading or shipping an Android-native ripgrep binary.
34+
- Adding a REPL persistent status indicator.
35+
- Touching `USE_BUILTIN_RIPGREP` semantics for users who already opt into system rg.
36+
- Modifying build / `postinstall.cjs` platform mapping.
37+
38+
## Design
39+
40+
### Decision chain (`getRipgrepConfig`)
41+
42+
The function gains an existence check and a system-rg fallback. The order of existing branches is preserved.
43+
44+
```
45+
1. USE_BUILTIN_RIPGREP=0 (user-opt) → system rg mode='system' note=undefined
46+
2. isInBundledMode() → bun embedded rg mode='embedded' note=undefined
47+
3. Compute builtin path; existsSync(rgPath)?
48+
✓ true → builtin rg mode='builtin' note=undefined
49+
✓ false → findExecutable('rg', [])
50+
✓ found → system rg (auto fallback) mode='system' note='fallback: builtin rg unavailable on <platform>, using system rg'
51+
✗ missing → keep builtin path (let upper layer ENOENT) mode='builtin' note='no ripgrep available on <platform>; install via apt/pkg/brew/...'
52+
```
53+
54+
Rationale for the missing-system-rg branch returning the (nonexistent) builtin path: it preserves the historical spawn behavior so existing error-handling paths in `ripGrepRaw` and callers continue to see `ENOENT`. The new `note` field carries the human-readable explanation; the spawn itself still fails the same way.
55+
56+
`existsSync` is a single synchronous syscall; `getRipgrepConfig` is already memoized via lodash, so the cost is paid once per process.
57+
58+
### Status API (`getRipgrepStatus`)
59+
60+
```ts
61+
type RipgrepStatus = {
62+
mode: 'system' | 'builtin' | 'embedded' // unchanged
63+
path: string // unchanged
64+
working: boolean | null // unchanged
65+
note?: string // NEW — human-readable hint
66+
}
67+
```
68+
69+
The internal `ripgrepStatus` singleton also gains `note?: string`. `testRipgrepOnFirstUse` propagates the note from the active config.
70+
71+
The `note` value is sourced from `getRipgrepConfig()` (the source of truth), so the API remains a single read; no second lookup.
72+
73+
### UI — `/doctor`
74+
75+
`src/screens/Doctor.tsx` renders the existing `Search:` line plus the note when present. Two example outputs:
76+
77+
```
78+
Search: OK (system rg fallbackbuiltin ripgrep unavailable on android)
79+
Search: Not working (no ripgrep available on androidinstall via apt/pkg/brew)
80+
```
81+
82+
`src/utils/doctorDiagnostic.ts` extends the `ripgrepStatus` object it returns to include `note`.
83+
84+
### UI — startup warning
85+
86+
A single check near the end of `src/entrypoints/init.ts` reads `getRipgrepStatus()`. If `note` is set, it writes one line to stderr:
87+
88+
```
89+
[ripgrep] fallback: builtin rg unavailable on android, using system rg
90+
```
91+
92+
Constraints:
93+
- Non-blocking — does not throw or exit.
94+
- Fires at most once per process (memoized config + idempotent init).
95+
- Goes to stderr so it does not corrupt pipe mode (`-p`) stdout.
96+
- No retry, no telemetry beyond existing `tengu_ripgrep_availability`.
97+
98+
### Testing
99+
100+
New test file `src/utils/__tests__/ripgrepDecision.test.ts` (or extend an existing one) covers the five branches:
101+
102+
1. `USE_BUILTIN_RIPGREP=0` and `rg` on PATH → `mode='system'`, `note=undefined`.
103+
2. `isInBundledMode()``mode='embedded'`, `note=undefined`.
104+
3. Builtin path exists → `mode='builtin'`, `note=undefined`.
105+
4. Builtin path missing, `rg` on PATH → `mode='system'`, `note` set.
106+
5. Builtin path missing, `rg` not on PATH → `mode='builtin'`, `note` set (path is the nonexistent builtin path).
107+
108+
Mocks: `existsSync` (via `fs` module), `findExecutable`, `isInBundledMode`, `process.env.USE_BUILTIN_RIPGREP`, `process.platform`. Follow the project's mock conventions (see `tests/mocks/`); no business-module mocking.
109+
110+
Existing `doctorDiagnostic` tests: extend to assert `note` is propagated; update any snapshots.
111+
112+
## Risks
113+
114+
- **Behavior preservation on supported platforms:** the `existsSync` check only changes the path when the builtin file is genuinely absent. On macOS/Linux/Windows the builtin binary always exists post-install, so the decision chain resolves to `mode='builtin'` exactly as today. Verified by the test for branch 3.
115+
- **`note` field addition is backward-compatible:** optional field; existing consumers ignore it.
116+
- **Memoization:** `getRipgrepConfig` is memoized for the process lifetime. If a user installs ripgrep mid-session, the fallback will not trigger until restart. Acceptable — matches existing behavior for `USE_BUILTIN_RIPGREP` changes.
117+
- **Platform string in `note`:** uses `process.platform` directly (`'android'`, `'linux'`, `'darwin'`, `'win32'`). No translation; the message is diagnostic, not user-facing marketing copy.
118+
119+
## Out of Scope (YAGNI)
120+
121+
- Android prebuilt binary download.
122+
- Persistent REPL status indicator.
123+
- Build-time vendor changes.
124+
- Telemetry beyond what `testRipgrepOnFirstUse` already emits.
125+
126+
## Acceptance Criteria
127+
128+
- On a platform where the builtin rg binary is missing and `rg` is on `PATH`, `getRipgrepStatus()` returns `mode='system'`, `path=<resolved system rg>`, `note` set to a non-empty human-readable string.
129+
- On a platform where neither builtin nor system rg is available, `/doctor` displays `Not working` plus the install hint.
130+
- The startup warning fires exactly once per session when `note` is set.
131+
- All existing ripgrep tests pass unchanged on macOS/Linux dev machines.
132+
- `bun run precheck` is green.

src/entrypoints/init.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,19 @@ export const init = memoize(async (): Promise<void> => {
237237
})
238238
}
239239

240+
// Surface ripgrep fallback (e.g. Android/Termux) once per session.
241+
// Goes to stderr so it doesn't corrupt pipe-mode (`-p`) stdout.
242+
try {
243+
const { getRipgrepStatus } = await import('../utils/ripgrep.js')
244+
const status = getRipgrepStatus()
245+
if (status.note) {
246+
process.stderr.write(`[ripgrep] ${status.note}\n`)
247+
}
248+
} catch {
249+
// Ripgrep status is best-effort; never block init.
250+
logForDebugging('[init] ripgrep status check skipped')
251+
}
252+
240253
logForDiagnosticsNoPII('info', 'init_completed', {
241254
duration_ms: Date.now() - initStartTime,
242255
})

src/screens/Doctor.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ export function Doctor({ onDone }: Props): React.ReactNode {
230230
: diagnostic.ripgrepStatus.systemPath || 'system'}
231231
)
232232
</Text>
233+
{diagnostic.ripgrepStatus.note && <Text color="warning">└ Note: {diagnostic.ripgrepStatus.note}</Text>}
233234

234235
{/* Show recommendation if auto-updates are disabled */}
235236
{diagnostic.recommendation && (

src/utils/Shell.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { execFileSync, spawn } from 'child_process'
22
import { constants as fsConstants, readFileSync, unlinkSync } from 'fs'
33
import { type FileHandle, mkdir, open, realpath } from 'fs/promises'
44
import memoize from 'lodash-es/memoize.js'
5+
import { tmpdir } from 'os'
56
import { isAbsolute, resolve } from 'path'
67
import { join as posixJoin } from 'path/posix'
78
import { logEvent } from 'src/services/analytics/index.js'
@@ -200,9 +201,10 @@ export async function exec(
200201
.toString(16)
201202
.padStart(4, '0')
202203

203-
// Sandbox temp directory - use per-user directory name to prevent multi-user permission conflicts
204+
// Sandbox temp directory - use per-user directory name to prevent multi-user permission conflicts.
205+
// tmpdir() honors $TMPDIR so non-/tmp environments (Termux/Android, containers) work out of the box.
204206
const sandboxTmpDir = posixJoin(
205-
process.env.CLAUDE_CODE_TMPDIR || '/tmp',
207+
process.env.CLAUDE_CODE_TMPDIR || tmpdir(),
206208
getClaudeTempDirName(),
207209
)
208210

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2+
import { mkdirSync, rmSync, writeFileSync } from 'fs'
3+
import { join } from 'path'
4+
5+
// Test the pure fallback function directly — no mock.module needed,
6+
// so this test cannot pollute other tests in the same Bun process.
7+
// See CLAUDE.md "Mock 使用规范" for why we avoid business-module mocking.
8+
const { resolveBuiltinWithFallback } = await import('../ripgrep.js')
9+
10+
// Real temp dir with a real (or removed) fake rg binary to control existsSync.
11+
const tmpDir = join(
12+
globalThis.process.env.TMPDIR || '/tmp',
13+
'ripgrep-config-test',
14+
)
15+
const vendorDir = join(
16+
tmpDir,
17+
'vendor',
18+
'ripgrep',
19+
`${process.arch}-${process.platform}`,
20+
)
21+
const rgPath = join(vendorDir, process.platform === 'win32' ? 'rg.exe' : 'rg')
22+
23+
describe('resolveBuiltinWithFallback', () => {
24+
beforeAll(() => {
25+
mkdirSync(vendorDir, { recursive: true })
26+
writeFileSync(rgPath, '')
27+
})
28+
29+
afterAll(() => {
30+
rmSync(tmpDir, { recursive: true, force: true })
31+
})
32+
33+
test('builtin exists -> mode=builtin, no note', () => {
34+
const result = resolveBuiltinWithFallback(rgPath)
35+
expect(result.mode).toBe('builtin')
36+
expect(result.command).toBe(rgPath)
37+
expect(result.note).toBeUndefined()
38+
})
39+
40+
test('builtin missing + system rg available -> mode=system, note set', () => {
41+
rmSync(rgPath)
42+
const result = resolveBuiltinWithFallback(
43+
rgPath,
44+
'/usr/local/bin/rg', // explicit system rg path
45+
'testplatform',
46+
)
47+
expect(result.mode).toBe('system')
48+
expect(result.command).toBe('rg')
49+
expect(result.note).toContain('fallback')
50+
expect(result.note).toContain('testplatform')
51+
// Restore for subsequent tests
52+
writeFileSync(rgPath, '')
53+
})
54+
55+
test('builtin missing + system rg missing -> mode=builtin, note set', () => {
56+
rmSync(rgPath)
57+
const result = resolveBuiltinWithFallback(
58+
rgPath,
59+
null, // no system rg
60+
'testplatform',
61+
)
62+
expect(result.mode).toBe('builtin')
63+
expect(result.command).toBe(rgPath)
64+
expect(result.note).toContain('no ripgrep available')
65+
expect(result.note).toContain('testplatform')
66+
writeFileSync(rgPath, '')
67+
})
68+
69+
test('uses process.platform when platform param omitted', () => {
70+
rmSync(rgPath)
71+
const result = resolveBuiltinWithFallback(rgPath, null)
72+
expect(result.note).toContain(process.platform)
73+
writeFileSync(rgPath, '')
74+
})
75+
})

src/utils/doctorDiagnostic.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export type DiagnosticInfo = {
6767
working: boolean
6868
mode: 'system' | 'builtin' | 'embedded'
6969
systemPath: string | null
70+
note: string | null
7071
}
7172
}
7273

@@ -594,6 +595,7 @@ export async function getDoctorDiagnostic(): Promise<DiagnosticInfo> {
594595
mode: ripgrepStatusRaw.mode,
595596
systemPath:
596597
ripgrepStatusRaw.mode === 'system' ? ripgrepStatusRaw.path : null,
598+
note: ripgrepStatusRaw.note ?? null,
597599
}
598600

599601
// Get package manager info if running from package manager

src/utils/imagePaste.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { feature } from 'bun:bundle'
22
import { randomBytes } from 'crypto'
33
import { execa } from 'execa'
4+
import { tmpdir } from 'os'
45
import { basename, extname, isAbsolute, join } from 'path'
56
import {
67
IMAGE_MAX_HEIGHT,
@@ -32,10 +33,11 @@ function getClipboardCommands() {
3233
const platform = process.platform as SupportedPlatform
3334

3435
// Platform-specific temporary file paths
35-
// Use CLAUDE_CODE_TMPDIR if set, otherwise fall back to platform defaults
36+
// Use CLAUDE_CODE_TMPDIR if set, otherwise fall back to platform defaults.
37+
// tmpdir() honors $TMPDIR so non-/tmp environments (Termux/Android, containers) work out of the box.
3638
const baseTmpDir =
3739
process.env.CLAUDE_CODE_TMPDIR ||
38-
(platform === 'win32' ? process.env.TEMP || 'C:\\Temp' : '/tmp')
40+
(platform === 'win32' ? process.env.TEMP || 'C:\\Temp' : tmpdir())
3941
const screenshotFilename = 'claude_cli_latest_screenshot.png'
4042
const tempPaths: Record<SupportedPlatform, string> = {
4143
darwin: join(baseTmpDir, screenshotFilename),

src/utils/permissions/filesystem.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -329,9 +329,9 @@ export function getClaudeTempDirName(): string {
329329
// and per-turn from BashTool prompt. Inputs (CLAUDE_CODE_TMPDIR env + platform) are
330330
// fixed at startup, and the realpath of the system tmp dir does not change mid-session.
331331
export const getClaudeTempDir = memoize(function getClaudeTempDir(): string {
332-
const baseTmpDir =
333-
process.env.CLAUDE_CODE_TMPDIR ||
334-
(getPlatform() === 'windows' ? tmpdir() : '/tmp')
332+
// tmpdir() honors $TMPDIR so non-/tmp environments (Termux/Android, containers)
333+
// work out of the box; CLAUDE_CODE_TMPDIR still wins if explicitly set.
334+
const baseTmpDir = process.env.CLAUDE_CODE_TMPDIR || tmpdir()
335335

336336
// Resolve symlinks in the base temp directory (e.g., /tmp -> /private/tmp on macOS)
337337
// This ensures the path matches resolved paths in permission checks

0 commit comments

Comments
 (0)