mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 05:45:51 +00:00
Compare commits
6 Commits
fix/ripgre
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b83395cdfe | ||
|
|
ddf1acdaed | ||
|
|
6c633744f4 | ||
|
|
bb100b16b3 | ||
|
|
0eabcccce9 | ||
|
|
9d845d77b9 |
@@ -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>",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] : []),
|
||||||
|
|||||||
10
src/commands/web-tools/index.ts
Normal file
10
src/commands/web-tools/index.ts
Normal 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
|
||||||
578
src/commands/web-tools/web-tools.tsx
Normal file
578
src/commands/web-tools/web-tools.tsx
Normal 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} />;
|
||||||
|
};
|
||||||
@@ -11,9 +11,11 @@ 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';
|
||||||
@@ -909,6 +911,11 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -1043,6 +1050,11 @@ 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) {
|
||||||
@@ -1468,6 +1480,10 @@ 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();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user