Compare commits

..

2 Commits

Author SHA1 Message Date
claude-code-best
9d2d511b53 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>
2026-06-15 16:51:33 +08:00
claude-code-best
9d6a98dd06 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>
2026-06-15 16:51:33 +08:00
14 changed files with 163 additions and 1079 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-code-best", "name": "claude-code-best",
"version": "2.7.1", "version": "2.7.0",
"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,7 +5,6 @@ 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 {
@@ -17,7 +16,6 @@ import {
import { import {
applyPromptToMarkdown, applyPromptToMarkdown,
type FetchedContent, type FetchedContent,
fetchContentWithTavily,
getURLMarkdownContent, getURLMarkdownContent,
isPreapprovedUrl, isPreapprovedUrl,
MAX_MARKDOWN_LENGTH, MAX_MARKDOWN_LENGTH,
@@ -213,72 +211,6 @@ ${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,9 +17,23 @@ 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'
const DEFAULT_TAVILY_EXTRACT_URL = 'https://tavily.claude-code-best.win/extract' // Custom error classes for domain blocking
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(
@@ -54,8 +68,18 @@ 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 {
@@ -117,19 +141,13 @@ 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.
// Overridable via settings.webFetchHttpTimeoutMs (set in /web-tools panel). const FETCH_TIMEOUT_MS = 60_000
const DEFAULT_FETCH_TIMEOUT_MS = 60_000
function getFetchTimeoutMs(): number { // Timeout for the domain blocklist preflight check (10 seconds).
const settings = getSettings_DEPRECATED() as Record<string, unknown> & { const DOMAIN_CHECK_TIMEOUT_MS = 10_000
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 timeout // a redirect loop (/a → /b → /a …) and the per-request FETCH_TIMEOUT_MS
// (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
@@ -178,6 +196,40 @@ 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:
@@ -247,7 +299,7 @@ export async function getWithPermittedRedirects(
try { try {
return await axios.get(url, { return await axios.get(url, {
signal, signal,
timeout: getFetchTimeoutMs(), timeout: FETCH_TIMEOUT_MS,
maxRedirects: 0, maxRedirects: 0,
responseType: 'arraybuffer', responseType: 'arraybuffer',
maxContentLength: MAX_HTTP_CONTENT_LENGTH, maxContentLength: MAX_HTTP_CONTENT_LENGTH,
@@ -360,6 +412,23 @@ 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:
@@ -367,6 +436,13 @@ 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)
} }
@@ -437,109 +513,6 @@ 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, test } from 'bun:test' import { afterEach, describe, expect, mock, test } from 'bun:test'
let mockSettingsWebSearchAdapter: string | undefined let isFirstPartyBaseUrl = true
// Mock settings to avoid depending on the on-disk settings.json file. // Only mock the external dependency that controls adapter selection
// Other tests running in the same process may have persisted adapter choices. mock.module('src/utils/model/providers.js', () => ({
let { getSettings_DEPRECATED } = await import('src/utils/settings/settings.js') isFirstPartyAnthropicBaseUrl: () => isFirstPartyBaseUrl,
const realGetSettings = getSettings_DEPRECATED getAPIProvider: () => 'firstParty',
getAPIProviderForStatsig: () => 'firstParty',
}))
// We can't mock getSettings_DEPRECATED directly without mocking the whole module, const { createAdapter } = await import('../adapters/index')
// 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,23 +24,6 @@ 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'
@@ -48,6 +31,7 @@ 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', () => {
@@ -58,21 +42,20 @@ 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('defaults to Tavily when no env var is set', () => { test('selects the API adapter for first-party Anthropic URLs', () => {
delete process.env.WEB_SEARCH_ADAPTER delete process.env.WEB_SEARCH_ADAPTER
isFirstPartyBaseUrl = true
const adapter = createAdapter() expect(createAdapter().constructor.name).toBe('ApiSearchAdapter')
// The actual adapter may vary if settings.webSearchAdapter is set on disk. })
// But we only assert it's one of the valid adapter types.
const validTypes = [ test('selects the Exa adapter for third-party Anthropic base URLs', () => {
'ApiSearchAdapter', delete process.env.WEB_SEARCH_ADAPTER
'BingSearchAdapter', isFirstPartyBaseUrl = false
'BraveSearchAdapter',
'ExaSearchAdapter', expect(createAdapter().constructor.name).toBe('ExaSearchAdapter')
'TavilySearchAdapter',
]
expect(validTypes).toContain(adapter.constructor.name)
}) })
}) })

View File

@@ -5,7 +5,6 @@
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
@@ -157,14 +156,6 @@ 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,10 +10,9 @@
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 DEFAULT_EXA_MCP_URL = 'https://mcp.exa.ai/mcp' const 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 {
@@ -39,24 +38,10 @@ 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(
exaUrl, EXA_MCP_URL,
{ {
jsonrpc: '2.0', jsonrpc: '2.0',
id: 1, id: 1,
@@ -75,7 +60,10 @@ 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,18 +1,13 @@
/** /**
* Search adapter factory — selects the appropriate backend. * Search adapter factory — selects the appropriate backend by checking
* * 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 { getSettings_DEPRECATED } from 'src/utils/settings/settings.js' import { isFirstPartyAnthropicBaseUrl } from 'src/utils/model/providers.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 {
@@ -22,53 +17,60 @@ 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: SearchAdapterKey | null = null let cachedAdapterKey: 'api' | 'bing' | 'brave' | 'exa' | 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
// 2. Settings preference (set via /web-tools panel) // Priority:
const settingsAdapter = getSettings_DEPRECATED().webSearchAdapter // 1. Explicit env override (WEB_SEARCH_ADAPTER=api|bing|brave)
// 2. Third-party provider (OpenAI/Gemini/Grok) → bing (no server_tools support)
const adapterKey: SearchAdapterKey = // 3. First-party Anthropic API → api (server-side web search + connector_text)
// 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
: settingsAdapter === 'api' || : isThirdPartyProvider()
settingsAdapter === 'bing' || ? 'bing'
settingsAdapter === 'brave' || : isFirstPartyAnthropicBaseUrl()
settingsAdapter === 'exa' || ? 'api'
settingsAdapter === 'tavily' : 'exa'
? settingsAdapter
: 'tavily' // 3. Default
if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter
switch (adapterKey) { if (adapterKey === 'api') {
case 'api': cachedAdapter = new ApiSearchAdapter()
cachedAdapter = new ApiSearchAdapter() cachedAdapterKey = 'api'
break return cachedAdapter
case 'bing': }
cachedAdapter = new BingSearchAdapter() if (adapterKey === 'brave') {
break cachedAdapter = new BraveSearchAdapter()
case 'brave': cachedAdapterKey = 'brave'
cachedAdapter = new BraveSearchAdapter() return cachedAdapter
break }
case 'exa': if (adapterKey === 'exa') {
cachedAdapter = new ExaSearchAdapter() cachedAdapter = new ExaSearchAdapter()
break cachedAdapterKey = 'exa'
case 'tavily': return cachedAdapter
default:
cachedAdapter = new TavilySearchAdapter()
break
} }
cachedAdapterKey = adapterKey cachedAdapter = new BingSearchAdapter()
cachedAdapterKey = 'bing'
return cachedAdapter return cachedAdapter
} }

View File

@@ -1,98 +0,0 @@
/**
* 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,7 +60,6 @@ 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 */
@@ -364,7 +363,6 @@ 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

@@ -1,10 +0,0 @@
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

@@ -1,578 +0,0 @@
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

@@ -11,11 +11,9 @@ import { getSSLErrorHint } from '@ant/model-provider';
import { sendNotification } from '../services/notifier.js'; import { sendNotification } from '../services/notifier.js';
import { import {
completeChatGPTDeviceLogin, completeChatGPTDeviceLogin,
removeChatGPTAuth,
requestChatGPTDeviceCode, requestChatGPTDeviceCode,
type ChatGPTDeviceCode, type ChatGPTDeviceCode,
} from '../services/api/openai/chatgptAuth.js'; } from '../services/api/openai/chatgptAuth.js';
import { clearOpenAIClientCache } from '../services/api/openai/client.js';
import { OAuthService } from '../services/oauth/index.js'; import { OAuthService } from '../services/oauth/index.js';
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'; import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js';
import { openBrowser } from '../utils/browser.js'; import { openBrowser } from '../utils/browser.js';
@@ -911,11 +909,6 @@ function OAuthStatusMessage({
process.env[k] = v; process.env[k] = v;
} }
} }
// Drop any cached OpenAI client so the next request rebuilds it
// with the new env vars. Also clear ChatGPT auth file so a prior
// ChatGPT Subscription login can't leak into the OpenAI Compatible path.
clearOpenAIClientCache();
void removeChatGPTAuth().catch(() => {});
setOAuthStatus({ state: 'success' }); setOAuthStatus({ state: 'success' });
void onDone(); void onDone();
} }
@@ -1050,11 +1043,6 @@ function OAuthStatusMessage({
throw new Error('Failed to save settings. Please try again.'); throw new Error('Failed to save settings. Please try again.');
} }
for (const [k, v] of Object.entries(env)) process.env[k] = v; for (const [k, v] of Object.entries(env)) process.env[k] = v;
// Drop any cached OpenAI client built from prior OpenAI Compatible
// env vars; the ChatGPT Subscription path bypasses the SDK client
// entirely (uses createChatGPTResponsesStream) but a stale cached
// client would still be picked up by sideQuery.
clearOpenAIClientCache();
setOAuthStatus({ state: 'success' }); setOAuthStatus({ state: 'success' });
void onDone(); void onDone();
} catch (err) { } catch (err) {
@@ -1480,10 +1468,6 @@ function OAuthStatusMessage({
process.env[k] = v; process.env[k] = v;
} }
} }
// Drop any cached OpenAI client and ChatGPT auth so the new
// provider/credentials take effect on the next request.
clearOpenAIClientCache();
void removeChatGPTAuth().catch(() => {});
logEvent('tengu_china_login_success', {}); logEvent('tengu_china_login_success', {});
setOAuthStatus({ state: 'success' }); setOAuthStatus({ state: 'success' });
void onDone(); void onDone();

View File

@@ -1136,18 +1136,6 @@ 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
@@ -1367,9 +1355,6 @@ 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
@@ -2549,24 +2534,6 @@ 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

@@ -661,54 +661,6 @@ 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()