mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
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>
This commit is contained in:
492
docs/superpowers/plans/2026-06-15-ripgrep-system-fallback.md
Normal file
492
docs/superpowers/plans/2026-06-15-ripgrep-system-fallback.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# Ripgrep System Fallback Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make `getRipgrepConfig()` automatically fall back to system `rg` on `PATH` when the builtin/bundled ripgrep is missing (e.g. on Android/Termux), and surface the fallback via `/doctor` plus a one-time startup warning.
|
||||
|
||||
**Architecture:** Add an `existsSync` check on the builtin rg path before returning it. If missing, query `findExecutable('rg', [])`; if found, use system rg with a new human-readable `note` field on `RipgrepConfig` / `getRipgrepStatus()`. Consumers (`/doctor`, startup) read `note` and render it. No new modes — `mode` stays `'system' | 'builtin' | 'embedded'`; `note` carries the fallback narrative.
|
||||
|
||||
**Tech Stack:** TypeScript, Bun runtime, `bun:test`, Biome, lodash `memoize`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-15-ripgrep-system-fallback-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `src/utils/ripgrep.ts` — extend `RipgrepConfig` type with `note?`; extend internal `ripgrepStatus` singleton with `note?`; extend `getRipgrepStatus()` return type with `note?`; rewrite the `builtin` branch of `getRipgrepConfig()` to add `existsSync` + system-rg fallback; sync `note` into the singleton inside `testRipgrepOnFirstUse`.
|
||||
- **Create** `src/utils/__tests__/ripgrepConfig.test.ts` — five-branch decision coverage for `getRipgrepConfig()`.
|
||||
- **Modify** `src/utils/doctorDiagnostic.ts` — propagate `note` from `getRipgrepStatus()` into the diagnostic object.
|
||||
- **Modify** `src/screens/Doctor.tsx` — render `note` in the `Search:` line.
|
||||
- **Modify** `src/entrypoints/init.ts` — emit a one-time stderr warning when `note` is set.
|
||||
|
||||
Each file has a single clear responsibility and changes stay inside that file's existing role.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend types with optional `note` field (no behavior change)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/utils/ripgrep.ts:22-27` (type), `:29-63` (function — minimal shape only), `:523-527` (singleton), `:533-544` (public getter)
|
||||
|
||||
This task only adds the optional field everywhere it's needed and populates it with `undefined` for existing branches. Behavior stays identical. Task 2 fills in the real values.
|
||||
|
||||
- [ ] **Step 1: Extend `RipgrepConfig` type**
|
||||
|
||||
File: `src/utils/ripgrep.ts`, replace lines 22-27.
|
||||
|
||||
```ts
|
||||
type RipgrepConfig = {
|
||||
mode: 'system' | 'builtin' | 'embedded'
|
||||
command: string
|
||||
args: string[]
|
||||
argv0?: string
|
||||
note?: string
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend the `ripgrepStatus` singleton shape**
|
||||
|
||||
File: `src/utils/ripgrep.ts`, replace lines 522-527.
|
||||
|
||||
```ts
|
||||
// Singleton to store ripgrep availability status
|
||||
let ripgrepStatus: {
|
||||
working: boolean
|
||||
lastTested: number
|
||||
config: RipgrepConfig
|
||||
note?: string
|
||||
} | null = null
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Extend `getRipgrepStatus()` return type**
|
||||
|
||||
File: `src/utils/ripgrep.ts`, replace lines 533-544.
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Get ripgrep status and configuration info
|
||||
* Returns current configuration immediately, with working status if available
|
||||
*/
|
||||
export function getRipgrepStatus(): {
|
||||
mode: 'system' | 'builtin' | 'embedded'
|
||||
path: string
|
||||
working: boolean | null // null if not yet tested
|
||||
note?: string
|
||||
} {
|
||||
const config = getRipgrepConfig()
|
||||
return {
|
||||
mode: config.mode,
|
||||
path: config.command,
|
||||
working: ripgrepStatus?.working ?? null,
|
||||
note: ripgrepStatus?.note ?? config.note,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify typecheck**
|
||||
|
||||
Run: `bunx tsc --noEmit`
|
||||
Expected: 0 errors. (All `note` fields are optional; existing code is unaffected.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/ripgrep.ts
|
||||
git commit -m "refactor: add optional note field to RipgrepConfig and getRipgrepStatus"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Implement fallback decision in `getRipgrepConfig()` (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/utils/ripgrep.ts:1-20` (imports), `:56-63` (builtin branch)
|
||||
- Test: `src/utils/__tests__/ripgrepConfig.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test file**
|
||||
|
||||
Create `src/utils/__tests__/ripgrepConfig.test.ts` with this exact content:
|
||||
|
||||
```ts
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
// Mock shared side-effect modules. log.ts pulls in bootstrap/state which has
|
||||
// realpathSync side effects at import time. See project CLAUDE.md "Mock 使用规范".
|
||||
mock.module('src/utils/log.ts', () => ({
|
||||
logError: () => {},
|
||||
logEvent: () => {},
|
||||
}))
|
||||
mock.module('src/utils/debug.ts', () => ({
|
||||
logForDebugging: () => {},
|
||||
}))
|
||||
|
||||
// Overridable fakes. Defaults match the "builtin exists" happy path on the
|
||||
// runner's actual platform (no process.platform override — avoids polluting
|
||||
// other tests in the same Bun process, see CLAUDE.md mock contamination note).
|
||||
let fakeExistsSync = (): boolean => true
|
||||
let fakeWhich: string | null = '/usr/local/bin/rg'
|
||||
let fakeBundled = false
|
||||
|
||||
mock.module('node:fs', () => ({
|
||||
existsSync: (p: string) => fakeExistsSync(p),
|
||||
realpathSync: (p: string) => p,
|
||||
constants: {},
|
||||
}))
|
||||
mock.module('src/utils/which.ts', () => ({
|
||||
whichSync: () => fakeWhich,
|
||||
}))
|
||||
mock.module('src/utils/bundledMode.ts', () => ({
|
||||
isInBundledMode: () => fakeBundled,
|
||||
}))
|
||||
mock.module('src/utils/envUtils.ts', () => ({
|
||||
isEnvDefinedFalsy: (v: string | undefined) =>
|
||||
v !== undefined &&
|
||||
['0', 'false', 'no', 'off'].includes(v.toLowerCase().trim()),
|
||||
isEnvTruthy: (v: string | undefined) =>
|
||||
v !== undefined &&
|
||||
['1', 'true', 'yes', 'on'].includes(v.toLowerCase().trim()),
|
||||
}))
|
||||
mock.module('src/utils/distRoot.ts', () => ({
|
||||
distRoot: '/fake/dist',
|
||||
}))
|
||||
mock.module('os', () => ({
|
||||
homedir: () => '/fake/home',
|
||||
tmpdir: () => '/tmp',
|
||||
}))
|
||||
// Disable memoize so each call re-evaluates with current fakes.
|
||||
mock.module('lodash-es/memoize.js', () => ({
|
||||
default: <T extends (...args: any[]) => any>(fn: T): T => fn,
|
||||
}))
|
||||
|
||||
const { getRipgrepConfig } = await import('../ripgrep.ts')
|
||||
|
||||
describe('getRipgrepConfig', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
beforeEach(() => {
|
||||
fakeExistsSync = () => true
|
||||
fakeWhich = '/usr/local/bin/rg'
|
||||
fakeBundled = false
|
||||
delete process.env.USE_BUILTIN_RIPGREP
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
test('USE_BUILTIN_RIPGREP=0 with system rg -> mode=system, no note', () => {
|
||||
process.env.USE_BUILTIN_RIPGREP = '0'
|
||||
const cfg = getRipgrepConfig()
|
||||
expect(cfg.mode).toBe('system')
|
||||
expect(cfg.command).toBe('rg')
|
||||
expect(cfg.note).toBeUndefined()
|
||||
})
|
||||
|
||||
test('bundled mode -> mode=embedded, no note', () => {
|
||||
fakeBundled = true
|
||||
const cfg = getRipgrepConfig()
|
||||
expect(cfg.mode).toBe('embedded')
|
||||
expect(cfg.note).toBeUndefined()
|
||||
})
|
||||
|
||||
test('builtin path exists -> mode=builtin, no note', () => {
|
||||
fakeExistsSync = () => true
|
||||
const cfg = getRipgrepConfig()
|
||||
expect(cfg.mode).toBe('builtin')
|
||||
expect(cfg.note).toBeUndefined()
|
||||
})
|
||||
|
||||
test('builtin missing + system rg available -> mode=system, note set', () => {
|
||||
fakeExistsSync = () => false
|
||||
fakeWhich = '/usr/local/bin/rg'
|
||||
const cfg = getRipgrepConfig()
|
||||
expect(cfg.mode).toBe('system')
|
||||
expect(cfg.command).toBe('rg')
|
||||
expect(typeof cfg.note).toBe('string')
|
||||
expect(cfg.note).toContain('fallback')
|
||||
// Note contains process.platform verbatim — assert the substring shape,
|
||||
// not a specific platform, so the test is portable.
|
||||
expect(cfg.note).toMatch(/builtin rg unavailable on \w+, using system rg/)
|
||||
})
|
||||
|
||||
test('builtin missing + system rg missing -> mode=builtin, note set', () => {
|
||||
fakeExistsSync = () => false
|
||||
fakeWhich = null
|
||||
const cfg = getRipgrepConfig()
|
||||
expect(cfg.mode).toBe('builtin')
|
||||
expect(typeof cfg.note).toBe('string')
|
||||
expect(cfg.note).toContain('no ripgrep available')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/utils/__tests__/ripgrepConfig.test.ts`
|
||||
Expected: The fourth and fifth tests FAIL — currently `getRipgrepConfig()` returns `mode='builtin'` with no `note` when the builtin path is missing, instead of falling back to system rg.
|
||||
|
||||
- [ ] **Step 3: Add `existsSync` import to `ripgrep.ts`**
|
||||
|
||||
File: `src/utils/ripgrep.ts`, replace lines 1-2.
|
||||
|
||||
```ts
|
||||
import type { ChildProcess, ExecFileException } from 'child_process'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Rewrite the builtin branch with fallback logic**
|
||||
|
||||
File: `src/utils/ripgrep.ts`, replace lines 56-63.
|
||||
|
||||
```ts
|
||||
const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep')
|
||||
const command =
|
||||
process.platform === 'win32'
|
||||
? path.resolve(rgRoot, `${process.arch}-win32`, 'rg.exe')
|
||||
: path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg')
|
||||
|
||||
// Builtin binary missing (e.g. Android/Termux, or incomplete install).
|
||||
// Fall back to system rg on PATH. If neither is available, keep the
|
||||
// (nonexistent) builtin path so upper layers still see ENOENT, but
|
||||
// surface a human-readable note so /doctor and startup can explain.
|
||||
if (!existsSync(command)) {
|
||||
const { cmd: systemPath } = findExecutable('rg', [])
|
||||
if (systemPath !== 'rg') {
|
||||
return {
|
||||
mode: 'system',
|
||||
command: 'rg',
|
||||
args: [],
|
||||
note: `fallback: builtin rg unavailable on ${process.platform}, using system rg`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'builtin',
|
||||
command,
|
||||
args: [],
|
||||
note: `no ripgrep available on ${process.platform}; install via apt/pkg/brew or set USE_BUILTIN_RIPGREP=0`,
|
||||
}
|
||||
}
|
||||
|
||||
return { mode: 'builtin', command, args: [] }
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/utils/__tests__/ripgrepConfig.test.ts`
|
||||
Expected: PASS (5/5).
|
||||
|
||||
- [ ] **Step 6: Run full precheck to ensure no regression**
|
||||
|
||||
Run: `bun run precheck`
|
||||
Expected: 0 typecheck errors, 0 lint errors, all tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/ripgrep.ts src/utils/__tests__/ripgrepConfig.test.ts
|
||||
git commit -m "feat: ripgrep falls back to system rg when builtin binary missing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Sync `note` into the singleton inside `testRipgrepOnFirstUse`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/utils/ripgrep.ts:549-615`
|
||||
|
||||
Currently `testRipgrepOnFirstUse` writes `ripgrepStatus = { working, lastTested, config }` without `note`. The new `getRipgrepStatus()` in Task 1 already falls back to `config.note` if the singleton has none, so this task is mostly belt-and-suspenders: persist the note explicitly so consumers reading the singleton directly also see it.
|
||||
|
||||
- [ ] **Step 1: Update the success-path assignment**
|
||||
|
||||
File: `src/utils/ripgrep.ts`, replace lines 592-596.
|
||||
|
||||
```ts
|
||||
ripgrepStatus = {
|
||||
working,
|
||||
lastTested: Date.now(),
|
||||
config,
|
||||
note: config.note,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the catch-path assignment**
|
||||
|
||||
File: `src/utils/ripgrep.ts`, replace lines 608-612.
|
||||
|
||||
```ts
|
||||
ripgrepStatus = {
|
||||
working: false,
|
||||
lastTested: Date.now(),
|
||||
config,
|
||||
note: config.note,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run precheck**
|
||||
|
||||
Run: `bun run precheck`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/ripgrep.ts
|
||||
git commit -m "refactor: persist ripgrep config.note in testRipgrepOnFirstUse singleton"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Propagate `note` through `/doctor`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/utils/doctorDiagnostic.ts:588-597`
|
||||
- Modify: `src/screens/Doctor.tsx:224-232`
|
||||
|
||||
- [ ] **Step 1: Extend the diagnostic object**
|
||||
|
||||
File: `src/utils/doctorDiagnostic.ts`, replace lines 588-597.
|
||||
|
||||
```ts
|
||||
// Get ripgrep status and configuration info
|
||||
const ripgrepStatusRaw = getRipgrepStatus()
|
||||
|
||||
// Provide simple ripgrep status info
|
||||
const ripgrepStatus = {
|
||||
working: ripgrepStatusRaw.working ?? true, // Assume working if not yet tested
|
||||
mode: ripgrepStatusRaw.mode,
|
||||
systemPath:
|
||||
ripgrepStatusRaw.mode === 'system' ? ripgrepStatusRaw.path : null,
|
||||
note: ripgrepStatusRaw.note ?? null,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Render `note` in Doctor.tsx**
|
||||
|
||||
File: `src/screens/Doctor.tsx`, replace lines 224-232.
|
||||
|
||||
```tsx
|
||||
<Text>
|
||||
└ Search: {diagnostic.ripgrepStatus.working ? 'OK' : 'Not working'} (
|
||||
{diagnostic.ripgrepStatus.mode === 'embedded'
|
||||
? 'bundled'
|
||||
: diagnostic.ripgrepStatus.mode === 'builtin'
|
||||
? 'vendor'
|
||||
: diagnostic.ripgrepStatus.systemPath || 'system'}
|
||||
)
|
||||
</Text>
|
||||
{diagnostic.ripgrepStatus.note && (
|
||||
<Text color="warning">
|
||||
└ Note: {diagnostic.ripgrepStatus.note}
|
||||
</Text>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run precheck (lint + typecheck)**
|
||||
|
||||
Run: `bun run precheck`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 4: Manual smoke check (optional)**
|
||||
|
||||
Run: `bun run dev -- doctor 2>&1 | grep -i search`
|
||||
Expected: prints the `Search:` line; on dev machine `note` should be empty so no `Note:` line appears.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/doctorDiagnostic.ts src/screens/Doctor.tsx
|
||||
git commit -m "feat: /doctor shows ripgrep fallback note"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Emit one-time startup warning from `init.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/entrypoints/init.ts:240-243`
|
||||
|
||||
- [ ] **Step 1: Add the warning right before `profileCheckpoint('init_function_end')`**
|
||||
|
||||
File: `src/entrypoints/init.ts`, replace lines 240-243.
|
||||
|
||||
```ts
|
||||
// Surface ripgrep fallback (e.g. Android/Termux) once per session.
|
||||
// Goes to stderr so it doesn't corrupt pipe-mode (`-p`) stdout.
|
||||
try {
|
||||
const { getRipgrepStatus } = await import('../utils/ripgrep.js')
|
||||
const status = getRipgrepStatus()
|
||||
if (status.note) {
|
||||
process.stderr.write(`[ripgrep] ${status.note}\n`)
|
||||
}
|
||||
} catch {
|
||||
// Ripgrep status is best-effort; never block init.
|
||||
}
|
||||
|
||||
logForDiagnosticsNoPII('info', 'init_completed', {
|
||||
duration_ms: Date.now() - initStartTime,
|
||||
})
|
||||
profileCheckpoint('init_function_end')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run precheck**
|
||||
|
||||
Run: `bun run precheck`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 3: Manual smoke check**
|
||||
|
||||
Simulate fallback by pointing vendor at a missing path is non-trivial; instead verify no warning fires on the dev machine (where builtin exists):
|
||||
Run: `bun run dev -- --version`
|
||||
Expected: `[ripgrep]` line does NOT appear on stderr.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/entrypoints/init.ts
|
||||
git commit -m "feat: warn on stderr when ripgrep falls back to system rg"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Final full precheck + verification
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Run full precheck**
|
||||
|
||||
Run: `bun run precheck`
|
||||
Expected: `XXXX pass / 0 fail`, 0 typecheck errors, 0 lint errors.
|
||||
|
||||
- [ ] **Step 2: Verify the five-branch test still passes**
|
||||
|
||||
Run: `bun test src/utils/__tests__/ripgrepConfig.test.ts`
|
||||
Expected: PASS (5/5).
|
||||
|
||||
- [ ] **Step 3: Verify decision logic via REPL sanity (optional)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
bun -e "import('./src/utils/ripgrep.ts').then(m => console.log(JSON.stringify(m.getRipgrepStatus(), null, 2)))"
|
||||
```
|
||||
Expected on macOS dev machine: `mode: "builtin"`, `note: undefined`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
**Spec coverage:**
|
||||
- Decision chain with 5 branches → Task 2 ✓
|
||||
- `note` field on `RipgrepConfig` / singleton / `getRipgrepStatus()` → Tasks 1, 3 ✓
|
||||
- `/doctor` rendering → Task 4 ✓
|
||||
- Startup warning → Task 5 ✓
|
||||
- Tests for 5 branches → Task 2 Step 1 ✓
|
||||
- Acceptance criteria 1-5 cross-checked against spec section "Acceptance Criteria"
|
||||
|
||||
**Placeholder scan:** None. Each step contains the exact code or command.
|
||||
|
||||
**Type consistency:** `note?: string` consistently used across `RipgrepConfig`, `ripgrepStatus` singleton, `getRipgrepStatus()` return, `doctorDiagnostic.ripgrepStatus.note`. In Doctor.tsx the diagnostic object's `note` is `string | null` (Task 4 Step 1 uses `?? null`), accessed with a truthy check (`{note && ...}`) which handles both `null` and `undefined`.
|
||||
|
||||
**Mock hygiene note:** Task 2's test mocks `node:fs`, `src/utils/which.ts`, `src/utils/bundledMode.ts`, `src/utils/envUtils.ts`, `src/utils/distRoot.ts`, `os`, and `lodash-es/memoize.js`. These are process-global mocks (Bun limitation — see project CLAUDE.md "Mock 使用规范"). The test file lives at `src/utils/__tests__/ripgrepConfig.test.ts` and there is no existing `ripgrep.test.ts` to collide with, so no contamination risk.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Ripgrep System Fallback — Design
|
||||
|
||||
**Date:** 2026-06-15
|
||||
**Status:** Approved (pending spec review)
|
||||
**Topic:** Make ripgrep gracefully degrade to system `rg` when the bundled/builtin binary is unavailable on the current platform (e.g. Android/Termux).
|
||||
|
||||
## Problem
|
||||
|
||||
`src/utils/ripgrep.ts` `getRipgrepConfig()` has three resolution branches:
|
||||
|
||||
1. `USE_BUILTIN_RIPGREP=0` → look up `rg` on `PATH`
|
||||
2. `isInBundledMode()` → bun-internal embedded rg
|
||||
3. Otherwise → `vendor/ripgrep/<arch>-<platform>/rg` (builtin)
|
||||
|
||||
On Android/Termux, all three fail:
|
||||
|
||||
- The user has not opted into system rg.
|
||||
- Bun does not publish Android builds, so `isInBundledMode()` is false.
|
||||
- `scripts/postinstall.cjs:81` throws `Unsupported platform: android`, so no builtin binary is ever downloaded. `vendor/ripgrep/` contains no `arm64-android` directory.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Goals
|
||||
|
||||
- On any platform, when the builtin/bundled ripgrep is unavailable, automatically fall back to `rg` on `PATH`.
|
||||
- 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.
|
||||
- Do not change behavior on platforms where the builtin rg works (macOS, Linux, Windows).
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Downloading or shipping an Android-native ripgrep binary.
|
||||
- Adding a REPL persistent status indicator.
|
||||
- Touching `USE_BUILTIN_RIPGREP` semantics for users who already opt into system rg.
|
||||
- Modifying build / `postinstall.cjs` platform mapping.
|
||||
|
||||
## Design
|
||||
|
||||
### Decision chain (`getRipgrepConfig`)
|
||||
|
||||
The function gains an existence check and a system-rg fallback. The order of existing branches is preserved.
|
||||
|
||||
```
|
||||
1. USE_BUILTIN_RIPGREP=0 (user-opt) → system rg mode='system' note=undefined
|
||||
2. isInBundledMode() → bun embedded rg mode='embedded' note=undefined
|
||||
3. Compute builtin path; existsSync(rgPath)?
|
||||
✓ true → builtin rg mode='builtin' note=undefined
|
||||
✓ false → findExecutable('rg', [])
|
||||
✓ found → system rg (auto fallback) mode='system' note='fallback: builtin rg unavailable on <platform>, using system rg'
|
||||
✗ missing → keep builtin path (let upper layer ENOENT) mode='builtin' note='no ripgrep available on <platform>; install via apt/pkg/brew/...'
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
`existsSync` is a single synchronous syscall; `getRipgrepConfig` is already memoized via lodash, so the cost is paid once per process.
|
||||
|
||||
### Status API (`getRipgrepStatus`)
|
||||
|
||||
```ts
|
||||
type RipgrepStatus = {
|
||||
mode: 'system' | 'builtin' | 'embedded' // unchanged
|
||||
path: string // unchanged
|
||||
working: boolean | null // unchanged
|
||||
note?: string // NEW — human-readable hint
|
||||
}
|
||||
```
|
||||
|
||||
The internal `ripgrepStatus` singleton also gains `note?: string`. `testRipgrepOnFirstUse` propagates the note from the active config.
|
||||
|
||||
The `note` value is sourced from `getRipgrepConfig()` (the source of truth), so the API remains a single read; no second lookup.
|
||||
|
||||
### UI — `/doctor`
|
||||
|
||||
`src/screens/Doctor.tsx` renders the existing `Search:` line plus the note when present. Two example outputs:
|
||||
|
||||
```
|
||||
Search: OK (system rg fallback — builtin ripgrep unavailable on android)
|
||||
Search: Not working (no ripgrep available on android — install via apt/pkg/brew)
|
||||
```
|
||||
|
||||
`src/utils/doctorDiagnostic.ts` extends the `ripgrepStatus` object it returns to include `note`.
|
||||
|
||||
### UI — startup warning
|
||||
|
||||
A single check near the end of `src/entrypoints/init.ts` reads `getRipgrepStatus()`. If `note` is set, it writes one line to stderr:
|
||||
|
||||
```
|
||||
[ripgrep] fallback: builtin rg unavailable on android, using system rg
|
||||
```
|
||||
|
||||
Constraints:
|
||||
- Non-blocking — does not throw or exit.
|
||||
- Fires at most once per process (memoized config + idempotent init).
|
||||
- Goes to stderr so it does not corrupt pipe mode (`-p`) stdout.
|
||||
- No retry, no telemetry beyond existing `tengu_ripgrep_availability`.
|
||||
|
||||
### Testing
|
||||
|
||||
New test file `src/utils/__tests__/ripgrepDecision.test.ts` (or extend an existing one) covers the five branches:
|
||||
|
||||
1. `USE_BUILTIN_RIPGREP=0` and `rg` on PATH → `mode='system'`, `note=undefined`.
|
||||
2. `isInBundledMode()` → `mode='embedded'`, `note=undefined`.
|
||||
3. Builtin path exists → `mode='builtin'`, `note=undefined`.
|
||||
4. Builtin path missing, `rg` on PATH → `mode='system'`, `note` set.
|
||||
5. Builtin path missing, `rg` not on PATH → `mode='builtin'`, `note` set (path is the nonexistent builtin path).
|
||||
|
||||
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.
|
||||
|
||||
Existing `doctorDiagnostic` tests: extend to assert `note` is propagated; update any snapshots.
|
||||
|
||||
## Risks
|
||||
|
||||
- **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.
|
||||
- **`note` field addition is backward-compatible:** optional field; existing consumers ignore it.
|
||||
- **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.
|
||||
- **Platform string in `note`:** uses `process.platform` directly (`'android'`, `'linux'`, `'darwin'`, `'win32'`). No translation; the message is diagnostic, not user-facing marketing copy.
|
||||
|
||||
## Out of Scope (YAGNI)
|
||||
|
||||
- Android prebuilt binary download.
|
||||
- Persistent REPL status indicator.
|
||||
- Build-time vendor changes.
|
||||
- Telemetry beyond what `testRipgrepOnFirstUse` already emits.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- 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.
|
||||
- On a platform where neither builtin nor system rg is available, `/doctor` displays `Not working` plus the install hint.
|
||||
- The startup warning fires exactly once per session when `note` is set.
|
||||
- All existing ripgrep tests pass unchanged on macOS/Linux dev machines.
|
||||
- `bun run precheck` is green.
|
||||
@@ -237,6 +237,18 @@ export const init = memoize(async (): Promise<void> => {
|
||||
})
|
||||
}
|
||||
|
||||
// Surface ripgrep fallback (e.g. Android/Termux) once per session.
|
||||
// Goes to stderr so it doesn't corrupt pipe-mode (`-p`) stdout.
|
||||
try {
|
||||
const { getRipgrepStatus } = await import('../utils/ripgrep.js')
|
||||
const status = getRipgrepStatus()
|
||||
if (status.note) {
|
||||
process.stderr.write(`[ripgrep] ${status.note}\n`)
|
||||
}
|
||||
} catch {
|
||||
// Ripgrep status is best-effort; never block init.
|
||||
}
|
||||
|
||||
logForDiagnosticsNoPII('info', 'init_completed', {
|
||||
duration_ms: Date.now() - initStartTime,
|
||||
})
|
||||
|
||||
@@ -230,6 +230,7 @@ export function Doctor({ onDone }: Props): React.ReactNode {
|
||||
: diagnostic.ripgrepStatus.systemPath || 'system'}
|
||||
)
|
||||
</Text>
|
||||
{diagnostic.ripgrepStatus.note && <Text color="warning">└ Note: {diagnostic.ripgrepStatus.note}</Text>}
|
||||
|
||||
{/* Show recommendation if auto-updates are disabled */}
|
||||
{diagnostic.recommendation && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { execFileSync, spawn } from 'child_process'
|
||||
import { constants as fsConstants, readFileSync, unlinkSync } from 'fs'
|
||||
import { type FileHandle, mkdir, open, realpath } from 'fs/promises'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { tmpdir } from 'os'
|
||||
import { isAbsolute, resolve } from 'path'
|
||||
import { join as posixJoin } from 'path/posix'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
@@ -200,9 +201,10 @@ export async function exec(
|
||||
.toString(16)
|
||||
.padStart(4, '0')
|
||||
|
||||
// Sandbox temp directory - use per-user directory name to prevent multi-user permission conflicts
|
||||
// Sandbox temp directory - use per-user directory name to prevent multi-user permission conflicts.
|
||||
// tmpdir() honors $TMPDIR so non-/tmp environments (Termux/Android, containers) work out of the box.
|
||||
const sandboxTmpDir = posixJoin(
|
||||
process.env.CLAUDE_CODE_TMPDIR || '/tmp',
|
||||
process.env.CLAUDE_CODE_TMPDIR || tmpdir(),
|
||||
getClaudeTempDirName(),
|
||||
)
|
||||
|
||||
|
||||
75
src/utils/__tests__/ripgrepConfig.test.ts
Normal file
75
src/utils/__tests__/ripgrepConfig.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
// Test the pure fallback function directly — no mock.module needed,
|
||||
// so this test cannot pollute other tests in the same Bun process.
|
||||
// See CLAUDE.md "Mock 使用规范" for why we avoid business-module mocking.
|
||||
const { resolveBuiltinWithFallback } = await import('../ripgrep.js')
|
||||
|
||||
// Real temp dir with a real (or removed) fake rg binary to control existsSync.
|
||||
const tmpDir = join(
|
||||
globalThis.process.env.TMPDIR || '/tmp',
|
||||
'ripgrep-config-test',
|
||||
)
|
||||
const vendorDir = join(
|
||||
tmpDir,
|
||||
'vendor',
|
||||
'ripgrep',
|
||||
`${process.arch}-${process.platform}`,
|
||||
)
|
||||
const rgPath = join(vendorDir, process.platform === 'win32' ? 'rg.exe' : 'rg')
|
||||
|
||||
describe('resolveBuiltinWithFallback', () => {
|
||||
beforeAll(() => {
|
||||
mkdirSync(vendorDir, { recursive: true })
|
||||
writeFileSync(rgPath, '')
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('builtin exists -> mode=builtin, no note', () => {
|
||||
const result = resolveBuiltinWithFallback(rgPath)
|
||||
expect(result.mode).toBe('builtin')
|
||||
expect(result.command).toBe(rgPath)
|
||||
expect(result.note).toBeUndefined()
|
||||
})
|
||||
|
||||
test('builtin missing + system rg available -> mode=system, note set', () => {
|
||||
rmSync(rgPath)
|
||||
const result = resolveBuiltinWithFallback(
|
||||
rgPath,
|
||||
'/usr/local/bin/rg', // explicit system rg path
|
||||
'testplatform',
|
||||
)
|
||||
expect(result.mode).toBe('system')
|
||||
expect(result.command).toBe('rg')
|
||||
expect(result.note).toContain('fallback')
|
||||
expect(result.note).toContain('testplatform')
|
||||
// Restore for subsequent tests
|
||||
writeFileSync(rgPath, '')
|
||||
})
|
||||
|
||||
test('builtin missing + system rg missing -> mode=builtin, note set', () => {
|
||||
rmSync(rgPath)
|
||||
const result = resolveBuiltinWithFallback(
|
||||
rgPath,
|
||||
null, // no system rg
|
||||
'testplatform',
|
||||
)
|
||||
expect(result.mode).toBe('builtin')
|
||||
expect(result.command).toBe(rgPath)
|
||||
expect(result.note).toContain('no ripgrep available')
|
||||
expect(result.note).toContain('testplatform')
|
||||
writeFileSync(rgPath, '')
|
||||
})
|
||||
|
||||
test('uses process.platform when platform param omitted', () => {
|
||||
rmSync(rgPath)
|
||||
const result = resolveBuiltinWithFallback(rgPath, null)
|
||||
expect(result.note).toContain(process.platform)
|
||||
writeFileSync(rgPath, '')
|
||||
})
|
||||
})
|
||||
@@ -67,6 +67,7 @@ export type DiagnosticInfo = {
|
||||
working: boolean
|
||||
mode: 'system' | 'builtin' | 'embedded'
|
||||
systemPath: string | null
|
||||
note: string | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,6 +595,7 @@ export async function getDoctorDiagnostic(): Promise<DiagnosticInfo> {
|
||||
mode: ripgrepStatusRaw.mode,
|
||||
systemPath:
|
||||
ripgrepStatusRaw.mode === 'system' ? ripgrepStatusRaw.path : null,
|
||||
note: ripgrepStatusRaw.note ?? null,
|
||||
}
|
||||
|
||||
// Get package manager info if running from package manager
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { execa } from 'execa'
|
||||
import { tmpdir } from 'os'
|
||||
import { basename, extname, isAbsolute, join } from 'path'
|
||||
import {
|
||||
IMAGE_MAX_HEIGHT,
|
||||
@@ -32,10 +33,11 @@ function getClipboardCommands() {
|
||||
const platform = process.platform as SupportedPlatform
|
||||
|
||||
// Platform-specific temporary file paths
|
||||
// Use CLAUDE_CODE_TMPDIR if set, otherwise fall back to platform defaults
|
||||
// Use CLAUDE_CODE_TMPDIR if set, otherwise fall back to platform defaults.
|
||||
// tmpdir() honors $TMPDIR so non-/tmp environments (Termux/Android, containers) work out of the box.
|
||||
const baseTmpDir =
|
||||
process.env.CLAUDE_CODE_TMPDIR ||
|
||||
(platform === 'win32' ? process.env.TEMP || 'C:\\Temp' : '/tmp')
|
||||
(platform === 'win32' ? process.env.TEMP || 'C:\\Temp' : tmpdir())
|
||||
const screenshotFilename = 'claude_cli_latest_screenshot.png'
|
||||
const tempPaths: Record<SupportedPlatform, string> = {
|
||||
darwin: join(baseTmpDir, screenshotFilename),
|
||||
|
||||
@@ -329,9 +329,9 @@ export function getClaudeTempDirName(): string {
|
||||
// and per-turn from BashTool prompt. Inputs (CLAUDE_CODE_TMPDIR env + platform) are
|
||||
// fixed at startup, and the realpath of the system tmp dir does not change mid-session.
|
||||
export const getClaudeTempDir = memoize(function getClaudeTempDir(): string {
|
||||
const baseTmpDir =
|
||||
process.env.CLAUDE_CODE_TMPDIR ||
|
||||
(getPlatform() === 'windows' ? tmpdir() : '/tmp')
|
||||
// tmpdir() honors $TMPDIR so non-/tmp environments (Termux/Android, containers)
|
||||
// work out of the box; CLAUDE_CODE_TMPDIR still wins if explicitly set.
|
||||
const baseTmpDir = process.env.CLAUDE_CODE_TMPDIR || tmpdir()
|
||||
|
||||
// Resolve symlinks in the base temp directory (e.g., /tmp -> /private/tmp on macOS)
|
||||
// This ensures the path matches resolved paths in permission checks
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChildProcess, ExecFileException } from 'child_process'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { homedir } from 'os'
|
||||
import * as path from 'path'
|
||||
@@ -24,9 +25,10 @@ type RipgrepConfig = {
|
||||
command: string
|
||||
args: string[]
|
||||
argv0?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
const getRipgrepConfig = memoize((): RipgrepConfig => {
|
||||
export const getRipgrepConfig = memoize((): RipgrepConfig => {
|
||||
const userWantsSystemRipgrep = isEnvDefinedFalsy(
|
||||
process.env.USE_BUILTIN_RIPGREP,
|
||||
)
|
||||
@@ -59,9 +61,61 @@ const getRipgrepConfig = memoize((): RipgrepConfig => {
|
||||
? path.resolve(rgRoot, `${process.arch}-win32`, 'rg.exe')
|
||||
: path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg')
|
||||
|
||||
return { mode: 'builtin', command, args: [] }
|
||||
return resolveBuiltinWithFallback(command)
|
||||
})
|
||||
|
||||
/**
|
||||
* Pure function: decide what to do when the builtin rg binary may be missing.
|
||||
* Extracted so it can be tested without any module mocking.
|
||||
*
|
||||
* @param builtinPath Path to the vendored rg binary.
|
||||
* @param systemRgPath When omitted, calls `findExecutable('rg')` (production path).
|
||||
* Pass a string to force a specific system path, or `null` to
|
||||
* simulate "system rg not found".
|
||||
* @param platform Override for `process.platform` (tests only).
|
||||
*/
|
||||
export function resolveBuiltinWithFallback(
|
||||
builtinPath: string,
|
||||
systemRgPath?: string | null,
|
||||
platform?: string,
|
||||
): {
|
||||
mode: 'system' | 'builtin'
|
||||
command: string
|
||||
args: string[]
|
||||
note?: string
|
||||
} {
|
||||
const p = platform ?? process.platform
|
||||
|
||||
// Builtin exists — use it, no note.
|
||||
if (existsSync(builtinPath)) {
|
||||
return { mode: 'builtin', command: builtinPath, args: [] }
|
||||
}
|
||||
|
||||
// Builtin missing — check system rg.
|
||||
// When systemRgPath is explicitly passed (including null), use it directly.
|
||||
// When undefined, call findExecutable (production path).
|
||||
const resolvedSystem =
|
||||
systemRgPath === undefined
|
||||
? findExecutable('rg', []).cmd
|
||||
: (systemRgPath ?? 'rg')
|
||||
if (resolvedSystem !== 'rg') {
|
||||
return {
|
||||
mode: 'system',
|
||||
command: 'rg',
|
||||
args: [],
|
||||
note: `fallback: builtin rg unavailable on ${p}, using system rg`,
|
||||
}
|
||||
}
|
||||
|
||||
// Neither available.
|
||||
return {
|
||||
mode: 'builtin',
|
||||
command: builtinPath,
|
||||
args: [],
|
||||
note: `no ripgrep available on ${p}; install via apt/pkg/brew or set USE_BUILTIN_RIPGREP=0`,
|
||||
}
|
||||
}
|
||||
|
||||
export function ripgrepCommand(): {
|
||||
rgPath: string
|
||||
rgArgs: string[]
|
||||
@@ -524,6 +578,7 @@ let ripgrepStatus: {
|
||||
working: boolean
|
||||
lastTested: number
|
||||
config: RipgrepConfig
|
||||
note?: string
|
||||
} | null = null
|
||||
|
||||
/**
|
||||
@@ -534,12 +589,14 @@ export function getRipgrepStatus(): {
|
||||
mode: 'system' | 'builtin' | 'embedded'
|
||||
path: string
|
||||
working: boolean | null // null if not yet tested
|
||||
note?: string
|
||||
} {
|
||||
const config = getRipgrepConfig()
|
||||
return {
|
||||
mode: config.mode,
|
||||
path: config.command,
|
||||
working: ripgrepStatus?.working ?? null,
|
||||
note: ripgrepStatus?.note ?? config.note,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,6 +650,7 @@ const testRipgrepOnFirstUse = memoize(async (): Promise<void> => {
|
||||
working,
|
||||
lastTested: Date.now(),
|
||||
config,
|
||||
note: config.note,
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
@@ -609,6 +667,7 @@ const testRipgrepOnFirstUse = memoize(async (): Promise<void> => {
|
||||
working: false,
|
||||
lastTested: Date.now(),
|
||||
config,
|
||||
note: config.note,
|
||||
}
|
||||
logError(error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user