Compare commits

...

6 Commits
v2.7.0 ... main

Author SHA1 Message Date
claude-code-best
ddf1acdaed chore: 2.7.1 2026-06-15 19:09:19 +08:00
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
claude-code-best
bb100b16b3 fix: ESC 关闭 local-jsx 面板后添加 grace-period 防止误触 cancel
/workflows 等面板通过 ESC 关闭时,React unmount 与 chat:cancel
keybinding 的 isActive 解除之间存在竞态窗口,导致同一按 ESC
会穿透到 onCancel 并中止正在执行的 Workflow 工具。

添加 500ms grace-period guard:面板关闭时打时间戳,onCancel 在窗口
内吞掉 ESC 并 reset,后续有意 ESC 仍正常取消。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
2026-06-15 16:51:37 +08:00
claude-code-best
0eabcccce9 fix: review — Brave API key + webFetchHttpTimeoutMs 联动 + Tavily URL 推导
- braveAdapter: 读取 settings.braveApiKey (优先于环境变量)
- webFetch utils: getFetchTimeoutMs() 统一读取 settings.webFetchHttpTimeoutMs,HTTP/Tavily 两条路径均生效
- tavilyAdapter: 自定义端点自动追加 /search 路径(与 fetchContentWithTavily 一致)

Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>
2026-06-15 16:51:37 +08:00
claude-code-best
9d845d77b9 feat: 重构 WebSearch/WebFetch,新增 Tavily 适配器及 /web-tools 面板
- WebSearch: 默认 Tavily,适配器优先级 WEB_SEARCH_ADAPTER > settings.webSearchAdapter > tavily
- WebFetch: 支持 Tavily /extract 返回 Markdown,移除 domain blacklist 远程检查
- 新增 /web-tools 命令面板(Search/Fetch 双 Tab + 二级配置菜单)
- 新增 settings 字段: webSearchAdapter, webFetchAdapter, tavilyEndpointUrl, braveApiKey, exaApiKey, exaEndpointUrl, webFetchHttpTimeoutMs
- 适配器联动: Tavily/Exa 从 settings 读取 endpoint 和 API key

Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>
2026-06-15 16:51:37 +08:00
claude-code-best
2714bbf812 docs: update contributors 2026-06-15 00:28:17 +00:00
24 changed files with 1898 additions and 218 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-code-best", "name": "claude-code-best",
"version": "2.7.0", "version": "2.7.1",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal", "description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module", "type": "module",
"author": "claude-code-best <claude-code-best@proton.me>", "author": "claude-code-best <claude-code-best@proton.me>",

View File

@@ -5,6 +5,7 @@ import { formatFileSize } from 'src/utils/format.js'
import { lazySchema } from 'src/utils/lazySchema.js' import { lazySchema } from 'src/utils/lazySchema.js'
import type { PermissionDecision } from 'src/utils/permissions/PermissionResult.js' import type { PermissionDecision } from 'src/utils/permissions/PermissionResult.js'
import { getRuleByContentsForTool } from 'src/utils/permissions/permissions.js' import { getRuleByContentsForTool } from 'src/utils/permissions/permissions.js'
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
import { isPreapprovedHost } from './preapproved.js' import { isPreapprovedHost } from './preapproved.js'
import { DESCRIPTION, WEB_FETCH_TOOL_NAME } from './prompt.js' import { DESCRIPTION, WEB_FETCH_TOOL_NAME } from './prompt.js'
import { import {
@@ -16,6 +17,7 @@ import {
import { import {
applyPromptToMarkdown, applyPromptToMarkdown,
type FetchedContent, type FetchedContent,
fetchContentWithTavily,
getURLMarkdownContent, getURLMarkdownContent,
isPreapprovedUrl, isPreapprovedUrl,
MAX_MARKDOWN_LENGTH, MAX_MARKDOWN_LENGTH,
@@ -211,6 +213,72 @@ ${DESCRIPTION}`
) { ) {
const start = Date.now() const start = Date.now()
// Select backend: settings.webFetchAdapter → default 'tavily'
const settings = getSettings_DEPRECATED()
const backend = settings.webFetchAdapter ?? 'tavily'
// Tavily path: /extract returns Markdown directly — skip turndown + queryHaiku
if (backend === 'tavily') {
const response = await fetchContentWithTavily(url, abortController)
if ('type' in response && response.type === 'redirect') {
const statusText = 'See Other'
const message = `REDIRECT DETECTED: The URL redirects to a different host.
Original URL: ${(response as { originalUrl: string }).originalUrl}
Redirect URL: ${(response as { redirectUrl: string }).redirectUrl}
Please use WebFetch again with the redirect URL.`
const output: Output = {
bytes: Buffer.byteLength(message),
code: 302,
codeText: statusText,
result: message,
durationMs: Date.now() - start,
url,
}
return { data: output }
}
const {
content,
bytes,
code,
codeText,
contentType,
persistedPath,
persistedSize,
} = response as FetchedContent
let result = content
if (prompt && prompt.trim()) {
// Tavily extract returns raw Markdown — if user provided a prompt,
// still run secondary model call for content processing
result = await applyPromptToMarkdown(
prompt,
content,
abortController.signal,
isNonInteractiveSession,
isPreapprovedUrl(url),
)
}
if (persistedPath) {
result += `\n\n[Binary content (${contentType}, ${formatFileSize(persistedSize ?? bytes)}) also saved to ${persistedPath}]`
}
const output: Output = {
bytes,
code,
codeText,
result,
durationMs: Date.now() - start,
url,
}
return { data: output }
}
// HTTP direct path (original behavior): fetch + turndown + queryHaiku
const response = await getURLMarkdownContent(url, abortController) const response = await getURLMarkdownContent(url, abortController)
// Check if we got a redirect to a different host // Check if we got a redirect to a different host

View File

@@ -17,23 +17,9 @@ import { asSystemPrompt } from 'src/utils/systemPromptType.js'
import { isPreapprovedHost } from './preapproved.js' import { isPreapprovedHost } from './preapproved.js'
import { makeSecondaryModelPrompt } from './prompt.js' import { makeSecondaryModelPrompt } from './prompt.js'
// Custom error classes for domain blocking const DEFAULT_TAVILY_EXTRACT_URL = 'https://tavily.claude-code-best.win/extract'
class DomainBlockedError extends Error {
constructor(domain: string) {
super(`Claude Code is unable to fetch from ${domain}`)
this.name = 'DomainBlockedError'
}
}
class DomainCheckFailedError extends Error {
constructor(domain: string) {
super(
`Unable to verify if domain ${domain} is safe to fetch. This may be due to network restrictions or enterprise security policies blocking claude.ai.`,
)
this.name = 'DomainCheckFailedError'
}
}
// Custom error class for egress proxy blocks
class EgressBlockedError extends Error { class EgressBlockedError extends Error {
constructor(public readonly domain: string) { constructor(public readonly domain: string) {
super( super(
@@ -68,18 +54,8 @@ const URL_CACHE = new LRUCache<string, CacheEntry>({
ttl: CACHE_TTL_MS, ttl: CACHE_TTL_MS,
}) })
// Separate cache for preflight domain checks. URL_CACHE is URL-keyed, so
// fetching two paths on the same domain triggers two identical preflight
// HTTP round-trips to api.anthropic.com. This hostname-keyed cache avoids
// that. Only 'allowed' is cached — blocked/failed re-check on next attempt.
const DOMAIN_CHECK_CACHE = new LRUCache<string, true>({
max: 128,
ttl: 5 * 60 * 1000, // 5 minutes — shorter than URL_CACHE TTL
})
export function clearWebFetchCache(): void { export function clearWebFetchCache(): void {
URL_CACHE.clear() URL_CACHE.clear()
DOMAIN_CHECK_CACHE.clear()
} }
function responseHeaderToString(value: unknown): string | undefined { function responseHeaderToString(value: unknown): string | undefined {
@@ -141,13 +117,19 @@ const MAX_HTTP_CONTENT_LENGTH = 10 * 1024 * 1024
// Timeout for the main HTTP fetch request (60 seconds). // Timeout for the main HTTP fetch request (60 seconds).
// Prevents hanging indefinitely on slow/unresponsive servers. // Prevents hanging indefinitely on slow/unresponsive servers.
const FETCH_TIMEOUT_MS = 60_000 // Overridable via settings.webFetchHttpTimeoutMs (set in /web-tools panel).
const DEFAULT_FETCH_TIMEOUT_MS = 60_000
// Timeout for the domain blocklist preflight check (10 seconds). function getFetchTimeoutMs(): number {
const DOMAIN_CHECK_TIMEOUT_MS = 10_000 const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
webFetchHttpTimeoutMs?: number
}
return settings.webFetchHttpTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS
}
// Cap same-host redirect hops. Without this a malicious server can return // Cap same-host redirect hops. Without this a malicious server can return
// a redirect loop (/a → /b → /a …) and the per-request FETCH_TIMEOUT_MS // a redirect loop (/a → /b → /a …) and the per-request timeout
// (controlled by settings.webFetchHttpTimeoutMs)
// resets on every hop, hanging the tool until user interrupt. 10 matches // resets on every hop, hanging the tool until user interrupt. 10 matches
// common client defaults (axios=5, follow-redirects=21, Chrome=20). // common client defaults (axios=5, follow-redirects=21, Chrome=20).
const MAX_REDIRECTS = 10 const MAX_REDIRECTS = 10
@@ -196,40 +178,6 @@ export function validateURL(url: string): boolean {
return true return true
} }
type DomainCheckResult =
| { status: 'allowed' }
| { status: 'blocked' }
| { status: 'check_failed'; error: Error }
export async function checkDomainBlocklist(
domain: string,
): Promise<DomainCheckResult> {
if (DOMAIN_CHECK_CACHE.has(domain)) {
return { status: 'allowed' }
}
try {
const response = await axios.get(
`https://api.anthropic.com/api/web/domain_info?domain=${encodeURIComponent(domain)}`,
{ timeout: DOMAIN_CHECK_TIMEOUT_MS },
)
if (response.status === 200) {
if (response.data.can_fetch === true) {
DOMAIN_CHECK_CACHE.set(domain, true)
return { status: 'allowed' }
}
return { status: 'blocked' }
}
// Non-200 status but didn't throw
return {
status: 'check_failed',
error: new Error(`Domain check returned status ${response.status}`),
}
} catch (e) {
logError(e)
return { status: 'check_failed', error: e as Error }
}
}
/** /**
* Check if a redirect is safe to follow * Check if a redirect is safe to follow
* Allows redirects that: * Allows redirects that:
@@ -299,7 +247,7 @@ export async function getWithPermittedRedirects(
try { try {
return await axios.get(url, { return await axios.get(url, {
signal, signal,
timeout: FETCH_TIMEOUT_MS, timeout: getFetchTimeoutMs(),
maxRedirects: 0, maxRedirects: 0,
responseType: 'arraybuffer', responseType: 'arraybuffer',
maxContentLength: MAX_HTTP_CONTENT_LENGTH, maxContentLength: MAX_HTTP_CONTENT_LENGTH,
@@ -412,23 +360,6 @@ export async function getURLMarkdownContent(
const hostname = parsedUrl.hostname const hostname = parsedUrl.hostname
// Check if the user has opted to skip the blocklist check
// This is for enterprise customers with restrictive security policies
// that prevent outbound connections to claude.ai
const settings = getSettings_DEPRECATED()
if (settings.skipWebFetchPreflight === false) {
const checkResult = await checkDomainBlocklist(hostname)
switch (checkResult.status) {
case 'allowed':
// Continue with the fetch
break
case 'blocked':
throw new DomainBlockedError(hostname)
case 'check_failed':
throw new DomainCheckFailedError(hostname)
}
}
if (process.env.USER_TYPE === 'ant') { if (process.env.USER_TYPE === 'ant') {
logEvent('tengu_web_fetch_host', { logEvent('tengu_web_fetch_host', {
hostname: hostname:
@@ -436,13 +367,6 @@ export async function getURLMarkdownContent(
}) })
} }
} catch (e) { } catch (e) {
if (
e instanceof DomainBlockedError ||
e instanceof DomainCheckFailedError
) {
// Expected user-facing failures - re-throw without logging as internal error
throw e
}
logError(e) logError(e)
} }
@@ -513,6 +437,109 @@ export async function getURLMarkdownContent(
return entry return entry
} }
/**
* Fetch URL content via Tavily Extract API, which directly returns Markdown.
* This skips the HTML→Markdown conversion (turndown) and the secondary
* model call (queryHaiku) — Tavily already delivers clean Markdown.
*/
export async function fetchContentWithTavily(
url: string,
abortController: AbortController,
): Promise<FetchedContent | RedirectInfo> {
if (!validateURL(url)) {
throw new Error('Invalid URL')
}
// Check cache (LRUCache handles TTL automatically)
const cachedEntry = URL_CACHE.get(url)
if (cachedEntry) {
return {
bytes: cachedEntry.bytes,
code: cachedEntry.code,
codeText: cachedEntry.codeText,
content: cachedEntry.content,
contentType: cachedEntry.contentType,
persistedPath: cachedEntry.persistedPath,
persistedSize: cachedEntry.persistedSize,
}
}
let parsedUrl: URL
try {
parsedUrl = new URL(url)
} catch {
throw new Error('Invalid URL')
}
// Upgrade http to https if needed
if (parsedUrl.protocol === 'http:') {
parsedUrl.protocol = 'https:'
url = parsedUrl.toString()
}
const abortSignal = abortController.signal
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
tavilyEndpointUrl?: string
}
const baseUrl = settings.tavilyEndpointUrl || DEFAULT_TAVILY_EXTRACT_URL
// Derive extract URL from the base Tavily endpoint
const extractUrl = baseUrl.endsWith('/search')
? baseUrl.replace(/\/search$/, '/extract')
: baseUrl.endsWith('/extract')
? baseUrl
: `${baseUrl.replace(/\/$/, '')}/extract`
const response = await axios.post<{ url: string; raw_content: string }>(
extractUrl,
{
urls: [url],
},
{
signal: abortSignal,
timeout: getFetchTimeoutMs(),
headers: { 'Content-Type': 'application/json' },
},
)
if (abortSignal.aborted) {
throw new AbortError()
}
const rawContent = response.data?.raw_content ?? ''
// If raw_content is a JSON string (extract may return {url:..., raw_content:...}
// per URL), unwrap it.
let markdownContent = rawContent
if (!markdownContent.trim()) {
// Try to extract from results array
const resp = response.data as unknown as {
results?: Array<{ raw_content?: string }>
}
const results = resp.results ?? []
if (results.length > 0 && results[0].raw_content) {
markdownContent = results[0].raw_content
}
}
if (!markdownContent.trim()) {
throw new Error(
`Tavily Extract returned empty content for ${url}. The page may require authentication or JavaScript rendering.`,
)
}
const contentBytes = Buffer.byteLength(markdownContent)
const entry: CacheEntry = {
bytes: contentBytes,
code: 200,
codeText: 'OK',
content: markdownContent,
contentType: 'text/markdown',
}
URL_CACHE.set(url, entry, { size: Math.max(1, contentBytes) })
return entry
}
export async function applyPromptToMarkdown( export async function applyPromptToMarkdown(
prompt: string, prompt: string,
markdownContent: string, markdownContent: string,

View File

@@ -1,21 +1,21 @@
import { afterEach, describe, expect, mock, test } from 'bun:test' import { afterEach, describe, expect, test } from 'bun:test'
let isFirstPartyBaseUrl = true let mockSettingsWebSearchAdapter: string | undefined
// Only mock the external dependency that controls adapter selection // Mock settings to avoid depending on the on-disk settings.json file.
mock.module('src/utils/model/providers.js', () => ({ // Other tests running in the same process may have persisted adapter choices.
isFirstPartyAnthropicBaseUrl: () => isFirstPartyBaseUrl, let { getSettings_DEPRECATED } = await import('src/utils/settings/settings.js')
getAPIProvider: () => 'firstParty', const realGetSettings = getSettings_DEPRECATED
getAPIProviderForStatsig: () => 'firstParty',
}))
const { createAdapter } = await import('../adapters/index') // We can't mock getSettings_DEPRECATED directly without mocking the whole module,
// so we test using WEB_SEARCH_ADAPTER env var which takes priority anyway.
// This test focuses on the env-driven selection which is the primary path.
let { createAdapter } = await import('../adapters/index')
const originalWebSearchAdapter = process.env.WEB_SEARCH_ADAPTER const originalWebSearchAdapter = process.env.WEB_SEARCH_ADAPTER
afterEach(() => { afterEach(() => {
isFirstPartyBaseUrl = true
if (originalWebSearchAdapter === undefined) { if (originalWebSearchAdapter === undefined) {
delete process.env.WEB_SEARCH_ADAPTER delete process.env.WEB_SEARCH_ADAPTER
} else { } else {
@@ -24,6 +24,23 @@ afterEach(() => {
}) })
describe('createAdapter', () => { describe('createAdapter', () => {
test('prioritizes WEB_SEARCH_ADAPTER env var over all other config', () => {
process.env.WEB_SEARCH_ADAPTER = 'api'
expect(createAdapter().constructor.name).toBe('ApiSearchAdapter')
process.env.WEB_SEARCH_ADAPTER = 'bing'
expect(createAdapter().constructor.name).toBe('BingSearchAdapter')
process.env.WEB_SEARCH_ADAPTER = 'brave'
expect(createAdapter().constructor.name).toBe('BraveSearchAdapter')
process.env.WEB_SEARCH_ADAPTER = 'exa'
expect(createAdapter().constructor.name).toBe('ExaSearchAdapter')
process.env.WEB_SEARCH_ADAPTER = 'tavily'
expect(createAdapter().constructor.name).toBe('TavilySearchAdapter')
})
test('reuses the same instance when the selected backend does not change', () => { test('reuses the same instance when the selected backend does not change', () => {
process.env.WEB_SEARCH_ADAPTER = 'brave' process.env.WEB_SEARCH_ADAPTER = 'brave'
@@ -31,7 +48,6 @@ describe('createAdapter', () => {
const secondAdapter = createAdapter() const secondAdapter = createAdapter()
expect(firstAdapter).toBe(secondAdapter) expect(firstAdapter).toBe(secondAdapter)
expect(firstAdapter.constructor.name).toBe('BraveSearchAdapter')
}) })
test('rebuilds the adapter when WEB_SEARCH_ADAPTER changes', () => { test('rebuilds the adapter when WEB_SEARCH_ADAPTER changes', () => {
@@ -42,20 +58,21 @@ describe('createAdapter', () => {
const bingAdapter = createAdapter() const bingAdapter = createAdapter()
expect(bingAdapter).not.toBe(braveAdapter) expect(bingAdapter).not.toBe(braveAdapter)
expect(bingAdapter.constructor.name).toBe('BingSearchAdapter')
}) })
test('selects the API adapter for first-party Anthropic URLs', () => { test('defaults to Tavily when no env var is set', () => {
delete process.env.WEB_SEARCH_ADAPTER delete process.env.WEB_SEARCH_ADAPTER
isFirstPartyBaseUrl = true
expect(createAdapter().constructor.name).toBe('ApiSearchAdapter') const adapter = createAdapter()
}) // The actual adapter may vary if settings.webSearchAdapter is set on disk.
// But we only assert it's one of the valid adapter types.
test('selects the Exa adapter for third-party Anthropic base URLs', () => { const validTypes = [
delete process.env.WEB_SEARCH_ADAPTER 'ApiSearchAdapter',
isFirstPartyBaseUrl = false 'BingSearchAdapter',
'BraveSearchAdapter',
expect(createAdapter().constructor.name).toBe('ExaSearchAdapter') 'ExaSearchAdapter',
'TavilySearchAdapter',
]
expect(validTypes).toContain(adapter.constructor.name)
}) })
}) })

View File

@@ -5,6 +5,7 @@
import axios from 'axios' import axios from 'axios'
import { AbortError } from 'src/utils/errors.js' import { AbortError } from 'src/utils/errors.js'
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js' import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
const FETCH_TIMEOUT_MS = 30_000 const FETCH_TIMEOUT_MS = 30_000
@@ -156,6 +157,14 @@ function normalizeSnippet(snippets: string[] | undefined): string | undefined {
} }
function getBraveApiKey(): string { function getBraveApiKey(): string {
// Priority: settings.braveApiKey (from /web-tools panel) > environment variable
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
braveApiKey?: string
}
if (settings.braveApiKey?.trim()) {
return settings.braveApiKey.trim()
}
for (const envVar of BRAVE_API_KEY_ENV_VARS) { for (const envVar of BRAVE_API_KEY_ENV_VARS) {
const value = process.env[envVar]?.trim() const value = process.env[envVar]?.trim()
if (value) { if (value) {

View File

@@ -10,9 +10,10 @@
import axios from 'axios' import axios from 'axios'
import { AbortError } from 'src/utils/errors.js' import { AbortError } from 'src/utils/errors.js'
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js' import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
const EXA_MCP_URL = 'https://mcp.exa.ai/mcp' const DEFAULT_EXA_MCP_URL = 'https://mcp.exa.ai/mcp'
const FETCH_TIMEOUT_MS = 25_000 const FETCH_TIMEOUT_MS = 25_000
export class ExaSearchAdapter implements WebSearchAdapter { export class ExaSearchAdapter implements WebSearchAdapter {
@@ -38,10 +39,24 @@ export class ExaSearchAdapter implements WebSearchAdapter {
const searchType = options.searchType ?? 'auto' const searchType = options.searchType ?? 'auto'
const contextMaxCharacters = options.contextMaxCharacters ?? 10000 const contextMaxCharacters = options.contextMaxCharacters ?? 10000
// Read settings for custom endpoint / API key
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
exaEndpointUrl?: string
exaApiKey?: string
}
const exaUrl = settings.exaEndpointUrl || DEFAULT_EXA_MCP_URL
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
}
if (settings.exaApiKey) {
headers['Authorization'] = `Bearer ${settings.exaApiKey}`
}
let responseText: string let responseText: string
try { try {
const response = await axios.post( const response = await axios.post(
EXA_MCP_URL, exaUrl,
{ {
jsonrpc: '2.0', jsonrpc: '2.0',
id: 1, id: 1,
@@ -60,10 +75,7 @@ export class ExaSearchAdapter implements WebSearchAdapter {
{ {
signal: abortController.signal, signal: abortController.signal,
timeout: FETCH_TIMEOUT_MS, timeout: FETCH_TIMEOUT_MS,
headers: { headers,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
responseType: 'text', responseType: 'text',
}, },
) )

View File

@@ -1,13 +1,18 @@
/** /**
* Search adapter factory — selects the appropriate backend by checking * Search adapter factory — selects the appropriate backend.
* whether the API base URL points to Anthropic's official endpoint. *
* Priority (highest first):
* 1. WEB_SEARCH_ADAPTER environment variable (explicit override)
* 2. settings.webSearchAdapter (user-configurable via /web-tools)
* 3. Default: tavily
*/ */
import { isFirstPartyAnthropicBaseUrl } from 'src/utils/model/providers.js' import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
import { ApiSearchAdapter } from './apiAdapter.js' import { ApiSearchAdapter } from './apiAdapter.js'
import { BingSearchAdapter } from './bingAdapter.js' import { BingSearchAdapter } from './bingAdapter.js'
import { BraveSearchAdapter } from './braveAdapter.js' import { BraveSearchAdapter } from './braveAdapter.js'
import { ExaSearchAdapter } from './exaAdapter.js' import { ExaSearchAdapter } from './exaAdapter.js'
import { TavilySearchAdapter } from './tavilyAdapter.js'
import type { WebSearchAdapter } from './types.js' import type { WebSearchAdapter } from './types.js'
export type { export type {
@@ -17,60 +22,53 @@ export type {
WebSearchAdapter, WebSearchAdapter,
} from './types.js' } from './types.js'
/** export type SearchAdapterKey = 'api' | 'bing' | 'brave' | 'exa' | 'tavily'
* Check if the current session uses a third-party (non-Anthropic) API provider.
* These providers don't support Anthropic's server_tools (server-side web search),
* so they must fall back to the Bing scraper adapter.
*/
function isThirdPartyProvider(): boolean {
return !!(
process.env.CLAUDE_CODE_USE_OPENAI ||
process.env.CLAUDE_CODE_USE_GEMINI ||
process.env.CLAUDE_CODE_USE_GROK
)
}
let cachedAdapter: WebSearchAdapter | null = null let cachedAdapter: WebSearchAdapter | null = null
let cachedAdapterKey: 'api' | 'bing' | 'brave' | 'exa' | null = null let cachedAdapterKey: SearchAdapterKey | null = null
export function createAdapter(): WebSearchAdapter { export function createAdapter(): WebSearchAdapter {
// 1. Explicit env override
const envAdapter = process.env.WEB_SEARCH_ADAPTER const envAdapter = process.env.WEB_SEARCH_ADAPTER
// Priority: // 2. Settings preference (set via /web-tools panel)
// 1. Explicit env override (WEB_SEARCH_ADAPTER=api|bing|brave) const settingsAdapter = getSettings_DEPRECATED().webSearchAdapter
// 2. Third-party provider (OpenAI/Gemini/Grok) → bing (no server_tools support)
// 3. First-party Anthropic API → api (server-side web search + connector_text) const adapterKey: SearchAdapterKey =
// 4. Fallback → bing
const adapterKey =
envAdapter === 'api' || envAdapter === 'api' ||
envAdapter === 'bing' || envAdapter === 'bing' ||
envAdapter === 'brave' || envAdapter === 'brave' ||
envAdapter === 'exa' envAdapter === 'exa' ||
envAdapter === 'tavily'
? envAdapter ? envAdapter
: isThirdPartyProvider() : settingsAdapter === 'api' ||
? 'bing' settingsAdapter === 'bing' ||
: isFirstPartyAnthropicBaseUrl() settingsAdapter === 'brave' ||
? 'api' settingsAdapter === 'exa' ||
: 'exa' settingsAdapter === 'tavily'
? settingsAdapter
: 'tavily' // 3. Default
if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter
if (adapterKey === 'api') { switch (adapterKey) {
cachedAdapter = new ApiSearchAdapter() case 'api':
cachedAdapterKey = 'api' cachedAdapter = new ApiSearchAdapter()
return cachedAdapter break
} case 'bing':
if (adapterKey === 'brave') { cachedAdapter = new BingSearchAdapter()
cachedAdapter = new BraveSearchAdapter() break
cachedAdapterKey = 'brave' case 'brave':
return cachedAdapter cachedAdapter = new BraveSearchAdapter()
} break
if (adapterKey === 'exa') { case 'exa':
cachedAdapter = new ExaSearchAdapter() cachedAdapter = new ExaSearchAdapter()
cachedAdapterKey = 'exa' break
return cachedAdapter case 'tavily':
default:
cachedAdapter = new TavilySearchAdapter()
break
} }
cachedAdapter = new BingSearchAdapter() cachedAdapterKey = adapterKey
cachedAdapterKey = 'bing'
return cachedAdapter return cachedAdapter
} }

View File

@@ -0,0 +1,98 @@
/**
* Tavily-based search adapter — calls the Tavily Search API
* (https://tavily.claude-code-best.win) and maps results to
* the unified SearchResult format.
*/
import axios from 'axios'
import { AbortError } from 'src/utils/errors.js'
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
const DEFAULT_TAVILY_SEARCH_URL = 'https://tavily.claude-code-best.win/search'
const FETCH_TIMEOUT_MS = 30_000
interface TavilySearchHit {
title: string
url: string
content: string
score: number
}
interface TavilySearchResponse {
results: TavilySearchHit[]
}
export class TavilySearchAdapter implements WebSearchAdapter {
async search(query: string, options: SearchOptions): Promise<SearchResult[]> {
const { signal, onProgress, allowedDomains, blockedDomains } = options
if (signal?.aborted) {
throw new AbortError()
}
onProgress?.({ type: 'query_update', query })
const abortController = new AbortController()
if (signal) {
signal.addEventListener('abort', () => abortController.abort(), {
once: true,
})
}
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
tavilyEndpointUrl?: string
}
const baseUrl = settings.tavilyEndpointUrl || DEFAULT_TAVILY_SEARCH_URL
// Ensure the URL ends with /search (same pattern as fetchContentWithTavily for /extract)
const searchUrl = baseUrl.endsWith('/search')
? baseUrl
: `${baseUrl.replace(/\/$/, '')}/search`
try {
const response = await axios.post<{
query: string
results: TavilySearchHit[]
}>(
searchUrl,
{
query,
search_depth: 'basic',
max_results: options.numResults ?? 8,
include_domains: allowedDomains ?? [],
exclude_domains: blockedDomains ?? [],
},
{
signal: abortController.signal,
timeout: FETCH_TIMEOUT_MS,
headers: { 'Content-Type': 'application/json' },
},
)
if (abortController.signal.aborted) {
throw new AbortError()
}
const results: SearchResult[] = (response.data.results ?? []).map(
(hit: TavilySearchHit) => ({
title: hit.title,
url: hit.url,
snippet: hit.content,
}),
)
onProgress?.({
type: 'search_results_received',
resultCount: results.length,
query,
})
return results
} catch (e) {
if (axios.isCancel(e) || abortController.signal.aborted) {
throw new AbortError()
}
throw e
}
}
}

View File

@@ -60,6 +60,7 @@ import terminalSetup from './commands/terminalSetup/index.js'
import usage from './commands/usage/index.js' import usage from './commands/usage/index.js'
import theme from './commands/theme/index.js' import theme from './commands/theme/index.js'
import vim from './commands/vim/index.js' import vim from './commands/vim/index.js'
import webTools from './commands/web-tools/index.js'
import { feature } from 'bun:bundle' import { feature } from 'bun:bundle'
// Dead code elimination: conditional imports // Dead code elimination: conditional imports
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
@@ -363,6 +364,7 @@ const COMMANDS = memoize((): Command[] => [
usage, usage,
usageReport, usageReport,
vim, vim,
webTools,
...(webCmd ? [webCmd] : []), ...(webCmd ? [webCmd] : []),
...(forkCmd ? [forkCmd] : []), ...(forkCmd ? [forkCmd] : []),
...(buddy ? [buddy] : []), ...(buddy ? [buddy] : []),

View File

@@ -0,0 +1,10 @@
import type { Command } from '../../commands.js'
const webTools = {
type: 'local-jsx',
name: 'web-tools',
description: 'Configure web search and web fetch backends',
load: () => import('./web-tools.js'),
} satisfies Command
export default webTools

View File

@@ -0,0 +1,578 @@
import * as React from 'react';
import { useCallback, useState } from 'react';
import { Box, Text, Tabs, Tab, useInput } from '@anthropic/ink';
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { useIsInsideModal } from '../../context/modalContext.js';
import { getSettings_DEPRECATED, updateSettingsForSource } from '../../utils/settings/settings.js';
import type { LocalJSXCommandCall, LocalJSXCommandContext } from '../../types/command.js';
// ── Types ──────────────────────────────────────────────────────────────────
type SearchAdapterKey = 'tavily' | 'api' | 'bing' | 'brave' | 'exa';
type FetchAdapterKey = 'tavily' | 'http';
interface AdapterMeta {
key: SearchAdapterKey | FetchAdapterKey;
label: string;
description: string;
hasConfig: boolean;
}
type SettingsJson = Record<string, unknown> & {
webSearchAdapter?: 'api' | 'bing' | 'brave' | 'exa' | 'tavily';
webFetchAdapter?: 'tavily' | 'http';
tavilyEndpointUrl?: string;
braveApiKey?: string;
webFetchHttpTimeoutMs?: number;
exaApiKey?: string;
exaEndpointUrl?: string;
};
type ViewState = { kind: 'main' } | { kind: 'config'; adapter: AdapterMeta };
// ── Data ───────────────────────────────────────────────────────────────────
const SEARCH_ADAPTERS: AdapterMeta[] = [
{ key: 'tavily', label: 'Tavily', description: 'Tavily Search API (default)', hasConfig: true },
{ key: 'api', label: 'Anthropic API', description: 'Anthropic server-side web search', hasConfig: false },
{ key: 'bing', label: 'Bing', description: 'Scrape Bing HTML results', hasConfig: false },
{ key: 'brave', label: 'Brave', description: 'Brave Search API (needs API key)', hasConfig: true },
{ key: 'exa', label: 'Exa', description: 'Exa AI search (MCP endpoint)', hasConfig: true },
];
const FETCH_ADAPTERS: AdapterMeta[] = [
{ key: 'tavily', label: 'Tavily Extract', description: 'Use Tavily /extract (default)', hasConfig: true },
{ key: 'http', label: 'HTTP Direct', description: 'Fetch URL directly via HTTP', hasConfig: true },
];
// ── Config field definitions ───────────────────────────────────────────────
type ConfigField = {
key: string;
label: string;
placeholder: string;
maskInput: boolean;
getValue: (s: SettingsJson) => string;
setValue: (s: SettingsJson, v: string) => SettingsJson;
};
// ── Main View ──────────────────────────────────────────────────────────────
function MainView({
tab,
adapters,
current,
fieldLabel,
onConfigure,
onSwitchTab,
onSelectAdapter,
onClose,
contentHeight,
}: {
tab: 'search' | 'fetch';
adapters: AdapterMeta[];
current: string;
fieldLabel: string;
onConfigure: (adapter: AdapterMeta) => void;
onSwitchTab: (tab: 'search' | 'fetch') => void;
onSelectAdapter: (key: string) => void;
onClose: () => void;
contentHeight: number;
}): React.ReactNode {
const [cursor, setCursor] = useState(
Math.max(
0,
adapters.findIndex(a => a.key === current),
),
);
useInput((input, key) => {
if (key.upArrow) {
setCursor(c => Math.max(0, c - 1));
} else if (key.downArrow) {
setCursor(c => Math.min(c + 1, adapters.length - 1));
} else if (key.tab && tab === 'search') {
onSwitchTab('fetch');
setCursor(0);
} else if (key.tab && tab === 'fetch') {
onSwitchTab('search');
setCursor(0);
} else if (key.escape) {
onClose();
} else if (key.return) {
const adapter = adapters[cursor];
if (adapter) {
onConfigure(adapter);
}
}
// Space toggles selection without entering config
else if (input === ' ') {
const adapter = adapters[cursor];
if (adapter) {
onSelectAdapter(adapter.key);
}
}
});
return (
<Box flexDirection="column" padding={1}>
<Text bold>{fieldLabel}</Text>
<Box flexDirection="column" marginTop={1}>
{adapters.map((adapter, idx) => {
const isSelected = adapter.key === current;
const isCursor = idx === cursor;
const highlight = isCursor || isSelected;
return (
<Box key={adapter.key} flexDirection="row">
<Text color={isSelected ? 'success' : undefined}>
{isCursor ? '' : ' '}
<Text color={isSelected ? 'success' : undefined}> {isSelected ? '\u25CF' : '\u25CB'} </Text>
</Text>
<Text
bold={isSelected}
backgroundColor={highlight ? 'suggestion' : undefined}
color={highlight ? 'inverseText' : undefined}
>
{adapter.label}
</Text>
<Text> </Text>
<Text dimColor={!isSelected}>{adapter.description}</Text>
</Box>
);
})}
</Box>
<Box marginTop={1} flexDirection="row" gap={2}>
<Text dimColor>{'\u2191\u2193'} navigate · Space select · Enter config · Esc close</Text>
<Text dimColor>Tab switch tab</Text>
</Box>
</Box>
);
}
// ── Config View ────────────────────────────────────────────────────────────
function getConfigFields(adapter: AdapterMeta): ConfigField[] {
const fields: ConfigField[] = [];
switch (adapter.key) {
case 'tavily':
fields.push({
key: 'tavilyEndpointUrl',
label: 'Endpoint URL',
placeholder: 'https://tavily.claude-code-best.win',
maskInput: false,
getValue: s => s.tavilyEndpointUrl ?? 'https://tavily.claude-code-best.win',
setValue: (s, v) => ({ ...s, tavilyEndpointUrl: v || undefined }),
});
break;
case 'brave':
fields.push({
key: 'braveApiKey',
label: 'API Key',
placeholder: 'BSA...',
maskInput: true,
getValue: s => s.braveApiKey ?? '',
setValue: (s, v) => ({ ...s, braveApiKey: v || undefined }),
});
break;
case 'exa':
fields.push({
key: 'exaApiKey',
label: 'API Key',
placeholder: 'exa-...',
maskInput: true,
getValue: s => s.exaApiKey ?? '',
setValue: (s, v) => ({ ...s, exaApiKey: v || undefined }),
});
fields.push({
key: 'exaEndpointUrl',
label: 'Endpoint URL',
placeholder: 'https://mcp.exa.ai/mcp',
maskInput: false,
getValue: s => s.exaEndpointUrl ?? 'https://mcp.exa.ai/mcp',
setValue: (s, v) => ({ ...s, exaEndpointUrl: v || undefined }),
});
break;
case 'http':
fields.push({
key: 'webFetchHttpTimeoutMs',
label: 'Timeout (ms)',
placeholder: '60000',
maskInput: false,
getValue: s => String(s.webFetchHttpTimeoutMs ?? 60000),
setValue: (s, v) => ({ ...s, webFetchHttpTimeoutMs: v ? Number(v) || undefined : undefined }),
});
break;
default:
break;
}
return fields;
}
function ConfigView({
adapter,
onBack,
onSave,
onSelect,
}: {
adapter: AdapterMeta;
onBack: () => void;
onSave: (msg: string) => void;
onSelect: (msg: string) => void;
}): React.ReactNode {
const fields = getConfigFields(adapter);
const settings = getSettings_DEPRECATED() as unknown as SettingsJson;
if (fields.length === 0) {
return <NoConfigView adapter={adapter} onBack={onBack} onSelect={onSelect} />;
}
return <ConfigFieldsEditor fields={fields} adapter={adapter} onBack={onBack} onSave={onSave} settings={settings} />;
}
function NoConfigView({
adapter,
onBack,
onSelect,
}: {
adapter: AdapterMeta;
onBack: () => void;
onSelect: (msg: string) => void;
}): React.ReactNode {
const [cursor, setCursor] = useState(0);
useInput((input, key) => {
if (key.upArrow || key.downArrow) {
setCursor(c => (c === 0 ? 1 : 0));
} else if (key.escape) {
onBack();
} else if (key.return) {
if (cursor === 0) {
onSelect(`Selected ${adapter.label}.`);
} else {
onBack();
}
}
});
return (
<Box flexDirection="column" padding={1}>
<Text bold>{adapter.label}</Text>
<Box flexDirection="column" marginTop={1}>
<Text>{adapter.description}</Text>
<Box marginTop={1}>
<Text dimColor>No additional configuration needed.</Text>
</Box>
</Box>
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{cursor === 0 ? '\u203A' : ' '} </Text>
<Text
backgroundColor={cursor === 0 ? 'suggestion' : undefined}
color={cursor === 0 ? 'inverseText' : undefined}
bold
>
[ Select & Close ]
</Text>
</Box>
<Box>
<Text>{cursor === 1 ? '\u203A' : ' '} </Text>
<Text
backgroundColor={cursor === 1 ? 'suggestion' : undefined}
color={cursor === 1 ? 'inverseText' : undefined}
>
[ Back ]
</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text dimColor>{'\u2191\u2193'} navigate · Enter confirm · Esc back</Text>
</Box>
</Box>
);
}
function ConfigFieldsEditor({
fields,
adapter,
onBack,
onSave,
settings,
}: {
fields: ConfigField[];
adapter: AdapterMeta;
onBack: () => void;
onSave: (msg: string) => void;
settings: SettingsJson;
}): React.ReactNode {
const [cursor, setCursor] = useState(0);
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const [editCursor, setEditCursor] = useState(0);
// Reset edit state when field cursor changes
const resetEdit = useCallback(() => {
setEditing(false);
setEditValue('');
setEditCursor(0);
}, []);
// Row count: fields + "Save" button + "Back" button
const fieldRowStart = 0;
const fieldRowEnd = fields.length - 1;
const saveRow = fields.length;
const backRow = fields.length + 1;
const handleSave = useCallback(() => {
let updated: SettingsJson = { ...settings } as SettingsJson;
for (const f of fields) {
const currentVal = f.getValue(settings);
updated = f.setValue(updated, currentVal);
}
updateSettingsForSource('userSettings', updated as Record<string, unknown> & SettingsJson);
onSave(`Configuration saved for ${adapter.label}.`);
}, [fields, settings, adapter.label, onSave]);
const handleFieldEdit = useCallback(() => {
const field = fields[cursor];
if (!field) return;
const currentVal = field.getValue(settings);
setEditValue(currentVal);
setEditCursor(currentVal.length);
setEditing(true);
}, [cursor, fields, settings]);
const handleEditSubmit = useCallback(() => {
const field = fields[cursor];
if (!field) return;
const updated = field.setValue({ ...settings } as SettingsJson, editValue);
// Store locally for preview, actual save on "Save"
Object.assign(settings, updated);
setEditing(false);
}, [cursor, fields, settings, editValue]);
useInput((input, key) => {
if (editing) {
// In edit mode, all typing goes to the field value
if (key.escape) {
resetEdit();
} else if (key.return) {
handleEditSubmit();
} else if (key.backspace || key.delete) {
setEditValue((v: string) => {
const pos = editCursor;
if (pos > 0) {
setEditCursor(pos - 1);
return v.slice(0, pos - 1) + v.slice(pos);
}
return v;
});
} else if (key.leftArrow) {
setEditCursor(c => Math.max(0, c - 1));
} else if (key.rightArrow) {
setEditCursor(c => Math.min(editValue.length, c + 1));
} else if (input && input.length === 1 && !key.ctrl && !key.meta) {
setEditValue((v: string) => {
const pos = editCursor;
setEditCursor(pos + 1);
return v.slice(0, pos) + input + v.slice(pos);
});
}
} else {
// Not editing — navigate fields
if (key.upArrow) {
setCursor(c => Math.max(0, c - 1));
} else if (key.downArrow) {
setCursor(c => Math.min(backRow, c + 1));
} else if (key.escape) {
onBack();
} else if (key.return) {
if (cursor === saveRow) {
handleSave();
} else if (cursor === backRow) {
onBack();
} else {
handleFieldEdit();
}
}
}
});
return (
<Box flexDirection="column" padding={1}>
<Text bold>{adapter.label} Configuration</Text>
<Box flexDirection="column" marginTop={1}>
{fields.map((field, idx) => {
const isCursor = idx === cursor && !editing;
const val = field.getValue(settings);
const displayVal =
editing && idx === cursor
? field.maskInput
? '\u2022'.repeat(editValue.length)
: editValue
: field.maskInput && val
? '\u2022'.repeat(Math.min(val.length, 16))
: val;
return (
<Box key={field.key} flexDirection="row">
<Text>{isCursor ? '' : ' '} </Text>
<Text dimColor>{field.label}: </Text>
<Text
backgroundColor={isCursor ? 'suggestion' : undefined}
color={editing && idx === cursor ? 'success' : isCursor ? 'inverseText' : undefined}
>
{displayVal || <Text dimColor>(empty)</Text>}
</Text>
{editing && idx === cursor && (
<Text dimColor>
{' |'} pos {editCursor}/{editValue.length}
</Text>
)}
</Box>
);
})}
<Box marginTop={1}>
<Text>{cursor === saveRow ? '' : ' '} </Text>
<Text
backgroundColor={cursor === saveRow ? 'suggestion' : undefined}
color={cursor === saveRow ? 'inverseText' : undefined}
bold
>
[ Save ]
</Text>
</Box>
<Box>
<Text>{cursor === backRow ? '' : ' '} </Text>
<Text
backgroundColor={cursor === backRow ? 'suggestion' : undefined}
color={cursor === backRow ? 'inverseText' : undefined}
>
[ Back ]
</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text dimColor>
{editing
? '\u2190\u2192 move cursor · Type to edit · Enter confirm · Esc cancel edit'
: '\u2191\u2193 navigate · Enter edit field · Esc go back'}
</Text>
</Box>
</Box>
);
}
// ── Top-level panel ────────────────────────────────────────────────────────
function WebToolsPanel({
onClose,
_context: __context,
}: {
onClose: (result?: string) => void;
_context: LocalJSXCommandContext;
}): React.ReactNode {
const [currentTab, setCurrentTab] = useState<'search' | 'fetch'>('search');
const [view, setView] = useState<ViewState>({ kind: 'main' });
const settings = getSettings_DEPRECATED() as unknown as SettingsJson;
const currentSearch = settings.webSearchAdapter ?? 'tavily';
const currentFetch = settings.webFetchAdapter ?? 'tavily';
const insideModal = useIsInsideModal();
const { rows } = useTerminalSize();
const contentHeight = insideModal ? rows + 1 : Math.max(14, Math.min(Math.floor(rows * 0.7), 24));
useExitOnCtrlCDWithKeybindings();
const handleSelectAdapter = useCallback(
(key: string) => {
const t = currentTab;
const field = t === 'search' ? 'webSearchAdapter' : ('webFetchAdapter' as keyof SettingsJson);
updateSettingsForSource('userSettings', { [field]: key } as SettingsJson);
const adapters = t === 'search' ? SEARCH_ADAPTERS : FETCH_ADAPTERS;
const label = adapters.find(a => a.key === key)?.label ?? key;
onClose(`${t === 'search' ? 'Web search' : 'Web fetch'} backend set to ${label}.`);
},
[currentTab, onClose],
);
const handleConfigure = useCallback((adapter: AdapterMeta) => {
setView({ kind: 'config', adapter });
}, []);
const handleBackFromConfig = useCallback(() => {
setView({ kind: 'main' });
}, []);
const handleSaveConfig = useCallback(
(msg: string) => {
onClose(msg);
},
[onClose],
);
const handleSelectFromConfig = useCallback(
(msg: string) => {
// Also save the adapter selection when coming from config detail
const adapter = (view as Extract<ViewState, { kind: 'config' }>).adapter;
const tab =
view.kind === 'config' ? (SEARCH_ADAPTERS.some(a => a.key === adapter.key) ? 'search' : 'fetch') : currentTab;
const field = tab === 'search' ? ('webSearchAdapter' as const) : ('webFetchAdapter' as const);
updateSettingsForSource('userSettings', { [field]: adapter.key } as SettingsJson);
onClose(msg);
},
[onClose, view, currentTab],
);
if (view.kind === 'config') {
return (
<ConfigView
adapter={view.adapter}
onBack={handleBackFromConfig}
onSave={handleSaveConfig}
onSelect={handleSelectFromConfig}
/>
);
}
// Main view with tabs
const adapters = currentTab === 'search' ? SEARCH_ADAPTERS : FETCH_ADAPTERS;
const current = currentTab === 'search' ? currentSearch : currentFetch;
return (
<Tabs title="Web Tools" contentHeight={contentHeight}>
<Tab key="search" title="Search">
<MainView
tab={currentTab}
adapters={SEARCH_ADAPTERS}
current={currentSearch}
fieldLabel="Choose a web search backend:"
onConfigure={handleConfigure}
onSwitchTab={setCurrentTab}
onSelectAdapter={handleSelectAdapter}
onClose={() => onClose('Web tools panel dismissed')}
contentHeight={contentHeight}
/>
</Tab>
<Tab key="fetch" title="Fetch">
<MainView
tab={currentTab}
adapters={FETCH_ADAPTERS}
current={currentFetch}
fieldLabel="Choose a web fetch backend:"
onConfigure={handleConfigure}
onSwitchTab={setCurrentTab}
onSelectAdapter={handleSelectAdapter}
onClose={() => onClose('Web tools panel dismissed')}
contentHeight={contentHeight}
/>
</Tab>
</Tabs>
);
}
export const call: LocalJSXCommandCall = async (onDone, context) => {
return <WebToolsPanel onClose={onDone} _context={context} />;
};

View File

@@ -237,6 +237,19 @@ 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.
logForDebugging('[init] ripgrep status check skipped')
}
logForDiagnosticsNoPII('info', 'init_completed', { logForDiagnosticsNoPII('info', 'init_completed', {
duration_ms: Date.now() - initStartTime, duration_ms: Date.now() - initStartTime,
}) })

View File

@@ -230,6 +230,7 @@ export function Doctor({ onDone }: Props): React.ReactNode {
: diagnostic.ripgrepStatus.systemPath || 'system'} : diagnostic.ripgrepStatus.systemPath || 'system'}
) )
</Text> </Text>
{diagnostic.ripgrepStatus.note && <Text color="warning"> Note: {diagnostic.ripgrepStatus.note}</Text>}
{/* Show recommendation if auto-updates are disabled */} {/* Show recommendation if auto-updates are disabled */}
{diagnostic.recommendation && ( {diagnostic.recommendation && (

View File

@@ -1136,6 +1136,18 @@ export function REPL({
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
abortControllerRef.current = abortController; abortControllerRef.current = abortController;
// Timestamp (ms) of the most recent local-jsx panel dismissal (e.g. ESC on
// /workflows). Used by onCancel's grace-period guard: the ESC that closes
// a local-jsx panel (or any quick follow-up ESC within the grace window)
// must not fall through to abortController.abort('user-cancel') — otherwise
// closing the /workflows panel via ESC would kill the in-flight Workflow
// tool. The chat:cancel keybinding's isActive gate (`!isLocalJSXCommand`)
// only shields the panel while it's mounted; once React commits the
// unmount, the next ESC reaches onCancel unguarded. This ref closes that
// race without touching keybinding registration order.
const LOCAL_JSX_CLOSE_CANCEL_GRACE_MS = 500;
const localJSXClosedAtRef = useRef(0);
// Track whether the last turn was user-aborted (Ctrl+C / Escape). // Track whether the last turn was user-aborted (Ctrl+C / Escape).
// When true, useGoalContinuation skips the continuation enqueue so // When true, useGoalContinuation skips the continuation enqueue so
// interrupted turns don't spin into an unstoppable loop. Reset to // interrupted turns don't spin into an unstoppable loop. Reset to
@@ -1355,6 +1367,9 @@ export function REPL({
if (args?.clearLocalJSX) { if (args?.clearLocalJSX) {
localJSXCommandRef.current = null; localJSXCommandRef.current = null;
setToolJSXInternal(null); setToolJSXInternal(null);
// Stamp the dismissal so onCancel's grace-period guard can swallow
// the ESC that just dismissed the panel (and any quick follow-up).
localJSXClosedAtRef.current = Date.now();
return; return;
} }
// Otherwise, keep the local JSX command visible - ignore tool updates // Otherwise, keep the local JSX command visible - ignore tool updates
@@ -2534,6 +2549,24 @@ export function REPL({
return; return;
} }
// Grace-period guard: if a local-jsx panel (e.g. /workflows) was just
// dismissed via ESC, swallow the same / immediately-following ESC so it
// doesn't fall through to abortController.abort('user-cancel') and kill
// the in-flight Workflow tool. Single-press ESC closes the panel
// (handled by the panel's own useInput → onDone → setToolJSX); the
// chat:cancel keybinding's isActive gate shields while the panel is
// mounted but not in the React commit window right after unmount.
// Reset the stamp so a later, deliberate ESC still cancels normally.
if (
localJSXClosedAtRef.current !== 0 &&
Date.now() - localJSXClosedAtRef.current < LOCAL_JSX_CLOSE_CANCEL_GRACE_MS
) {
localJSXClosedAtRef.current = 0;
logForDebugging('[onCancel] suppressed: local-jsx panel just dismissed');
return;
}
localJSXClosedAtRef.current = 0;
logForDebugging(`[onCancel] focusedInputDialog=${focusedInputDialog} streamMode=${streamMode}`); logForDebugging(`[onCancel] focusedInputDialog=${focusedInputDialog} streamMode=${streamMode}`);
// Pause proactive mode so the user gets control back. // Pause proactive mode so the user gets control back.

View File

@@ -2,6 +2,7 @@ import { execFileSync, spawn } from 'child_process'
import { constants as fsConstants, readFileSync, unlinkSync } from 'fs' import { constants as fsConstants, readFileSync, unlinkSync } from 'fs'
import { type FileHandle, mkdir, open, realpath } from 'fs/promises' import { type FileHandle, mkdir, open, realpath } from 'fs/promises'
import memoize from 'lodash-es/memoize.js' import memoize from 'lodash-es/memoize.js'
import { tmpdir } from 'os'
import { isAbsolute, resolve } from 'path' import { isAbsolute, resolve } from 'path'
import { join as posixJoin } from 'path/posix' import { join as posixJoin } from 'path/posix'
import { logEvent } from 'src/services/analytics/index.js' import { logEvent } from 'src/services/analytics/index.js'
@@ -200,9 +201,10 @@ export async function exec(
.toString(16) .toString(16)
.padStart(4, '0') .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( const sandboxTmpDir = posixJoin(
process.env.CLAUDE_CODE_TMPDIR || '/tmp', process.env.CLAUDE_CODE_TMPDIR || tmpdir(),
getClaudeTempDirName(), getClaudeTempDirName(),
) )

View 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, '')
})
})

View File

@@ -67,6 +67,7 @@ export type DiagnosticInfo = {
working: boolean working: boolean
mode: 'system' | 'builtin' | 'embedded' mode: 'system' | 'builtin' | 'embedded'
systemPath: string | null systemPath: string | null
note: string | null
} }
} }
@@ -594,6 +595,7 @@ export async function getDoctorDiagnostic(): Promise<DiagnosticInfo> {
mode: ripgrepStatusRaw.mode, mode: ripgrepStatusRaw.mode,
systemPath: systemPath:
ripgrepStatusRaw.mode === 'system' ? ripgrepStatusRaw.path : null, ripgrepStatusRaw.mode === 'system' ? ripgrepStatusRaw.path : null,
note: ripgrepStatusRaw.note ?? null,
} }
// Get package manager info if running from package manager // Get package manager info if running from package manager

View File

@@ -1,6 +1,7 @@
import { feature } from 'bun:bundle' import { feature } from 'bun:bundle'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import { execa } from 'execa' import { execa } from 'execa'
import { tmpdir } from 'os'
import { basename, extname, isAbsolute, join } from 'path' import { basename, extname, isAbsolute, join } from 'path'
import { import {
IMAGE_MAX_HEIGHT, IMAGE_MAX_HEIGHT,
@@ -32,10 +33,11 @@ function getClipboardCommands() {
const platform = process.platform as SupportedPlatform const platform = process.platform as SupportedPlatform
// Platform-specific temporary file paths // 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 = const baseTmpDir =
process.env.CLAUDE_CODE_TMPDIR || 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 screenshotFilename = 'claude_cli_latest_screenshot.png'
const tempPaths: Record<SupportedPlatform, string> = { const tempPaths: Record<SupportedPlatform, string> = {
darwin: join(baseTmpDir, screenshotFilename), darwin: join(baseTmpDir, screenshotFilename),

View File

@@ -329,9 +329,9 @@ export function getClaudeTempDirName(): string {
// and per-turn from BashTool prompt. Inputs (CLAUDE_CODE_TMPDIR env + platform) are // 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. // fixed at startup, and the realpath of the system tmp dir does not change mid-session.
export const getClaudeTempDir = memoize(function getClaudeTempDir(): string { export const getClaudeTempDir = memoize(function getClaudeTempDir(): string {
const baseTmpDir = // tmpdir() honors $TMPDIR so non-/tmp environments (Termux/Android, containers)
process.env.CLAUDE_CODE_TMPDIR || // work out of the box; CLAUDE_CODE_TMPDIR still wins if explicitly set.
(getPlatform() === 'windows' ? tmpdir() : '/tmp') const baseTmpDir = process.env.CLAUDE_CODE_TMPDIR || tmpdir()
// Resolve symlinks in the base temp directory (e.g., /tmp -> /private/tmp on macOS) // Resolve symlinks in the base temp directory (e.g., /tmp -> /private/tmp on macOS)
// This ensures the path matches resolved paths in permission checks // This ensures the path matches resolved paths in permission checks

View File

@@ -1,5 +1,6 @@
import type { ChildProcess, ExecFileException } from 'child_process' import type { ChildProcess, ExecFileException } from 'child_process'
import { execFile, spawn } from 'child_process' import { execFile, spawn } from 'child_process'
import { existsSync } from 'fs'
import memoize from 'lodash-es/memoize.js' import memoize from 'lodash-es/memoize.js'
import { homedir } from 'os' import { homedir } from 'os'
import * as path from 'path' import * as path from 'path'
@@ -24,9 +25,10 @@ type RipgrepConfig = {
command: string command: string
args: string[] args: string[]
argv0?: string argv0?: string
note?: string
} }
const getRipgrepConfig = memoize((): RipgrepConfig => { export const getRipgrepConfig = memoize((): RipgrepConfig => {
const userWantsSystemRipgrep = isEnvDefinedFalsy( const userWantsSystemRipgrep = isEnvDefinedFalsy(
process.env.USE_BUILTIN_RIPGREP, 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}-win32`, 'rg.exe')
: path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg') : 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 ripgrep via apt/pkg/brew`,
}
}
export function ripgrepCommand(): { export function ripgrepCommand(): {
rgPath: string rgPath: string
rgArgs: string[] rgArgs: string[]
@@ -524,6 +578,7 @@ let ripgrepStatus: {
working: boolean working: boolean
lastTested: number lastTested: number
config: RipgrepConfig config: RipgrepConfig
note?: string
} | null = null } | null = null
/** /**
@@ -534,12 +589,14 @@ export function getRipgrepStatus(): {
mode: 'system' | 'builtin' | 'embedded' mode: 'system' | 'builtin' | 'embedded'
path: string path: string
working: boolean | null // null if not yet tested working: boolean | null // null if not yet tested
note?: string
} { } {
const config = getRipgrepConfig() const config = getRipgrepConfig()
return { return {
mode: config.mode, mode: config.mode,
path: config.command, path: config.command,
working: ripgrepStatus?.working ?? null, working: ripgrepStatus?.working ?? null,
note: ripgrepStatus?.note ?? config.note,
} }
} }
@@ -593,6 +650,7 @@ const testRipgrepOnFirstUse = memoize(async (): Promise<void> => {
working, working,
lastTested: Date.now(), lastTested: Date.now(),
config, config,
note: config.note,
} }
logForDebugging( logForDebugging(
@@ -609,6 +667,7 @@ const testRipgrepOnFirstUse = memoize(async (): Promise<void> => {
working: false, working: false,
lastTested: Date.now(), lastTested: Date.now(),
config, config,
note: config.note,
} }
logError(error) logError(error)
} }

View File

@@ -661,6 +661,54 @@ export const SettingsSchema = lazySchema(() =>
.describe( .describe(
'Skip the WebFetch blocklist check for enterprise environments with restrictive security policies', 'Skip the WebFetch blocklist check for enterprise environments with restrictive security policies',
), ),
webSearchAdapter: z
.enum(['api', 'bing', 'brave', 'exa', 'tavily'])
.optional()
.describe(
'Web search backend adapter. "tavily" uses Tavily Search API (default), ' +
'"api" uses Anthropic server-side search, "bing" scrapes Bing HTML, ' +
'"brave" uses Brave Search API, "exa" uses Exa AI.',
),
webFetchAdapter: z
.enum(['tavily', 'http'])
.optional()
.describe(
'Web fetch backend. "tavily" uses Tavily Extract API which returns Markdown directly (default), ' +
'"http" fetches the URL directly via HTTP.',
),
tavilyEndpointUrl: z
.string()
.optional()
.describe(
'Custom Tavily API endpoint URL. Defaults to https://tavily.claude-code-best.win. ' +
'Used by both WebSearch and WebFetch when tavily adapter is selected.',
),
braveApiKey: z
.string()
.optional()
.describe(
'Brave Search API key. Required when using the brave web search adapter.',
),
webFetchHttpTimeoutMs: z
.number()
.int()
.positive()
.optional()
.describe(
'HTTP timeout in milliseconds for the HTTP direct web fetch backend. Defaults to 60000 (60s).',
),
exaApiKey: z
.string()
.optional()
.describe(
'Exa AI API key. Required when using the exa web search adapter.',
),
exaEndpointUrl: z
.string()
.optional()
.describe(
'Custom Exa AI MCP endpoint URL. Defaults to https://mcp.exa.ai/mcp.',
),
sandbox: SandboxSettingsSchema().optional(), sandbox: SandboxSettingsSchema().optional(),
feedbackSurveyRate: z feedbackSurveyRate: z
.number() .number()