Files
claude-code/docs/superpowers/plans/2026-06-15-ripgrep-system-fallback.md
claude-code-best 6c633744f4 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>
2026-06-15 19:08:31 +08:00

16 KiB

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.

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.

// 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.

/**
 * 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
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:

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.

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.

  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
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.

    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.

    ripgrepStatus = {
      working: false,
      lastTested: Date.now(),
      config,
      note: config.note,
    }
  • Step 3: Run precheck

Run: bun run precheck Expected: 0 errors.

  • Step 4: Commit
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.

  // 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.

        <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
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.

    // 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
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:

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.