From fd99ebca983970b5fa1d5d366b0af5d136f9433e Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 16 Dec 2025 02:49:07 -0500 Subject: [PATCH] feat(websearch): add MCP fallback and Gemini CLI hook for third-party profiles - Add Gemini CLI transformer hook (websearch-gemini-transformer.cjs) - Implement MCP auto-configuration with web-search-prime, brave, tavily fallback - Add installWebSearchHook() to activate hook on profile launch - Update settings.tsx to clarify web-search-prime requires z.ai subscription - Add WebSearch config types and loader support - Create implementation plan for WebSearch UX enhancement (startup status, dashboard) Third-party profiles (gemini, agy, codex, qwen, glm, kimi) now have WebSearch capability via Gemini CLI primary + MCP fallback chain. --- docs/websearch.md | 75 ++++- lib/hooks/websearch-gemini-transformer.cjs | 324 +++++++++++++++++++++ src/ccs.ts | 3 +- src/cliproxy/cliproxy-executor.ts | 6 +- src/config/unified-config-loader.ts | 8 + src/config/unified-config-types.ts | 15 + src/utils/mcp-manager.ts | 233 +++++++++++++-- ui/src/pages/settings.tsx | 6 +- 8 files changed, 635 insertions(+), 35 deletions(-) create mode 100644 lib/hooks/websearch-gemini-transformer.cjs diff --git a/docs/websearch.md b/docs/websearch.md index c1a02392..623d1b2c 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -15,7 +15,10 @@ Third-party profiles (OAuth and API-based) cannot use Anthropic's WebSearch beca - CLIProxyAPI only receives conversation messages - Tool execution never reaches the third-party backend -CCS solves this by automatically configuring MCP (Model Context Protocol) web search servers. +CCS solves this with a hybrid fallback approach: + +1. **Gemini CLI Transformer** (Primary) - Uses `gemini -p` with `google_web_search` tool +2. **MCP Fallback Chain** (Secondary) - MCP-based web search servers ## Architecture @@ -28,19 +31,54 @@ CCS solves this by automatically configuring MCP (Model Context Protocol) web se │ ├── Native Claude Account? → Anthropic WebSearch API │ │ │ ($10/1000 searches) │ │ │ │ -│ └── Third-party Profile? → MCP Fallback Chain │ +│ └── Third-party Profile? → PreToolUse Hook │ │ │ │ -│ ├── 1. web-search-prime │ -│ ├── 2. Brave Search (free) │ -│ └── 3. Tavily (paid) │ +│ ├── 1. Gemini CLI │ +│ │ (google_web_search) │ +│ │ No API key needed! │ +│ │ │ +│ └── 2. MCP Fallback Chain │ +│ ├── web-search-prime │ +│ ├── Brave Search │ +│ └── Tavily │ └──────────────────────────────────────────────────────────────┘ ``` +## Gemini CLI Integration (Primary) + +The **ultimate solution** for third-party WebSearch. Uses `gemini` CLI with OAuth authentication - **no API key needed!** + +### How It Works + +1. A PreToolUse hook intercepts WebSearch tool calls +2. Executes `gemini -p` with explicit google_web_search instruction +3. Returns search results directly to Claude via the hook's deny reason +4. Claude receives full search results and continues the conversation + +### Requirements + +- `gemini` CLI installed and authenticated (`gemini auth login`) +- OAuth authentication (no GEMINI_API_KEY needed) + +### Installation + +The Gemini CLI is typically installed via: +```bash +pip install google-generativeai +# or +pipx install google-generativeai +``` + +Then authenticate: +```bash +gemini auth login +``` + ## MCP Providers | Provider | Type | Cost | API Key Required | Notes | |----------|------|------|------------------|-------| -| web-search-prime | HTTP MCP | Free | No | Primary, always available | +| web-search-prime | HTTP MCP | z.ai subscription | No | Requires z.ai coding plan | | Brave Search | stdio MCP | Free tier | `BRAVE_API_KEY` | 15k queries/month | | Tavily | stdio MCP | Paid | `TAVILY_API_KEY` | AI-optimized search | @@ -65,12 +103,28 @@ websearch: provider: auto # auto | web-search-prime | brave | tavily fallback: true # Enable fallback chain (default: true) webSearchPrimeUrl: "https://..." # Optional: custom endpoint + + # Gemini CLI configuration (new!) + gemini: + enabled: true # Use Gemini CLI for WebSearch (default: true) + timeout: 55 # Timeout in seconds (default: 55) ``` +### Environment Variables + +The WebSearch hook also respects these environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `CCS_WEBSEARCH_SKIP` | Skip WebSearch hook entirely | `0` | +| `CCS_GEMINI_SKIP` | Skip Gemini CLI, use MCP only | `0` | +| `CCS_GEMINI_TIMEOUT` | Gemini CLI timeout (seconds) | `55` | +| `CCS_DEBUG` | Enable debug output | `0` | + ### Provider Options - **auto** (default): Uses web-search-prime, adds Brave/Tavily if API keys available -- **web-search-prime**: Free, no API key needed +- **web-search-prime**: Requires z.ai coding plan subscription - **brave**: Requires `BRAVE_API_KEY` env var - **tavily**: Requires `TAVILY_API_KEY` env var @@ -120,6 +174,13 @@ CCS writes MCP configuration to `~/.claude/.mcp.json`. Example: ## Troubleshooting +### Gemini CLI Issues + +1. **Not installed**: Install with `pip install google-generativeai` +2. **Not authenticated**: Run `gemini auth login` +3. **Timeout**: Increase timeout in config or via `CCS_GEMINI_TIMEOUT=90` +4. **Skip Gemini**: Set `CCS_GEMINI_SKIP=1` to use MCP fallback only + ### WebSearch Not Working 1. **Check config**: Ensure `websearch.enabled: true` in config diff --git a/lib/hooks/websearch-gemini-transformer.cjs b/lib/hooks/websearch-gemini-transformer.cjs new file mode 100644 index 00000000..06fd43e3 --- /dev/null +++ b/lib/hooks/websearch-gemini-transformer.cjs @@ -0,0 +1,324 @@ +#!/usr/bin/env node +/** + * CCS WebSearch Gemini Transformer Hook + * + * Intercepts Claude's WebSearch tool and executes search via Gemini CLI. + * This is the ultimate solution for third-party providers that lack + * native WebSearch (gemini, agy, codex, qwen profiles). + * + * Strategy: Execute-and-Deny Pattern + * 1. Intercept WebSearch PreToolUse event + * 2. Execute `gemini -p` with google_web_search tool + * 3. Return deny with search results in permissionDecisionReason + * 4. Claude receives results as feedback, continues conversation + * + * If Gemini CLI fails, falls back to MCP redirect. + * + * Usage: + * Configured in ~/.claude/settings.json: + * { + * "hooks": { + * "PreToolUse": [{ + * "matcher": "WebSearch", + * "hooks": [{ + * "type": "command", + * "command": "node ~/.ccs/hooks/websearch-gemini-transformer.cjs", + * "timeout": 60 + * }] + * }] + * } + * } + * + * Environment variables: + * CCS_DEBUG=1 - Enable debug output + * CCS_WEBSEARCH_SKIP=1 - Skip this hook entirely (allow WebSearch) + * CCS_GEMINI_SKIP=1 - Skip Gemini CLI, use MCP fallback only + * CCS_GEMINI_TIMEOUT=55 - Gemini CLI timeout in seconds (default: 55) + * + * Exit codes: + * 0 - Allow tool (pass-through) or deny with results + * 2 - Block tool (deny with message) + * + * @module hooks/websearch-gemini-transformer + */ + +const { spawnSync } = require('child_process'); + +// Minimum response length to consider valid (prevent false positives from error messages) +const MIN_VALID_RESPONSE_LENGTH = 20; + +// Read input from stdin +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; +}); +process.stdin.on('end', () => { + processHook(); +}); + +// Handle stdin not being available +process.stdin.on('error', () => { + process.exit(0); +}); + +/** + * Main hook processing logic + */ +function processHook() { + try { + // Skip if disabled + if (process.env.CCS_WEBSEARCH_SKIP === '1') { + process.exit(0); + } + + const data = JSON.parse(input); + + // Only handle WebSearch tool + if (data.tool_name !== 'WebSearch') { + process.exit(0); + } + + const query = data.tool_input?.query || ''; + + if (!query) { + // No query provided - allow native handling (will fail anyway) + process.exit(0); + } + + // Check if Gemini CLI is specifically disabled - skip straight to MCP + if (process.env.CCS_GEMINI_SKIP === '1') { + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Gemini CLI disabled, using MCP fallback'); + } + outputMcpFallback(query, 'Gemini CLI disabled by configuration'); + return; + } + + // Try Gemini CLI first + const geminiResult = tryGeminiSearch(query); + + if (geminiResult.success) { + // Success! Return results via deny with reason + const output = { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: formatGeminiResults(query, geminiResult.content), + }, + }; + + console.log(JSON.stringify(output)); + process.exit(0); + } + + // Gemini failed - fall back to MCP redirect + if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] Gemini failed: ${geminiResult.error}, falling back to MCP`); + } + + outputMcpFallback(query, geminiResult.error); + } catch (err) { + // Don't block on parse errors - allow tool to proceed + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Parse error:', err.message); + } + process.exit(0); + } +} + +/** + * Try to execute search via Gemini CLI + * + * @param {string} query - Search query + * @returns {{ success: boolean, content?: string, error?: string }} + */ +function tryGeminiSearch(query) { + try { + // Get timeout from env or default to 55 seconds (under 60s hook timeout) + const timeoutSec = parseInt(process.env.CCS_GEMINI_TIMEOUT || '55', 10); + const timeoutMs = timeoutSec * 1000; + + // Build prompt for Gemini with explicit instruction to use web search + const prompt = buildGeminiPrompt(query); + + if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] Executing: gemini -p "${prompt.substring(0, 100)}..."`); + } + + // Execute gemini CLI using spawnSync to avoid shell injection + // Note: gemini CLI uses OAuth auth, no API key needed + const spawnResult = spawnSync('gemini', ['-p', prompt], { + encoding: 'utf8', + timeout: timeoutMs, + maxBuffer: 1024 * 1024 * 2, // 2MB buffer for large responses + stdio: ['pipe', 'pipe', 'pipe'], + }); + + // Handle spawn errors + if (spawnResult.error) { + throw spawnResult.error; + } + + // Check for non-zero exit code + if (spawnResult.status !== 0) { + return { + success: false, + error: `Gemini CLI exited with code ${spawnResult.status}: ${(spawnResult.stderr || '').substring(0, 200)}`, + }; + } + + const result = spawnResult.stdout || ''; + const trimmedResult = result.trim(); + + // Check if result looks like an error or empty + if (!trimmedResult || trimmedResult.length < MIN_VALID_RESPONSE_LENGTH) { + return { + success: false, + error: 'Empty or too short response from Gemini', + }; + } + + // Check for common error patterns + const lowerResult = trimmedResult.toLowerCase(); + if ( + lowerResult.includes('error:') || + lowerResult.includes('failed to') || + lowerResult.includes('unable to') || + lowerResult.includes('authentication required') + ) { + return { + success: false, + error: `Gemini returned error: ${trimmedResult.substring(0, 100)}`, + }; + } + + return { + success: true, + content: trimmedResult, + }; + } catch (err) { + // Handle different error types + if (err.killed) { + return { + success: false, + error: 'Gemini CLI timed out', + }; + } + + if (err.code === 'ENOENT') { + return { + success: false, + error: 'Gemini CLI not found (not installed or not in PATH)', + }; + } + + // Check stderr for more details + if (err.stderr) { + return { + success: false, + error: `Gemini CLI error: ${err.stderr.substring(0, 200)}`, + }; + } + + return { + success: false, + error: err.message || 'Unknown error executing Gemini CLI', + }; + } +} + +/** + * Build the prompt for Gemini CLI + * Instructs Gemini to use google_web_search tool for the query + * + * @param {string} query - Original search query + * @returns {string} Formatted prompt + */ +function buildGeminiPrompt(query) { + return [ + `Search the web for: ${query}`, + '', + 'Instructions:', + '1. Use the google_web_search tool to find current information', + '2. Provide a comprehensive summary of the search results', + '3. Include relevant URLs/sources when available', + '4. Be concise but thorough', + '5. Focus on factual, up-to-date information', + ].join('\n'); +} + +/** + * Format Gemini search results for Claude + * + * @param {string} query - Original query + * @param {string} content - Gemini response content + * @returns {string} Formatted message for Claude + */ +function formatGeminiResults(query, content) { + return [ + '[WebSearch Results via Gemini]', + '', + `Query: "${query}"`, + '', + '---', + '', + content, + '', + '---', + '', + 'Search completed successfully. You can use this information to answer the user\'s question.', + 'If you need more specific information, you can search again with a refined query.', + ].join('\n'); +} + +/** + * Get MCP fallback message when Gemini fails + * + * @param {string} query - Original query + * @param {string} error - Error message from Gemini attempt + * @returns {string} Message directing Claude to use MCP + */ +function getMcpFallbackMessage(query, error) { + return [ + '[WebSearch Unavailable - Use MCP Fallback]', + '', + `Gemini CLI failed: ${error || 'Unknown error'}`, + '', + 'The native WebSearch tool is not available with your current provider.', + 'Please use one of the following MCP tools instead:', + '', + '1. mcp__web-search-prime__webSearchPrime (requires z.ai subscription)', + '2. mcp__brave-search__brave_web_search (if configured)', + '3. mcp__tavily__search (if configured)', + '', + `Use the same query: "${query}"`, + '', + 'Example:', + '```', + 'mcp__web-search-prime__webSearchPrime', + `search_query: "${query}"`, + '```', + ].join('\n'); +} + +/** + * Output MCP fallback response and exit + * + * @param {string} query - Original query + * @param {string} error - Error message + */ +function outputMcpFallback(query, error) { + const output = { + decision: 'block', + reason: 'WebSearch unavailable - use MCP fallback', + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: getMcpFallbackMessage(query, error), + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); +} diff --git a/src/ccs.ts b/src/ccs.ts index 0ad69fe5..af79374a 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -14,7 +14,7 @@ import { detectClaudeCli } from './utils/claude-detector'; import { getSettingsPath } from './utils/config-manager'; import { ErrorManager } from './utils/error-manager'; import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; -import { ensureMcpWebSearch } from './utils/mcp-manager'; +import { ensureMcpWebSearch, installWebSearchHook } from './utils/mcp-manager'; // Import extracted command handlers import { handleVersionCommand } from './commands/version-command'; @@ -382,6 +382,7 @@ async function main(): Promise { // Settings-based profiles (glm, glmt, kimi) are third-party providers // WebSearch is server-side tool - third-party providers have no access ensureMcpWebSearch(); + installWebSearchHook(); // Check if this is GLMT profile (requires proxy) if (profileInfo.name === 'glmt') { diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index a7ae91ac..d81a1d19 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -39,7 +39,7 @@ import { } from './account-manager'; import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands'; import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; -import { ensureMcpWebSearch } from '../utils/mcp-manager'; +import { ensureMcpWebSearch, installWebSearchHook } from '../utils/mcp-manager'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -118,6 +118,10 @@ export async function execClaudeWithCLIProxy( // Third-party providers don't have access, so we use MCP fallback ensureMcpWebSearch(); + // Install WebSearch hook for Gemini CLI + MCP fallback + // Hook intercepts WebSearch, tries Gemini CLI first, falls back to MCP + installWebSearchHook(); + // Validate provider const providerConfig = getProviderConfig(provider); log(`Provider: ${providerConfig.displayName}`); diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 2b181ba0..909e81aa 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -325,6 +325,10 @@ export function getWebSearchConfig(): { provider: 'auto' | 'web-search-prime' | 'brave' | 'tavily'; fallback: boolean; webSearchPrimeUrl?: string; + gemini: { + enabled: boolean; + timeout: number; + }; } { const config = loadOrCreateUnifiedConfig(); return { @@ -332,5 +336,9 @@ export function getWebSearchConfig(): { provider: config.websearch?.provider ?? 'auto', fallback: config.websearch?.fallback ?? true, webSearchPrimeUrl: config.websearch?.webSearchPrimeUrl, + gemini: { + enabled: config.websearch?.gemini?.enabled ?? true, + timeout: config.websearch?.gemini?.timeout ?? 55, + }, }; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 3d9fa5ce..08949c6d 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -113,6 +113,17 @@ export interface WebSearchConfig { fallback?: boolean; /** Custom URL for web-search-prime provider (optional, overrides default) */ webSearchPrimeUrl?: string; + /** + * Gemini CLI configuration for WebSearch transformation. + * Uses `gemini -p` with google_web_search tool as primary method. + * No API key needed - uses OAuth authentication. + */ + gemini?: { + /** Enable Gemini CLI for WebSearch (default: true) */ + enabled?: boolean; + /** Timeout in seconds for Gemini CLI (default: 55) */ + timeout?: number; + }; } /** @@ -175,6 +186,10 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { enabled: true, provider: 'auto', fallback: true, + gemini: { + enabled: true, + timeout: 55, + }, }, }; } diff --git a/src/utils/mcp-manager.ts b/src/utils/mcp-manager.ts index 8e85cf00..855d1686 100644 --- a/src/utils/mcp-manager.ts +++ b/src/utils/mcp-manager.ts @@ -42,7 +42,7 @@ interface McpConfig { /** * Default web-search-prime MCP configuration (primary) - * HTTP-based, no API key needed + * HTTP-based, requires z.ai coding plan subscription */ const WEB_SEARCH_PRIME_CONFIG: McpServerConfig = { type: 'http', @@ -86,7 +86,7 @@ const TAVILY_CONFIG: McpServerConfig = { * - fallback: false → don't add fallback providers * * Multi-tier MCP fallback chain: - * 1. web-search-prime (primary, no API key needed) + * 1. web-search-prime (primary, requires z.ai subscription) * 2. brave-search (if BRAVE_API_KEY set) * 3. tavily (if TAVILY_API_KEY set) * @@ -290,31 +290,45 @@ export function getMcpConfigPath(): string { // CCS hooks directory const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks'); +// Legacy hook for simple MCP redirect (deprecated) const BLOCK_WEBSEARCH_HOOK = 'block-websearch.cjs'; +// New hybrid hook: Gemini CLI first, MCP fallback +const GEMINI_TRANSFORMER_HOOK = 'websearch-gemini-transformer.cjs'; +// Default hook timeout in seconds (Gemini CLI needs time) +const HOOK_TIMEOUT_SECONDS = 60; +// Path to Claude settings.json +const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); /** - * Install WebSearch blocking hook to ~/.ccs/hooks/ + * Install WebSearch transformer hook to ~/.ccs/hooks/ * - * This hook blocks native WebSearch and directs Claude to use MCP. - * Optional feature - must be explicitly enabled. + * This hook intercepts WebSearch and: + * 1. Tries Gemini CLI with google_web_search (no API key needed) + * 2. Falls back to MCP redirect if Gemini fails * + * This is the ultimate solution for third-party profiles. + * + * @param options - Installation options + * @param options.legacy - Install legacy block-websearch.cjs instead (default: false) * @returns true if hook installed successfully */ -export function installWebSearchHook(): boolean { +export function installWebSearchHook(options?: { legacy?: boolean }): boolean { try { // Ensure hooks directory exists if (!fs.existsSync(CCS_HOOKS_DIR)) { fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 }); } - const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); + // Determine which hook to install + const hookFileName = options?.legacy ? BLOCK_WEBSEARCH_HOOK : GEMINI_TRANSFORMER_HOOK; + const hookPath = path.join(CCS_HOOKS_DIR, hookFileName); // Find the bundled hook script - // In npm package: node_modules/ccs/lib/hooks/block-websearch.cjs - // In development: lib/hooks/block-websearch.cjs + // In npm package: node_modules/ccs/lib/hooks/ + // In development: lib/hooks/ const possiblePaths = [ - path.join(__dirname, '..', '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK), - path.join(__dirname, '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK), + path.join(__dirname, '..', '..', 'lib', 'hooks', hookFileName), + path.join(__dirname, '..', 'lib', 'hooks', hookFileName), ]; let sourcePath: string | null = null; @@ -327,7 +341,7 @@ export function installWebSearchHook(): boolean { if (!sourcePath) { if (process.env.CCS_DEBUG) { - console.error(warn('WebSearch hook source not found')); + console.error(warn(`WebSearch hook source not found: ${hookFileName}`)); } return false; } @@ -336,10 +350,31 @@ export function installWebSearchHook(): boolean { fs.copyFileSync(sourcePath, hookPath); fs.chmodSync(hookPath, 0o755); - if (process.env.CCS_DEBUG) { - console.error(info(`Installed WebSearch blocking hook to ${hookPath}`)); + // Also install the legacy hook for backward compatibility + if (!options?.legacy) { + const legacyHookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); + const legacySourcePaths = [ + path.join(__dirname, '..', '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK), + path.join(__dirname, '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK), + ]; + + for (const p of legacySourcePaths) { + if (fs.existsSync(p)) { + fs.copyFileSync(p, legacyHookPath); + fs.chmodSync(legacyHookPath, 0o755); + break; + } + } } + if (process.env.CCS_DEBUG) { + console.error(info(`Installed WebSearch hook: ${hookPath}`)); + } + + // Also ensure the hook is configured in settings.json + // This is called after the hook file is installed + ensureWebSearchHookConfigInternal(); + return true; } catch (error) { if (process.env.CCS_DEBUG) { @@ -352,10 +387,17 @@ export function installWebSearchHook(): boolean { /** * Get WebSearch hook configuration for settings.json * - * @returns hook configuration object + * Returns config for the Gemini transformer hook (default) or legacy hook. + * + * @param options - Configuration options + * @param options.legacy - Use legacy block-websearch.cjs instead (default: false) + * @returns hook configuration object for settings.json */ -export function getWebSearchHookConfig(): Record { - const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); +export function getWebSearchHookConfig(options?: { legacy?: boolean }): Record { + const hookFileName = options?.legacy ? BLOCK_WEBSEARCH_HOOK : GEMINI_TRANSFORMER_HOOK; + const hookPath = path.join(CCS_HOOKS_DIR, hookFileName); + // Legacy hook only needs 5s, Gemini hook needs 60s for CLI execution + const timeout = options?.legacy ? 5 : HOOK_TIMEOUT_SECONDS; return { PreToolUse: [ @@ -365,7 +407,7 @@ export function getWebSearchHookConfig(): Record { { type: 'command', command: `node "${hookPath}"`, - timeout: 5, + timeout: timeout, }, ], }, @@ -374,11 +416,158 @@ export function getWebSearchHookConfig(): Record { } /** - * Check if WebSearch blocking hook is installed + * Check if WebSearch hook is installed * - * @returns true if hook exists + * Checks for both the new Gemini transformer and legacy block hook. + * + * @returns true if any WebSearch hook exists */ export function hasWebSearchHook(): boolean { - const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); - return fs.existsSync(hookPath); + const geminiHookPath = path.join(CCS_HOOKS_DIR, GEMINI_TRANSFORMER_HOOK); + const legacyHookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); + return fs.existsSync(geminiHookPath) || fs.existsSync(legacyHookPath); +} + +/** + * Get information about installed WebSearch hooks + * + * @returns Object with hook status details + */ +export function getWebSearchHookStatus(): { + installed: boolean; + geminiTransformer: boolean; + legacyBlock: boolean; + activePath: string | null; +} { + const geminiHookPath = path.join(CCS_HOOKS_DIR, GEMINI_TRANSFORMER_HOOK); + const legacyHookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); + const hasGemini = fs.existsSync(geminiHookPath); + const hasLegacy = fs.existsSync(legacyHookPath); + + return { + installed: hasGemini || hasLegacy, + geminiTransformer: hasGemini, + legacyBlock: hasLegacy, + // Gemini transformer takes priority + activePath: hasGemini ? geminiHookPath : hasLegacy ? legacyHookPath : null, + }; +} + +/** + * Get environment variables for WebSearch hook configuration. + * + * These env vars are read by the websearch-gemini-transformer.cjs hook + * to control its behavior without requiring file I/O. + * + * @returns Record of environment variables to set before spawning Claude + */ +export function getWebSearchHookEnv(): Record { + const wsConfig = getWebSearchConfig(); + const env: Record = {}; + + // Skip hook entirely if disabled + if (!wsConfig.enabled) { + env.CCS_WEBSEARCH_SKIP = '1'; + return env; + } + + // Skip Gemini CLI if specifically disabled + if (!wsConfig.gemini.enabled) { + env.CCS_GEMINI_SKIP = '1'; + } + + // Set Gemini timeout + if (wsConfig.gemini.timeout) { + env.CCS_GEMINI_TIMEOUT = String(wsConfig.gemini.timeout); + } + + return env; +} + +/** + * Ensure WebSearch hook is configured in ~/.claude/settings.json + * + * Merges the hook configuration into existing settings.json without + * overwriting other settings. Only adds if not already present. + * + * This is an internal function called by installWebSearchHook. + * + * @returns true if hook config is present (already existed or was added) + */ +function ensureWebSearchHookConfigInternal(): boolean { + try { + const wsConfig = getWebSearchConfig(); + + // Skip if disabled + if (!wsConfig.enabled) { + return false; + } + + // Read existing settings or start fresh + let settings: Record = {}; + if (fs.existsSync(CLAUDE_SETTINGS_PATH)) { + try { + const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8'); + settings = JSON.parse(content); + } catch { + // Malformed JSON - start fresh + if (process.env.CCS_DEBUG) { + console.error(warn('Malformed settings.json - will merge carefully')); + } + } + } + + // Check if WebSearch hook already configured + const hooks = settings.hooks as Record | undefined; + if (hooks?.PreToolUse) { + const hasWebSearchHook = hooks.PreToolUse.some((h: unknown) => { + const hook = h as Record; + return hook.matcher === 'WebSearch'; + }); + + if (hasWebSearchHook) { + if (process.env.CCS_DEBUG) { + console.error(info('WebSearch hook already configured in settings.json')); + } + return true; + } + } + + // Get hook config + const hookConfig = getWebSearchHookConfig(); + + // Merge hook config into settings + if (!settings.hooks) { + settings.hooks = {}; + } + + const settingsHooks = settings.hooks as Record; + if (!settingsHooks.PreToolUse) { + settingsHooks.PreToolUse = []; + } + + // Add our hook config + const preToolUseHooks = hookConfig.PreToolUse as unknown[]; + settingsHooks.PreToolUse.push(...preToolUseHooks); + + // Ensure ~/.claude directory exists + const claudeDir = path.dirname(CLAUDE_SETTINGS_PATH); + if (!fs.existsSync(claudeDir)) { + fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 }); + } + + // Write updated settings + fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8'); + + if (process.env.CCS_DEBUG) { + console.error(info('Added WebSearch hook to settings.json')); + } + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to configure WebSearch hook: ${(error as Error).message}`)); + } + return false; + } } diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index f1971496..92a36d16 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -163,9 +163,7 @@ export function SettingsPage() { -

- Primary web search provider to use -

+

Primary web search provider to use