diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index bec0e6a5..26dc974d 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -1,9 +1,9 @@ #!/usr/bin/env node /** - * CCS WebSearch Hook - CLI Tool Executor + * CCS WebSearch Hook - CLI Tool Executor with Fallback Chain * * Intercepts Claude's WebSearch tool and executes search via CLI tools. - * Currently supports Gemini CLI, designed for easy addition of future tools. + * Supports automatic fallback: Gemini CLI → OpenCode → Grok CLI * * Environment Variables (set by CCS): * CCS_WEBSEARCH_SKIP=1 - Skip this hook entirely (for official Claude) @@ -15,7 +15,7 @@ * 0 - Allow tool (pass-through to native WebSearch) * 2 - Block tool (deny with results/message) * - * @module hooks/websearch-gemini-transformer + * @module hooks/websearch-transformer */ const { spawnSync } = require('child_process'); @@ -58,7 +58,7 @@ function isCliAvailable(cmd) { } /** - * Main hook processing logic + * Main hook processing logic with fallback chain */ async function processHook() { try { @@ -87,30 +87,47 @@ async function processHook() { const timeout = parseInt(process.env.CCS_WEBSEARCH_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); - // Try Gemini CLI (currently the only supported CLI tool) - if (isCliAvailable('gemini')) { + // Fallback chain: Gemini → OpenCode → Grok + const providers = [ + { name: 'Gemini CLI', cmd: 'gemini', fn: tryGeminiSearch }, + { name: 'OpenCode', cmd: 'opencode', fn: tryOpenCodeSearch }, + { name: 'Grok CLI', cmd: 'grok', fn: tryGrokSearch }, + ]; + + const availableProviders = providers.filter((p) => isCliAvailable(p.cmd)); + const errors = []; + + if (process.env.CCS_DEBUG) { + const available = availableProviders.map((p) => p.name).join(', ') || 'none'; + console.error(`[CCS Hook] Available providers: ${available}`); + } + + // Try each available provider in order + for (const provider of availableProviders) { if (process.env.CCS_DEBUG) { - console.error('[CCS Hook] Executing Gemini CLI...'); + console.error(`[CCS Hook] Trying ${provider.name}...`); } - const result = tryGeminiSearch(query, timeout); + const result = provider.fn(query, timeout); if (result.success) { - outputSuccess(query, result.content, 'Gemini CLI'); + outputSuccess(query, result.content, provider.name); return; } if (process.env.CCS_DEBUG) { - console.error(`[CCS Hook] Gemini failed: ${result.error}`); + console.error(`[CCS Hook] ${provider.name} failed: ${result.error}`); } - // Gemini failed - show error message - outputError(query, result.error, 'Gemini CLI'); - return; + errors.push({ provider: provider.name, error: result.error }); } - // No CLI tools available - outputNoToolsMessage(query); + // All providers failed or none available + if (availableProviders.length === 0) { + outputNoToolsMessage(query); + } else { + outputAllFailedMessage(query, errors); + } } catch (err) { if (process.env.CCS_DEBUG) { console.error('[CCS Hook] Parse error:', err.message); @@ -191,6 +208,134 @@ function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { } } +/** + * Execute search via OpenCode CLI + * Uses opencode run with gpt-5-nano model for web search + */ +function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + try { + const timeoutMs = timeoutSec * 1000; + + const prompt = `Search the web for: ${query} + +Provide a comprehensive summary with relevant URLs/sources.`; + + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Executing: opencode run --model opencode/gpt-5-nano "..."'); + } + + const spawnResult = spawnSync( + 'opencode', + ['run', prompt, '--model', 'opencode/gpt-5-nano'], + { + encoding: 'utf8', + timeout: timeoutMs, + maxBuffer: 1024 * 1024 * 2, + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + + if (spawnResult.error) { + if (spawnResult.error.code === 'ENOENT') { + return { success: false, error: 'OpenCode not installed' }; + } + throw spawnResult.error; + } + + if (spawnResult.status !== 0) { + const stderr = (spawnResult.stderr || '').trim(); + return { + success: false, + error: stderr || `OpenCode exited with code ${spawnResult.status}`, + }; + } + + const result = (spawnResult.stdout || '').trim(); + + if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) { + return { success: false, error: 'Empty or too short response from OpenCode' }; + } + + const lowerResult = result.toLowerCase(); + if ( + lowerResult.includes('error:') || + lowerResult.includes('failed to') || + lowerResult.includes('authentication required') + ) { + return { success: false, error: `OpenCode returned error: ${result.substring(0, 100)}` }; + } + + return { success: true, content: result }; + } catch (err) { + if (err.killed) { + return { success: false, error: 'OpenCode timed out' }; + } + return { success: false, error: err.message || 'Unknown OpenCode error' }; + } +} + +/** + * Execute search via Grok CLI + * Uses grok command for web search (requires GROK_API_KEY) + */ +function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + try { + const timeoutMs = timeoutSec * 1000; + + const prompt = `Search the web for: ${query} + +Provide a comprehensive summary with relevant URLs/sources.`; + + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Executing: grok "..."'); + } + + const spawnResult = spawnSync('grok', [prompt], { + encoding: 'utf8', + timeout: timeoutMs, + maxBuffer: 1024 * 1024 * 2, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + if (spawnResult.error) { + if (spawnResult.error.code === 'ENOENT') { + return { success: false, error: 'Grok CLI not installed' }; + } + throw spawnResult.error; + } + + if (spawnResult.status !== 0) { + const stderr = (spawnResult.stderr || '').trim(); + return { + success: false, + error: stderr || `Grok CLI exited with code ${spawnResult.status}`, + }; + } + + const result = (spawnResult.stdout || '').trim(); + + if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) { + return { success: false, error: 'Empty or too short response from Grok' }; + } + + const lowerResult = result.toLowerCase(); + if ( + lowerResult.includes('error:') || + lowerResult.includes('failed to') || + lowerResult.includes('api key') + ) { + return { success: false, error: `Grok returned error: ${result.substring(0, 100)}` }; + } + + return { success: true, content: result }; + } catch (err) { + if (err.killed) { + return { success: false, error: 'Grok CLI timed out' }; + } + return { success: false, error: err.message || 'Unknown Grok error' }; + } +} + /** * Format search results for Claude */ @@ -265,9 +410,17 @@ function outputNoToolsMessage(query) { '', 'WebSearch requires a CLI tool to be installed.', '', - 'Install Gemini CLI (free, uses Google OAuth):', - ' npm install -g @google/gemini-cli', - ' gemini auth login', + 'Install one of the following (in order of preference):', + '', + '1. Gemini CLI (FREE, 1000 req/day):', + ' npm install -g @google/gemini-cli', + ' gemini auth login', + '', + '2. OpenCode (FREE via Zen):', + ' curl -fsSL https://opencode.ai/install | bash', + '', + '3. Grok CLI (requires XAI_API_KEY):', + ' npm install -g @vibe-kit/grok-cli', '', `Query: "${query}"`, ].join('\n'); @@ -285,3 +438,39 @@ function outputNoToolsMessage(query) { console.log(JSON.stringify(output)); process.exit(2); } + +/** + * Output all providers failed message + */ +function outputAllFailedMessage(query, errors) { + const errorDetails = errors + .map((e) => ` - ${e.provider}: ${e.error}`) + .join('\n'); + + const message = [ + '[WebSearch - All Providers Failed]', + '', + 'Tried all available CLI tools but all failed:', + errorDetails, + '', + `Query: "${query}"`, + '', + 'Troubleshooting:', + ' - Gemini: gemini auth status / gemini auth login', + ' - OpenCode: opencode --version', + ' - Grok: Check XAI_API_KEY environment variable', + ].join('\n'); + + const output = { + decision: 'block', + reason: 'WebSearch failed - all providers failed', + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: message, + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); +} diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 96929a79..40d055e8 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -267,8 +267,10 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push('# Third-party providers (gemini, codex, agy, etc.) do not have access to'); lines.push("# Anthropic's WebSearch tool. These CLI tools provide fallback web search."); lines.push('#'); + lines.push('# Fallback chain: Gemini -> OpenCode -> Grok (tries in order until success)'); lines.push('# providers:'); lines.push('# gemini: Gemini CLI (FREE) - npm i -g @google/gemini-cli'); + lines.push('# opencode: OpenCode (FREE) - curl -fsSL https://opencode.ai/install | bash'); lines.push('# grok: Grok CLI (paid) - npm i -g @vibe-kit/grok-cli (needs GROK_API_KEY)'); lines.push('#'); lines.push('# enabled: Master switch - auto-disables when no providers enabled');