From c0938e1c8286b0f9c262cc36472e3600a2b044bb Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 16 Dec 2025 21:00:35 -0500 Subject: [PATCH] feat(websearch): add Grok CLI support and improve install guidance - Add Grok CLI detection (grok-4-cli by lalomorales22) alongside Gemini CLI - Add WebSearch health check group to health-service.ts - Update settings UI to show both Gemini and Grok CLI providers - Add detailed install hints when no WebSearch CLI is installed - Update API routes to return grokCli status - Both CLI and dashboard users now see clear installation guidance Providers: - Gemini CLI: FREE tier (1000 req/day), no API key needed - Grok CLI: Requires xAI API key (XAI_API_KEY), web + X search --- lib/hooks/websearch-gemini-transformer.cjs | 333 -------- lib/hooks/websearch-transformer.cjs | 287 +++++++ src/ccs.ts | 6 +- src/cliproxy/cliproxy-executor.ts | 9 +- src/config/unified-config-loader.ts | 113 +-- src/config/unified-config-types.ts | 84 ++- src/utils/mcp-manager.ts | 834 --------------------- src/utils/shell-executor.ts | 8 +- src/utils/websearch-manager.ts | 632 ++++++++++++++++ src/web-server/health-service.ts | 54 ++ src/web-server/routes.ts | 97 +-- ui/src/pages/settings.tsx | 578 +++++--------- 12 files changed, 1309 insertions(+), 1726 deletions(-) delete mode 100644 lib/hooks/websearch-gemini-transformer.cjs create mode 100644 lib/hooks/websearch-transformer.cjs delete mode 100644 src/utils/mcp-manager.ts create mode 100644 src/utils/websearch-manager.ts diff --git a/lib/hooks/websearch-gemini-transformer.cjs b/lib/hooks/websearch-gemini-transformer.cjs deleted file mode 100644 index 3b2756f6..00000000 --- a/lib/hooks/websearch-gemini-transformer.cjs +++ /dev/null @@ -1,333 +0,0 @@ -#!/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! Use deny with clear success messaging - // Note: "deny" prevents native WebSearch from running (which would fail anyway) - // The permissionDecisionReason contains the actual search results - 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 --model gemini-2.5-flash --yolo -p "..."`); - } - - // Execute gemini CLI with required flags: - // --model gemini-2.5-flash: Use the flash model for fast responses - // --yolo: Skip confirmation prompts for tool use - // -p: Provide prompt - const spawnResult = spawnSync('gemini', [ - '--model', 'gemini-2.5-flash', - '--yolo', - '-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 installed. Install with: npm install -g @google/gemini-cli', - }; - } - - // 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 COMPLETED SUCCESSFULLY ===', - '(via Gemini CLI - this is NOT an error)', - '', - `Query: "${query}"`, - '', - content, - '', - '=========================================', - 'Use this information to answer the user. Search again if needed.', - ].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'}`, - '', - 'To enable Gemini CLI WebSearch:', - ' 1. Install: npm install -g @google/gemini-cli', - ' 2. Authenticate: gemini auth', - '', - '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/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs new file mode 100644 index 00000000..bec0e6a5 --- /dev/null +++ b/lib/hooks/websearch-transformer.cjs @@ -0,0 +1,287 @@ +#!/usr/bin/env node +/** + * CCS WebSearch Hook - CLI Tool Executor + * + * Intercepts Claude's WebSearch tool and executes search via CLI tools. + * Currently supports Gemini CLI, designed for easy addition of future tools. + * + * Environment Variables (set by CCS): + * CCS_WEBSEARCH_SKIP=1 - Skip this hook entirely (for official Claude) + * CCS_WEBSEARCH_ENABLED=1 - Enable WebSearch (default: 1) + * CCS_WEBSEARCH_TIMEOUT=55 - Timeout in seconds (default: 55) + * CCS_DEBUG=1 - Enable debug output + * + * Exit codes: + * 0 - Allow tool (pass-through to native WebSearch) + * 2 - Block tool (deny with results/message) + * + * @module hooks/websearch-gemini-transformer + */ + +const { spawnSync } = require('child_process'); + +// Minimum response length to consider valid +const MIN_VALID_RESPONSE_LENGTH = 20; + +// Default timeout in seconds +const DEFAULT_TIMEOUT_SEC = 55; + +// 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); +}); + +/** + * Check if a CLI tool is available + */ +function isCliAvailable(cmd) { + try { + const result = spawnSync('which', [cmd], { + encoding: 'utf8', + timeout: 2000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + return result.status === 0 && result.stdout.trim().length > 0; + } catch { + return false; + } +} + +/** + * Main hook processing logic + */ +async function processHook() { + try { + // Skip if disabled (for official Claude subscriptions) + if (process.env.CCS_WEBSEARCH_SKIP === '1') { + process.exit(0); + } + + // Check if enabled (default: enabled) + if (process.env.CCS_WEBSEARCH_ENABLED === '0') { + 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) { + process.exit(0); + } + + const timeout = parseInt(process.env.CCS_WEBSEARCH_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); + + // Try Gemini CLI (currently the only supported CLI tool) + if (isCliAvailable('gemini')) { + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Executing Gemini CLI...'); + } + + const result = tryGeminiSearch(query, timeout); + + if (result.success) { + outputSuccess(query, result.content, 'Gemini CLI'); + return; + } + + if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] Gemini failed: ${result.error}`); + } + + // Gemini failed - show error message + outputError(query, result.error, 'Gemini CLI'); + return; + } + + // No CLI tools available + outputNoToolsMessage(query); + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Parse error:', err.message); + } + process.exit(0); + } +} + +/** + * Execute search via Gemini CLI + */ +function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + try { + const timeoutMs = timeoutSec * 1000; + + const prompt = [ + `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'); + + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Executing: gemini --model gemini-2.5-flash --yolo -p "..."'); + } + + const spawnResult = spawnSync( + 'gemini', + ['--model', 'gemini-2.5-flash', '--yolo', '-p', 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: 'Gemini CLI not installed' }; + } + throw spawnResult.error; + } + + if (spawnResult.status !== 0) { + const stderr = (spawnResult.stderr || '').trim(); + return { + success: false, + error: stderr || `Gemini 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 Gemini' }; + } + + const lowerResult = result.toLowerCase(); + if ( + lowerResult.includes('error:') || + lowerResult.includes('failed to') || + lowerResult.includes('authentication required') + ) { + return { success: false, error: `Gemini returned error: ${result.substring(0, 100)}` }; + } + + return { success: true, content: result }; + } catch (err) { + if (err.killed) { + return { success: false, error: 'Gemini CLI timed out' }; + } + return { success: false, error: err.message || 'Unknown Gemini error' }; + } +} + +/** + * Format search results for Claude + */ +function formatSearchResults(query, content, providerName) { + return [ + '=== WEBSEARCH COMPLETED SUCCESSFULLY ===', + `(via ${providerName} - this is NOT an error)`, + '', + `Query: "${query}"`, + '', + content, + '', + '=========================================', + 'Use this information to answer the user. Search again if needed.', + ].join('\n'); +} + +/** + * Output success response and exit + */ +function outputSuccess(query, content, providerName) { + const output = { + decision: 'block', + reason: `WebSearch completed via ${providerName}`, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: formatSearchResults(query, content, providerName), + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Output error message + */ +function outputError(query, error, providerName) { + const message = [ + `[WebSearch - ${providerName} Error]`, + '', + `Error: ${error}`, + '', + `Query: "${query}"`, + '', + 'Troubleshooting:', + ' - Check if Gemini CLI is authenticated: gemini auth status', + ' - Re-authenticate if needed: gemini auth login', + ].join('\n'); + + const output = { + decision: 'block', + reason: `WebSearch failed: ${error}`, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: message, + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Output no tools message + */ +function outputNoToolsMessage(query) { + const message = [ + '[WebSearch - No CLI Tools Available]', + '', + 'WebSearch requires a CLI tool to be installed.', + '', + 'Install Gemini CLI (free, uses Google OAuth):', + ' npm install -g @google/gemini-cli', + ' gemini auth login', + '', + `Query: "${query}"`, + ].join('\n'); + + const output = { + decision: 'block', + reason: 'WebSearch unavailable - no CLI tools installed', + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: message, + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); +} diff --git a/src/ccs.ts b/src/ccs.ts index 176e845c..3e1c22ef 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -18,7 +18,8 @@ import { ensureMcpWebSearch, installWebSearchHook, displayWebSearchStatus, -} from './utils/mcp-manager'; + getWebSearchHookEnv, +} from './utils/websearch-manager'; // Import extracted command handlers import { handleVersionCommand } from './commands/version-command'; @@ -152,7 +153,8 @@ async function execClaudeWithProxy( const isWindows = process.platform === 'win32'; const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); - const env = { ...process.env, ...envVars }; + const webSearchEnv = getWebSearchHookEnv(); + const env = { ...process.env, ...envVars, ...webSearchEnv }; let claude: ChildProcess; if (needsShell) { diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 0ef7ff65..80f19ea4 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -28,6 +28,7 @@ import { import { isAuthenticated } from './auth-handler'; import { CLIProxyProvider, ExecutorConfig } from './types'; import { configureProviderModel, getCurrentModel } from './model-config'; +import { getWebSearchHookEnv } from '../utils/websearch-manager'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; import { findAccountByQuery, @@ -43,7 +44,7 @@ import { ensureMcpWebSearch, installWebSearchHook, displayWebSearchStatus, -} from '../utils/mcp-manager'; +} from '../utils/websearch-manager'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -386,10 +387,14 @@ export async function execClaudeWithCLIProxy( // 7. Execute Claude CLI with proxied environment // Uses custom settings path (for variants), user settings, or bundled defaults const envVars = getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath); - const env = { ...process.env, ...envVars }; + const webSearchEnv = getWebSearchHookEnv(); + const env = { ...process.env, ...envVars, ...webSearchEnv }; log(`Claude env: ANTHROPIC_BASE_URL=${envVars.ANTHROPIC_BASE_URL}`); log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`); + if (Object.keys(webSearchEnv).length > 0) { + log(`Claude env: WebSearch config=${JSON.stringify(webSearchEnv)}`); + } // Filter out CCS-specific flags before passing to Claude CLI const ccsFlags = [ diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 7df75034..1c765c93 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -64,6 +64,7 @@ export function getConfigFormat(): 'yaml' | 'json' | 'none' { /** * Load unified config from YAML file. * Returns null if file doesn't exist or format check fails. + * Auto-upgrades config if version is outdated (regenerates comments). */ export function loadUnifiedConfig(): UnifiedConfig | null { const yamlPath = getConfigYamlPath(); @@ -82,6 +83,19 @@ export function loadUnifiedConfig(): UnifiedConfig | null { return null; } + // Auto-upgrade if version is outdated (regenerates YAML with new comments) + if ((parsed.version ?? 1) < UNIFIED_CONFIG_VERSION) { + parsed.version = UNIFIED_CONFIG_VERSION; + try { + saveUnifiedConfig(parsed); + if (process.env.CCS_DEBUG) { + console.error(`[i] Config upgraded to v${UNIFIED_CONFIG_VERSION}`); + } + } catch { + // Ignore save errors during upgrade - config still works + } + } + return parsed; } catch (err) { const error = err instanceof Error ? err.message : 'Unknown error'; @@ -117,18 +131,20 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { }, websearch: { enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true, - provider: partial.websearch?.provider ?? defaults.websearch?.provider ?? 'auto', - fallback: partial.websearch?.fallback ?? defaults.websearch?.fallback ?? true, - webSearchPrimeUrl: - partial.websearch?.webSearchPrimeUrl ?? defaults.websearch?.webSearchPrimeUrl, - gemini: { - enabled: partial.websearch?.gemini?.enabled ?? defaults.websearch?.gemini?.enabled ?? true, - timeout: partial.websearch?.gemini?.timeout ?? defaults.websearch?.gemini?.timeout ?? 55, + providers: { + gemini: { + enabled: + partial.websearch?.providers?.gemini?.enabled ?? + partial.websearch?.gemini?.enabled ?? // Legacy fallback + true, + timeout: + partial.websearch?.providers?.gemini?.timeout ?? + partial.websearch?.gemini?.timeout ?? // Legacy fallback + 55, + }, }, - mode: partial.websearch?.mode ?? defaults.websearch?.mode ?? 'sequential', - selectedProviders: - partial.websearch?.selectedProviders ?? defaults.websearch?.selectedProviders ?? [], - customMcp: partial.websearch?.customMcp ?? defaults.websearch?.customMcp ?? [], + // Legacy fields (keep for backwards compatibility during read) + gemini: partial.websearch?.gemini, }, }; } @@ -245,11 +261,19 @@ function generateYamlWithComments(config: UnifiedConfig): string { // WebSearch section if (config.websearch) { lines.push('# ----------------------------------------------------------------------------'); - lines.push('# WebSearch: MCP auto-configuration for third-party profiles'); - lines.push('# enabled: true/false - Enable/disable MCP web-search auto-config'); - lines.push('# provider: auto | web-search-prime | brave | tavily'); - lines.push('# fallback: true/false - Enable fallback chain when provider fails'); - lines.push('# API keys: Set BRAVE_API_KEY or TAVILY_API_KEY env vars'); + lines.push('# WebSearch: Provider configuration for third-party profiles'); + lines.push('# Dashboard (`ccs config`) is the source of truth for provider selection.'); + lines.push('#'); + lines.push('# enabled: Master switch - auto-disables when no providers enabled'); + lines.push('# mode: sequential (try in order) | parallel (merge all results)'); + lines.push('#'); + lines.push('# providers:'); + lines.push('# gemini: Uses Gemini CLI with google_web_search tool'); + lines.push('# web-search-prime: HTTP MCP endpoint (z.ai subscription)'); + lines.push('# brave: Brave Search API (requires BRAVE_API_KEY)'); + lines.push('# tavily: Tavily Search API (requires TAVILY_API_KEY)'); + lines.push('#'); + lines.push('# customMcp: User-defined MCP servers (BYOM - Bring Your Own MCP)'); lines.push('# ----------------------------------------------------------------------------'); lines.push( yaml @@ -324,43 +348,46 @@ export function setDefaultProfile(name: string): void { updateUnifiedConfig({ default: name }); } +/** + * Gemini CLI WebSearch configuration + */ +export interface GeminiWebSearchInfo { + enabled: boolean; + timeout: number; +} + /** * Get websearch configuration. * Returns defaults if not configured. + * Simplified: Gemini CLI only (future CLI tools can be added). */ export function getWebSearchConfig(): { enabled: boolean; - provider: 'auto' | 'web-search-prime' | 'brave' | 'tavily'; - fallback: boolean; - webSearchPrimeUrl?: string; - gemini: { - enabled: boolean; - timeout: number; + providers?: { + gemini?: GeminiWebSearchInfo; }; - mode: 'sequential' | 'parallel'; - selectedProviders: string[]; - customMcp: Array<{ - name: string; - type: 'http' | 'stdio'; - url?: string; - headers?: Record; - command?: string; - args?: string[]; - env?: Record; - }>; + // Legacy fields (deprecated) + gemini?: { enabled?: boolean; timeout?: number }; } { const config = loadOrCreateUnifiedConfig(); + + // Build provider config from new format or legacy fallbacks + const geminiConfig: GeminiWebSearchInfo = { + enabled: + config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? true, + timeout: + config.websearch?.providers?.gemini?.timeout ?? config.websearch?.gemini?.timeout ?? 55, + }; + + // Auto-disable master switch if Gemini is not enabled + const enabled = geminiConfig.enabled && (config.websearch?.enabled ?? true); + return { - enabled: config.websearch?.enabled ?? true, - 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, + enabled, + providers: { + gemini: geminiConfig, }, - mode: config.websearch?.mode ?? 'sequential', - selectedProviders: config.websearch?.selectedProviders ?? [], - customMcp: config.websearch?.customMcp ?? [], + // Legacy field for backwards compatibility + gemini: config.websearch?.gemini, }; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 96e4a781..72463bb3 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -12,8 +12,9 @@ /** * Unified config version. * Version 2 = YAML unified format + * Version 3 = WebSearch config comments update */ -export const UNIFIED_CONFIG_VERSION = 2; +export const UNIFIED_CONFIG_VERSION = 3; /** * Account configuration (formerly in profiles.json). @@ -101,55 +102,56 @@ export interface PreferencesConfig { } /** - * Custom MCP server configuration for BYOM (Bring Your Own MCP) + * Gemini CLI WebSearch configuration. */ -export interface CustomMcpConfig { - /** Unique name for this MCP server */ - name: string; - /** Server type: HTTP endpoint or stdio command */ - type: 'http' | 'stdio'; - /** URL for HTTP-based MCP servers */ - url?: string; - /** Headers for HTTP requests */ - headers?: Record; - /** Command for stdio-based MCP servers */ - command?: string; - /** Arguments for stdio command */ - args?: string[]; - /** Environment variables for the server */ - env?: Record; +export interface GeminiWebSearchConfig { + /** Enable Gemini CLI for WebSearch (default: true) */ + enabled?: boolean; + /** Timeout in seconds (default: 55) */ + timeout?: number; +} + +/** + * WebSearch providers configuration. + * Currently supports Gemini CLI only. + * Future: opencode, grok-cli, etc. + */ +export interface WebSearchProvidersConfig { + /** Gemini CLI - uses google_web_search tool */ + gemini?: GeminiWebSearchConfig; + // Future CLI tools can be added here: + // opencode?: { enabled?: boolean; model?: string; }; + // grok?: { enabled?: boolean; }; } /** * WebSearch configuration. - * Controls MCP web-search auto-configuration for third-party profiles. + * Uses CLI tools (Gemini CLI) to provide WebSearch for third-party profiles. + * Third-party providers don't have server-side WebSearch access. */ export interface WebSearchConfig { - /** Enable auto-configuration of MCP web-search (default: true) */ + /** Master switch - enable/disable WebSearch (default: true) */ enabled?: boolean; - /** Preferred provider: auto uses fallback chain, or specify one */ - provider?: 'auto' | 'web-search-prime' | 'brave' | 'tavily'; - /** Enable fallback chain when preferred provider fails (default: true) */ - 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. - */ + /** Individual provider configurations */ + providers?: WebSearchProvidersConfig; + // Legacy fields (deprecated, kept for backwards compatibility) + /** @deprecated Use providers.gemini instead */ gemini?: { - /** Enable Gemini CLI for WebSearch (default: true) */ enabled?: boolean; - /** Timeout in seconds for Gemini CLI (default: 55) */ timeout?: number; }; - /** Search mode: sequential (default) or parallel */ + /** @deprecated Unused */ mode?: 'sequential' | 'parallel'; - /** Selected providers for parallel mode */ + /** @deprecated Unused */ + provider?: 'auto' | 'web-search-prime' | 'brave' | 'tavily'; + /** @deprecated Unused */ + fallback?: boolean; + /** @deprecated Unused */ + webSearchPrimeUrl?: string; + /** @deprecated Unused */ selectedProviders?: string[]; - /** Custom MCP servers (BYOM - Bring Your Own MCP) */ - customMcp?: CustomMcpConfig[]; + /** @deprecated Unused */ + customMcp?: unknown[]; } /** @@ -210,11 +212,11 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { }, websearch: { enabled: true, - provider: 'auto', - fallback: true, - gemini: { - enabled: true, - timeout: 55, + providers: { + gemini: { + enabled: true, + timeout: 55, + }, }, }, }; diff --git a/src/utils/mcp-manager.ts b/src/utils/mcp-manager.ts deleted file mode 100644 index 99d684d8..00000000 --- a/src/utils/mcp-manager.ts +++ /dev/null @@ -1,834 +0,0 @@ -/** - * MCP Manager - Manages MCP server configuration for CCS - * - * Ensures web-search-prime MCP is available for third-party profiles - * that cannot use Claude's native WebSearch tool. - * - * WebSearch is a server-side tool executed by Anthropic's API. - * Third-party providers (gemini, agy, codex, qwen) don't have access. - * This manager auto-configures MCP web-search as a fallback. - * - * @module utils/mcp-manager - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { ok, info, warn, fail } from './ui'; -import { getWebSearchConfig } from '../config/unified-config-loader'; - -// MCP configuration file path -const MCP_CONFIG_PATH = path.join(os.homedir(), '.claude', '.mcp.json'); - -/** - * MCP server configuration interface - */ -interface McpServerConfig { - type: 'http' | 'stdio'; - url?: string; - headers?: Record; - command?: string; - args?: string[]; - env?: Record; - _managedBy?: 'ccs'; // CCS-managed marker (Option C hybrid) -} - -/** - * MCP configuration file structure - */ -interface McpConfig { - mcpServers?: Record; - [key: string]: unknown; -} - -/** - * Default web-search-prime MCP configuration (primary) - * HTTP-based, requires z.ai coding plan subscription - */ -const WEB_SEARCH_PRIME_CONFIG: McpServerConfig = { - type: 'http', - url: 'https://api.z.ai/api/mcp/web_search_prime/mcp', - headers: {}, - _managedBy: 'ccs', -}; - -/** - * Brave Search MCP configuration (secondary fallback) - * Requires BRAVE_API_KEY env var - * Free tier: 15k queries/month, 1 query/sec - * Package: @modelcontextprotocol/server-brave-search - */ -const BRAVE_SEARCH_CONFIG: McpServerConfig = { - type: 'stdio', - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-brave-search'], - env: {}, - _managedBy: 'ccs', -}; - -/** - * Tavily MCP configuration (tertiary fallback) - * Requires TAVILY_API_KEY env var - * AI-optimized search, paid service - * Package: @tavily/mcp-server (official) - */ -const TAVILY_CONFIG: McpServerConfig = { - type: 'stdio', - command: 'npx', - args: ['-y', '@tavily/mcp-server'], - env: {}, - _managedBy: 'ccs', -}; - -/** - * Ensure MCP web-search is configured for third-party profiles - * - * Called before spawning Claude CLI for CLIProxy profiles. - * Respects user configuration from ~/.ccs/config.yaml: - * - enabled: false → skip auto-config - * - provider: specific → add only that provider - * - fallback: false → don't add fallback providers - * - * Multi-tier MCP fallback chain: - * 1. web-search-prime (primary, requires z.ai subscription) - * 2. brave-search (if BRAVE_API_KEY set) - * 3. tavily (if TAVILY_API_KEY set) - * - * Only adds MCPs if no web search MCP is already configured. - * - * @returns true if web search MCP is available, false on error - */ -export function ensureMcpWebSearch(): boolean { - try { - // Check user configuration - const wsConfig = getWebSearchConfig(); - - // If disabled by user, skip auto-configuration - if (!wsConfig.enabled) { - if (process.env.CCS_DEBUG) { - console.error(info('WebSearch auto-config disabled by user')); - } - return false; - } - - let config: McpConfig = { mcpServers: {} }; - - // Read existing config if present - if (fs.existsSync(MCP_CONFIG_PATH)) { - const content = fs.readFileSync(MCP_CONFIG_PATH, 'utf8'); - try { - config = JSON.parse(content); - } catch { - // Malformed JSON - start fresh but preserve file as backup - if (process.env.CCS_DEBUG) { - console.error(warn('Malformed .mcp.json - starting fresh')); - } - config = { mcpServers: {} }; - } - } - - // Initialize mcpServers if missing - if (!config.mcpServers) { - config.mcpServers = {}; - } - - // Check if any web search MCP already configured (case-insensitive) - const hasWebSearch = Object.keys(config.mcpServers).some((key) => { - const lowerKey = key.toLowerCase(); - return ( - lowerKey.includes('web-search') || - lowerKey.includes('websearch') || - lowerKey.includes('tavily') || - lowerKey.includes('brave') - ); - }); - - if (hasWebSearch) { - if (process.env.CCS_DEBUG) { - console.error(info('MCP web-search already configured')); - } - return true; - } - - // Track what we add for logging - const addedMcps: string[] = []; - - // Helper to add a specific provider - const addProvider = (provider: 'web-search-prime' | 'brave' | 'tavily'): boolean => { - // mcpServers is guaranteed to exist here (initialized above) - const servers = config.mcpServers as Record; - - if (provider === 'web-search-prime') { - const webSearchPrimeConfig = { ...WEB_SEARCH_PRIME_CONFIG }; - // Use configurable URL if provided - if (wsConfig.webSearchPrimeUrl) { - webSearchPrimeConfig.url = wsConfig.webSearchPrimeUrl; - } - servers['web-search-prime'] = webSearchPrimeConfig; - addedMcps.push('web-search-prime'); - return true; - } - - if (provider === 'brave') { - const braveApiKey = process.env.BRAVE_API_KEY; - if (braveApiKey) { - const braveConfig = { ...BRAVE_SEARCH_CONFIG }; - braveConfig.env = { BRAVE_API_KEY: braveApiKey }; - servers['brave-search'] = braveConfig; - addedMcps.push('brave-search'); - return true; - } - return false; - } - - if (provider === 'tavily') { - const tavilyApiKey = process.env.TAVILY_API_KEY; - if (tavilyApiKey) { - const tavilyConfig = { ...TAVILY_CONFIG }; - tavilyConfig.env = { TAVILY_API_KEY: tavilyApiKey }; - servers['tavily'] = tavilyConfig; - addedMcps.push('tavily'); - return true; - } - return false; - } - - return false; - }; - - // Apply user's provider preference - if (wsConfig.provider === 'auto') { - // Auto mode: add all available providers - addProvider('web-search-prime'); - if (wsConfig.fallback) { - addProvider('brave'); - addProvider('tavily'); - } - } else { - // Specific provider requested - const added = addProvider(wsConfig.provider); - if (!added && wsConfig.fallback) { - // Fallback if preferred provider not available - if (process.env.CCS_DEBUG) { - console.error( - warn(`Preferred provider ${wsConfig.provider} not available, using fallback`) - ); - } - addProvider('web-search-prime'); - addProvider('brave'); - addProvider('tavily'); - } - } - - // Add custom MCPs from config (BYOM) - if (wsConfig.customMcp && wsConfig.customMcp.length > 0) { - const servers = config.mcpServers as Record; - for (const custom of wsConfig.customMcp) { - const serverConfig: McpServerConfig = { - type: custom.type, - _managedBy: 'ccs', - }; - if (custom.type === 'http') { - serverConfig.url = custom.url; - serverConfig.headers = custom.headers || {}; - } else { - serverConfig.command = custom.command; - serverConfig.args = custom.args || []; - serverConfig.env = custom.env || {}; - } - servers[custom.name] = serverConfig; - addedMcps.push(custom.name); - } - } - - // If nothing was added, return false - if (addedMcps.length === 0) { - if (process.env.CCS_DEBUG) { - console.error(warn('No web search MCP could be configured')); - } - return false; - } - - // Ensure ~/.claude directory exists - const claudeDir = path.dirname(MCP_CONFIG_PATH); - if (!fs.existsSync(claudeDir)) { - fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 }); - } - - // Write config with proper formatting - fs.writeFileSync(MCP_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); - - if (process.env.CCS_DEBUG) { - console.error(info(`Added MCP servers for web search: ${addedMcps.join(', ')}`)); - } - - return true; - } catch (error) { - if (process.env.CCS_DEBUG) { - console.error(warn(`Failed to configure MCP: ${(error as Error).message}`)); - } - return false; - } -} - -/** - * Check if MCP web-search is configured - * - * @returns true if any web search MCP is present - */ -export function hasMcpWebSearch(): boolean { - try { - if (!fs.existsSync(MCP_CONFIG_PATH)) { - return false; - } - - const content = fs.readFileSync(MCP_CONFIG_PATH, 'utf8'); - const config: McpConfig = JSON.parse(content); - - if (!config.mcpServers) { - return false; - } - - // Case-insensitive check for web search MCP - return Object.keys(config.mcpServers).some((key) => { - const lowerKey = key.toLowerCase(); - return ( - lowerKey.includes('web-search') || - lowerKey.includes('websearch') || - lowerKey.includes('tavily') || - lowerKey.includes('brave') - ); - }); - } catch { - return false; - } -} - -/** - * Get path to MCP config file - * - * @returns absolute path to .mcp.json - */ -export function getMcpConfigPath(): string { - return MCP_CONFIG_PATH; -} - -// 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 transformer hook to ~/.ccs/hooks/ - * - * 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(options?: { legacy?: boolean }): boolean { - try { - // Ensure hooks directory exists - if (!fs.existsSync(CCS_HOOKS_DIR)) { - fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 }); - } - - // 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/ - // In development: lib/hooks/ - const possiblePaths = [ - path.join(__dirname, '..', '..', 'lib', 'hooks', hookFileName), - path.join(__dirname, '..', 'lib', 'hooks', hookFileName), - ]; - - let sourcePath: string | null = null; - for (const p of possiblePaths) { - if (fs.existsSync(p)) { - sourcePath = p; - break; - } - } - - if (!sourcePath) { - if (process.env.CCS_DEBUG) { - console.error(warn(`WebSearch hook source not found: ${hookFileName}`)); - } - return false; - } - - // Copy hook to ~/.ccs/hooks/ - fs.copyFileSync(sourcePath, hookPath); - fs.chmodSync(hookPath, 0o755); - - // 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) { - console.error(warn(`Failed to install WebSearch hook: ${(error as Error).message}`)); - } - return false; - } -} - -/** - * Get WebSearch hook configuration for settings.json - * - * 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(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: [ - { - matcher: 'WebSearch', - hooks: [ - { - type: 'command', - command: `node "${hookPath}"`, - timeout: timeout, - }, - ], - }, - ], - }; -} - -/** - * Check if WebSearch hook is installed - * - * Checks for both the new Gemini transformer and legacy block hook. - * - * @returns true if any WebSearch hook exists - */ -export function hasWebSearchHook(): boolean { - 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); - } - - // Set search mode (sequential or parallel) - if (wsConfig.mode === 'parallel') { - env.CCS_WEBSEARCH_MODE = 'parallel'; - // Pass selected providers for parallel mode - if (wsConfig.selectedProviders && wsConfig.selectedProviders.length > 0) { - env.CCS_WEBSEARCH_PROVIDERS = wsConfig.selectedProviders.join(','); - } - } - - 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; - } -} - -// ========== Phase 1: MCP Status Functions ========== - -/** - * MCP web search status with ownership tracking - */ -export interface McpWebSearchStatus { - configured: boolean; - ccsManaged: string[]; // CCS-managed server names - userAdded: string[]; // User-added server names -} - -/** - * Get comprehensive MCP web search status - * - * Distinguishes CCS-managed vs user-added MCP servers - * based on _managedBy marker. - */ -export function getMcpWebSearchStatus(): McpWebSearchStatus { - const result: McpWebSearchStatus = { - configured: false, - ccsManaged: [], - userAdded: [], - }; - - try { - if (!fs.existsSync(MCP_CONFIG_PATH)) return result; - - const content = fs.readFileSync(MCP_CONFIG_PATH, 'utf8'); - const config: McpConfig = JSON.parse(content); - - if (!config.mcpServers) return result; - - for (const [name, server] of Object.entries(config.mcpServers)) { - // Check if it's a web search server - const lowerName = name.toLowerCase(); - const isWebSearch = - lowerName.includes('web-search') || - lowerName.includes('websearch') || - lowerName.includes('tavily') || - lowerName.includes('brave'); - - if (!isWebSearch) continue; - - if (server._managedBy === 'ccs') { - result.ccsManaged.push(name); - } else { - result.userAdded.push(name); - } - } - - result.configured = result.ccsManaged.length > 0 || result.userAdded.length > 0; - return result; - } catch { - return result; - } -} - -/** - * Check if CCS-managed web search MCP is configured - */ -export function hasCcsManagedWebSearch(): boolean { - const status = getMcpWebSearchStatus(); - return status.ccsManaged.length > 0; -} - -// ========== Phase 2: Gemini CLI Detection ========== - -import { execSync } from 'child_process'; - -/** - * Gemini CLI installation status - */ -export interface GeminiCliStatus { - installed: boolean; - path: string | null; - version: string | null; -} - -// Cache for Gemini CLI status (per process) -let geminiCliCache: GeminiCliStatus | null = null; - -/** - * Check if Gemini CLI is installed globally - * - * Requires global install: `npm install -g @google/gemini-cli` - * No npx fallback - must be in PATH - * - * @returns Gemini CLI status with path and version - */ -export function getGeminiCliStatus(): GeminiCliStatus { - // Return cached result if available - if (geminiCliCache) { - return geminiCliCache; - } - - const result: GeminiCliStatus = { - installed: false, - path: null, - version: null, - }; - - try { - const isWindows = process.platform === 'win32'; - const whichCmd = isWindows ? 'where gemini' : 'which gemini'; - - const pathResult = execSync(whichCmd, { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - const geminiPath = pathResult.trim().split('\n')[0]; // First result on Windows - - if (geminiPath) { - result.installed = true; - result.path = geminiPath; - - // Try to get version - try { - const versionResult = execSync('gemini --version', { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); - result.version = versionResult.trim(); - } catch { - // Version check failed, but CLI is installed - result.version = 'unknown'; - } - } - } catch { - // Command not found - Gemini CLI not installed - } - - // Cache result - geminiCliCache = result; - return result; -} - -/** - * Check if Gemini CLI is available (quick boolean check) - */ -export function hasGeminiCli(): boolean { - return getGeminiCliStatus().installed; -} - -/** - * Clear Gemini CLI cache (for testing or after installation) - */ -export function clearGeminiCliCache(): void { - geminiCliCache = null; -} - -// ========== Phase 3: WebSearch Readiness Status ========== - -/** - * WebSearch availability status for third-party profiles - */ -export type WebSearchReadiness = 'ready' | 'mcp-only' | 'unavailable'; - -/** - * WebSearch status for display - */ -export interface WebSearchStatus { - readiness: WebSearchReadiness; - geminiCli: boolean; - mcpConfigured: boolean; - message: string; -} - -/** - * Get WebSearch readiness status for display - * - * Called on third-party profile startup to inform user. - */ -export function getWebSearchReadiness(): WebSearchStatus { - const geminiCli = hasGeminiCli(); - const mcpStatus = getMcpWebSearchStatus(); - const mcpConfigured = mcpStatus.configured; - - if (geminiCli) { - return { - readiness: 'ready', - geminiCli: true, - mcpConfigured, - message: 'Ready (Gemini CLI)', - }; - } - - if (mcpConfigured) { - return { - readiness: 'mcp-only', - geminiCli: false, - mcpConfigured: true, - message: 'MCP fallback only', - }; - } - - return { - readiness: 'unavailable', - geminiCli: false, - mcpConfigured: false, - message: 'Unavailable (run: ccs config)', - }; -} - -/** - * Display WebSearch status (single line, equilibrium UX) - * - * Only call for third-party profiles. - */ -export function displayWebSearchStatus(): void { - const status = getWebSearchReadiness(); - - switch (status.readiness) { - case 'ready': - console.error(ok(`WebSearch: ${status.message}`)); - break; - case 'mcp-only': - console.error(info(`WebSearch: ${status.message}`)); - break; - case 'unavailable': - console.error(fail(`WebSearch: ${status.message}`)); - break; - } -} diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index c0af201b..56d1223b 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -6,6 +6,7 @@ import { spawn, ChildProcess } from 'child_process'; import { ErrorManager } from './error-manager'; +import { getWebSearchHookEnv } from './websearch-manager'; /** * Escape arguments for shell execution (Windows compatibility) @@ -25,8 +26,13 @@ export function execClaude( const isWindows = process.platform === 'win32'; const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); + // Get WebSearch hook config env vars + const webSearchEnv = getWebSearchHookEnv(); + // Prepare environment (merge with process.env if envVars provided) - const env = envVars ? { ...process.env, ...envVars } : process.env; + const env = envVars + ? { ...process.env, ...envVars, ...webSearchEnv } + : { ...process.env, ...webSearchEnv }; let child: ChildProcess; if (needsShell) { diff --git a/src/utils/websearch-manager.ts b/src/utils/websearch-manager.ts new file mode 100644 index 00000000..f350ae44 --- /dev/null +++ b/src/utils/websearch-manager.ts @@ -0,0 +1,632 @@ +/** + * WebSearch Manager - Manages WebSearch hook for CCS + * + * WebSearch is a server-side tool executed by Anthropic's API. + * Third-party providers (gemini, agy, codex, qwen) don't have access. + * This manager installs a hook that uses CLI tools (Gemini CLI) as fallback. + * + * Simplified Architecture: + * - No MCP complexity + * - Uses CLI tools (currently Gemini CLI) + * - Easy to extend for future CLI tools (opencode, etc.) + * + * @module utils/websearch-manager + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; +import { ok, info, warn, fail } from './ui'; +import { getWebSearchConfig } from '../config/unified-config-loader'; + +// CCS hooks directory +const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks'); + +// Hook file name +const WEBSEARCH_HOOK = 'websearch-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'); + +// ========== Gemini CLI Detection ========== + +/** + * Gemini CLI installation status + */ +export interface GeminiCliStatus { + installed: boolean; + path: string | null; + version: string | null; +} + +// Cache for Gemini CLI status (per process) +let geminiCliCache: GeminiCliStatus | null = null; + +/** + * Check if Gemini CLI is installed globally + * + * Requires global install: `npm install -g @google/gemini-cli` + * No npx fallback - must be in PATH + * + * @returns Gemini CLI status with path and version + */ +export function getGeminiCliStatus(): GeminiCliStatus { + // Return cached result if available + if (geminiCliCache) { + return geminiCliCache; + } + + const result: GeminiCliStatus = { + installed: false, + path: null, + version: null, + }; + + try { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where gemini' : 'which gemini'; + + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const geminiPath = pathResult.trim().split('\n')[0]; // First result on Windows + + if (geminiPath) { + result.installed = true; + result.path = geminiPath; + + // Try to get version + try { + const versionResult = execSync('gemini --version', { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + result.version = versionResult.trim(); + } catch { + // Version check failed, but CLI is installed + result.version = 'unknown'; + } + } + } catch { + // Command not found - Gemini CLI not installed + } + + // Cache result + geminiCliCache = result; + return result; +} + +/** + * Check if Gemini CLI is available (quick boolean check) + */ +export function hasGeminiCli(): boolean { + return getGeminiCliStatus().installed; +} + +/** + * Clear Gemini CLI cache (for testing or after installation) + */ +export function clearGeminiCliCache(): void { + geminiCliCache = null; +} + +// ========== Grok CLI Detection ========== + +/** + * Grok CLI installation status + */ +export interface GrokCliStatus { + installed: boolean; + path: string | null; + version: string | null; +} + +// Cache for Grok CLI status (per process) +let grokCliCache: GrokCliStatus | null = null; + +/** + * Check if Grok CLI is installed globally + * + * Grok CLI (grok-4-cli by lalomorales22) provides web search + X search. + * Requires: `npm install -g grok-cli` and XAI_API_KEY env var. + * + * @returns Grok CLI status with path and version + */ +export function getGrokCliStatus(): GrokCliStatus { + // Return cached result if available + if (grokCliCache) { + return grokCliCache; + } + + const result: GrokCliStatus = { + installed: false, + path: null, + version: null, + }; + + try { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where grok' : 'which grok'; + + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const grokPath = pathResult.trim().split('\n')[0]; // First result on Windows + + if (grokPath) { + result.installed = true; + result.path = grokPath; + + // Try to get version + try { + const versionResult = execSync('grok --version', { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + result.version = versionResult.trim(); + } catch { + // Version check failed, but CLI is installed + result.version = 'unknown'; + } + } + } catch { + // Command not found - Grok CLI not installed + } + + // Cache result + grokCliCache = result; + return result; +} + +/** + * Check if Grok CLI is available (quick boolean check) + */ +export function hasGrokCli(): boolean { + return getGrokCliStatus().installed; +} + +/** + * Clear Grok CLI cache (for testing or after installation) + */ +export function clearGrokCliCache(): void { + grokCliCache = null; +} + +/** + * Clear all CLI caches + */ +export function clearAllCliCaches(): void { + geminiCliCache = null; + grokCliCache = null; +} + +// ========== CLI Provider Info ========== + +/** + * WebSearch CLI provider information for health checks and UI + */ +export interface WebSearchCliInfo { + /** Provider ID */ + id: 'gemini' | 'grok'; + /** Display name */ + name: string; + /** CLI command name */ + command: string; + /** Whether CLI is installed */ + installed: boolean; + /** CLI version if installed */ + version: string | null; + /** Install command */ + installCommand: string; + /** Docs URL */ + docsUrl: string; + /** Whether this provider requires an API key */ + requiresApiKey: boolean; + /** API key environment variable name */ + apiKeyEnvVar?: string; + /** Brief description */ + description: string; + /** Free tier available? */ + freeTier: boolean; +} + +/** + * Get all WebSearch CLI providers with their status + */ +export function getWebSearchCliProviders(): WebSearchCliInfo[] { + const geminiStatus = getGeminiCliStatus(); + const grokStatus = getGrokCliStatus(); + + return [ + { + id: 'gemini', + name: 'Gemini CLI', + command: 'gemini', + installed: geminiStatus.installed, + version: geminiStatus.version, + installCommand: 'npm install -g @google/gemini-cli', + docsUrl: 'https://github.com/google-gemini/gemini-cli', + requiresApiKey: false, + description: 'Google Gemini with web search (FREE tier: 1000 req/day)', + freeTier: true, + }, + { + id: 'grok', + name: 'Grok CLI', + command: 'grok', + installed: grokStatus.installed, + version: grokStatus.version, + installCommand: 'npm install -g grok-cli', + docsUrl: 'https://github.com/lalomorales22/grok-4-cli', + requiresApiKey: true, + apiKeyEnvVar: 'XAI_API_KEY', + description: 'xAI Grok with web + X search (requires API key)', + freeTier: false, + }, + ]; +} + +/** + * Check if any WebSearch CLI is available + */ +export function hasAnyWebSearchCli(): boolean { + return hasGeminiCli() || hasGrokCli(); +} + +/** + * Get install hints for CLI-only users when no WebSearch CLI is installed + */ +export function getCliInstallHints(): string[] { + if (hasAnyWebSearchCli()) { + return []; + } + + return [ + '[i] WebSearch: No CLI tools installed', + ' Gemini CLI (FREE): npm i -g @google/gemini-cli', + ' Grok CLI (paid): npm i -g grok-cli', + ]; +} + +// ========== Hook Management ========== + +/** + * Install WebSearch hook to ~/.ccs/hooks/ + * + * This hook intercepts WebSearch and executes via Gemini CLI. + * + * @returns true if hook installed successfully + */ +export function installWebSearchHook(): boolean { + try { + const wsConfig = getWebSearchConfig(); + + // Skip if disabled + if (!wsConfig.enabled) { + if (process.env.CCS_DEBUG) { + console.error(info('WebSearch disabled - skipping hook install')); + } + return false; + } + + // 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, WEBSEARCH_HOOK); + + // Find the bundled hook script + // In npm package: node_modules/ccs/lib/hooks/ + // In development: lib/hooks/ + const possiblePaths = [ + path.join(__dirname, '..', '..', 'lib', 'hooks', WEBSEARCH_HOOK), + path.join(__dirname, '..', 'lib', 'hooks', WEBSEARCH_HOOK), + ]; + + let sourcePath: string | null = null; + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + sourcePath = p; + break; + } + } + + if (!sourcePath) { + if (process.env.CCS_DEBUG) { + console.error(warn(`WebSearch hook source not found: ${WEBSEARCH_HOOK}`)); + } + return false; + } + + // Copy hook to ~/.ccs/hooks/ + fs.copyFileSync(sourcePath, hookPath); + fs.chmodSync(hookPath, 0o755); + + if (process.env.CCS_DEBUG) { + console.error(info(`Installed WebSearch hook: ${hookPath}`)); + } + + // Ensure hook is configured in settings.json + ensureHookConfig(); + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to install WebSearch hook: ${(error as Error).message}`)); + } + return false; + } +} + +/** + * Check if WebSearch hook is installed + */ +export function hasWebSearchHook(): boolean { + const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK); + return fs.existsSync(hookPath); +} + +/** + * Get WebSearch hook configuration for settings.json + */ +export function getWebSearchHookConfig(): Record { + const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK); + + return { + PreToolUse: [ + { + matcher: 'WebSearch', + hooks: [ + { + type: 'command', + command: `node "${hookPath}"`, + timeout: HOOK_TIMEOUT_SECONDS, + }, + ], + }, + ], + }; +} + +/** + * Ensure WebSearch hook is configured in ~/.claude/settings.json + */ +function ensureHookConfig(): boolean { + try { + const wsConfig = getWebSearchConfig(); + + 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 { + 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; + } +} + +// ========== Environment Variables for Hook ========== + +/** + * Get environment variables for WebSearch hook configuration. + * + * Simple env vars - hook reads these to control behavior. + * + * @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; + } + + // Pass simple config to hook + env.CCS_WEBSEARCH_ENABLED = '1'; + env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers?.gemini?.timeout || 55); + + return env; +} + +// ========== WebSearch Readiness Status ========== + +/** + * WebSearch availability status for third-party profiles + */ +export type WebSearchReadiness = 'ready' | 'unavailable'; + +/** + * WebSearch status for display + */ +export interface WebSearchStatus { + readiness: WebSearchReadiness; + geminiCli: boolean; + grokCli: boolean; + message: string; +} + +/** + * Get WebSearch readiness status for display + * + * Called on third-party profile startup to inform user. + */ +export function getWebSearchReadiness(): WebSearchStatus { + const wsConfig = getWebSearchConfig(); + + // Check if WebSearch is disabled entirely + if (!wsConfig.enabled) { + return { + readiness: 'unavailable', + geminiCli: false, + grokCli: false, + message: 'Disabled in config', + }; + } + + // Check both CLIs + const geminiInstalled = hasGeminiCli(); + const grokInstalled = hasGrokCli(); + + if (geminiInstalled && grokInstalled) { + return { + readiness: 'ready', + geminiCli: true, + grokCli: true, + message: 'Ready (Gemini + Grok CLI)', + }; + } + + if (geminiInstalled) { + return { + readiness: 'ready', + geminiCli: true, + grokCli: false, + message: 'Ready (Gemini CLI)', + }; + } + + if (grokInstalled) { + return { + readiness: 'ready', + geminiCli: false, + grokCli: true, + message: 'Ready (Grok CLI)', + }; + } + + return { + readiness: 'unavailable', + geminiCli: false, + grokCli: false, + message: 'Install: npm i -g @google/gemini-cli', + }; +} + +/** + * Display WebSearch status (single line, equilibrium UX) + * + * Only call for third-party profiles. + * Shows detailed install hints when no CLI is installed. + */ +export function displayWebSearchStatus(): void { + const status = getWebSearchReadiness(); + + switch (status.readiness) { + case 'ready': + console.error(ok(`WebSearch: ${status.message}`)); + break; + case 'unavailable': + console.error(fail(`WebSearch: ${status.message}`)); + // Show install hints for CLI-only users + const hints = getCliInstallHints(); + if (hints.length > 0) { + for (const hint of hints) { + console.error(info(hint)); + } + } + break; + } +} + +// ========== Backward Compatibility Exports ========== +// These are kept for imports that haven't been updated yet + +/** + * @deprecated Use installWebSearchHook instead - MCP is no longer used + */ +export function ensureMcpWebSearch(): boolean { + // No-op - MCP is no longer used + return false; +} + +/** + * @deprecated MCP is no longer used + */ +export function hasMcpWebSearch(): boolean { + return false; +} + +/** + * @deprecated MCP is no longer used + */ +export function getMcpConfigPath(): string { + return path.join(os.homedir(), '.claude', '.mcp.json'); +} diff --git a/src/web-server/health-service.ts b/src/web-server/health-service.ts index 5219f904..8403dcaa 100644 --- a/src/web-server/health-service.ts +++ b/src/web-server/health-service.ts @@ -23,6 +23,7 @@ import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; import packageJson from '../../package.json'; import { getEnvironmentDiagnostics } from '../management/environment-diagnostics'; import { checkAuthCodePorts } from '../management/oauth-port-diagnostics'; +import { getWebSearchCliProviders, hasAnyWebSearchCli } from '../utils/websearch-manager'; export interface HealthCheck { id: string; @@ -136,6 +137,16 @@ export async function runHealthChecks(): Promise { checks: oauthReadinessChecks, }); + // Group 8: WebSearch CLI Providers + const websearchChecks: HealthCheck[] = []; + websearchChecks.push(...checkWebSearchClis()); + groups.push({ + id: 'websearch', + name: 'WebSearch', + icon: 'Search', + checks: websearchChecks, + }); + // Flatten all checks for backward compatibility const allChecks = groups.flatMap((g) => g.checks); @@ -816,6 +827,49 @@ async function checkOAuthPortsForDashboard(): Promise { }); } +// Check 18: WebSearch CLI Providers (Gemini CLI, Grok CLI) +function checkWebSearchClis(): HealthCheck[] { + const providers = getWebSearchCliProviders(); + const checks: HealthCheck[] = []; + + for (const provider of providers) { + if (provider.installed) { + const freeTag = provider.freeTier ? ' (FREE)' : ''; + checks.push({ + id: `websearch-${provider.id}`, + name: provider.name, + status: 'ok', + message: `v${provider.version || 'unknown'}${freeTag}`, + details: provider.description, + }); + } else { + const keyNote = provider.requiresApiKey ? ` (needs ${provider.apiKeyEnvVar})` : ' (FREE)'; + checks.push({ + id: `websearch-${provider.id}`, + name: provider.name, + status: 'info', + message: `Not installed${keyNote}`, + fix: provider.installCommand, + details: provider.description, + }); + } + } + + // Add summary check if no providers installed + if (!hasAnyWebSearchCli()) { + checks.push({ + id: 'websearch-summary', + name: 'WebSearch Status', + status: 'warning', + message: 'No CLI tools installed', + fix: 'npm install -g @google/gemini-cli (FREE)', + details: 'Install a WebSearch CLI for real-time web access', + }); + } + + return checks; +} + /** * Fix a health issue by its check ID */ diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 7b2146c1..03145707 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -60,9 +60,9 @@ import { isUnifiedConfig } from '../config/unified-config-types'; import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys'; import { getWebSearchReadiness, - getMcpWebSearchStatus, getGeminiCliStatus, -} from '../utils/mcp-manager'; + getGrokCliStatus, +} from '../utils/websearch-manager'; export const apiRoutes = Router(); @@ -1440,19 +1440,11 @@ apiRoutes.get('/websearch', (_req: Request, res: Response): void => { /** * PUT /api/websearch - Update WebSearch configuration - * Body: WebSearchConfig fields (enabled, provider, fallback, gemini, mode, selectedProviders, customMcp) + * Body: WebSearchConfig fields (enabled, providers) + * Dashboard is the source of truth for provider selection. */ apiRoutes.put('/websearch', (req: Request, res: Response): void => { - const { - enabled, - provider, - fallback, - webSearchPrimeUrl, - gemini, - mode, - selectedProviders, - customMcp, - } = req.body as Partial; + const { enabled, providers } = req.body as Partial; // Validate enabled if (enabled !== undefined && typeof enabled !== 'boolean') { @@ -1460,45 +1452,9 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => { return; } - // Validate fallback - if (fallback !== undefined && typeof fallback !== 'boolean') { - res.status(400).json({ error: 'Invalid value for fallback. Must be a boolean.' }); - return; - } - - // Validate provider if specified - const validProviders = ['auto', 'web-search-prime', 'brave', 'tavily']; - if (provider && !validProviders.includes(provider)) { - res.status(400).json({ - error: `Invalid provider. Must be one of: ${validProviders.join(', ')}`, - }); - return; - } - - // Validate webSearchPrimeUrl if specified - if (webSearchPrimeUrl !== undefined && typeof webSearchPrimeUrl !== 'string') { - res.status(400).json({ error: 'Invalid value for webSearchPrimeUrl. Must be a string.' }); - return; - } - - // Validate mode if specified - const validModes = ['sequential', 'parallel']; - if (mode && !validModes.includes(mode)) { - res.status(400).json({ - error: `Invalid mode. Must be one of: ${validModes.join(', ')}`, - }); - return; - } - - // Validate selectedProviders if specified - if (selectedProviders !== undefined && !Array.isArray(selectedProviders)) { - res.status(400).json({ error: 'Invalid value for selectedProviders. Must be an array.' }); - return; - } - - // Validate customMcp if specified - if (customMcp !== undefined && !Array.isArray(customMcp)) { - res.status(400).json({ error: 'Invalid value for customMcp. Must be an array.' }); + // Validate providers if specified + if (providers !== undefined && typeof providers !== 'object') { + res.status(400).json({ error: 'Invalid value for providers. Must be an object.' }); return; } @@ -1510,16 +1466,23 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => { return; } - // Merge updates - preserve all existing fields + // Merge updates - simple structure (Gemini CLI only for now) existingConfig.websearch = { enabled: enabled ?? existingConfig.websearch?.enabled ?? true, - provider: provider ?? existingConfig.websearch?.provider ?? 'auto', - fallback: fallback ?? existingConfig.websearch?.fallback ?? true, - webSearchPrimeUrl: webSearchPrimeUrl ?? existingConfig.websearch?.webSearchPrimeUrl, - gemini: gemini ?? existingConfig.websearch?.gemini ?? { enabled: true, timeout: 55 }, - mode: mode ?? existingConfig.websearch?.mode ?? 'sequential', - selectedProviders: selectedProviders ?? existingConfig.websearch?.selectedProviders ?? [], - customMcp: customMcp ?? existingConfig.websearch?.customMcp ?? [], + providers: providers + ? { + gemini: { + enabled: + providers.gemini?.enabled ?? + existingConfig.websearch?.providers?.gemini?.enabled ?? + true, + timeout: + providers.gemini?.timeout ?? + existingConfig.websearch?.providers?.gemini?.timeout ?? + 55, + }, + } + : existingConfig.websearch?.providers, }; saveUnifiedConfig(existingConfig); @@ -1534,13 +1497,13 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => { }); /** - * GET /api/websearch/status - Get comprehensive WebSearch status - * Returns: { geminiCli, mcpServers, readiness } + * GET /api/websearch/status - Get WebSearch status + * Returns: { geminiCli, grokCli, readiness } */ apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => { try { const geminiCli = getGeminiCliStatus(); - const mcpStatus = getMcpWebSearchStatus(); + const grokCli = getGrokCliStatus(); const readiness = getWebSearchReadiness(); res.json({ @@ -1549,10 +1512,10 @@ apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => { path: geminiCli.path, version: geminiCli.version, }, - mcpServers: { - configured: mcpStatus.configured, - ccsManaged: mcpStatus.ccsManaged, - userAdded: mcpStatus.userAdded, + grokCli: { + installed: grokCli.installed, + path: grokCli.path, + version: grokCli.version, }, readiness: { status: readiness.readiness, diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index 48f6527c..a8b80e26 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -1,6 +1,6 @@ /** * Settings Page - WebSearch Configuration - * Simplified toggle-based UI: enable providers you want, we handle the rest + * Supports Gemini CLI and Grok CLI providers */ import { useState, useEffect } from 'react'; @@ -9,7 +9,6 @@ import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; -import { Label } from '@/components/ui/label'; import { Globe, RefreshCw, @@ -18,87 +17,42 @@ import { FileCode, Copy, Check, - Plus, - Trash2, GripVertical, - Zap, Terminal, - Server, + ExternalLink, } from 'lucide-react'; import { CodeEditor } from '@/components/code-editor'; -interface CustomMcpConfig { - name: string; - type: 'http' | 'stdio'; - url?: string; - headers?: Record; - command?: string; - args?: string[]; - env?: Record; +interface ProviderConfig { + enabled?: boolean; + timeout?: number; +} + +interface WebSearchProvidersConfig { + gemini?: ProviderConfig; + grok?: ProviderConfig; } interface WebSearchConfig { enabled: boolean; - provider: 'auto' | 'web-search-prime' | 'brave' | 'tavily'; - fallback: boolean; - gemini?: { - enabled: boolean; - timeout: number; - }; - mode?: 'sequential' | 'parallel'; - selectedProviders?: string[]; - customMcp?: CustomMcpConfig[]; + providers?: WebSearchProvidersConfig; +} + +interface CliStatus { + installed: boolean; + path: string | null; + version: string | null; } interface WebSearchStatus { - geminiCli: { - installed: boolean; - path: string | null; - version: string | null; - }; - mcpServers: { - configured: boolean; - ccsManaged: string[]; - userAdded: string[]; - }; + geminiCli: CliStatus; + grokCli: CliStatus; readiness: { - status: 'ready' | 'mcp-only' | 'unavailable'; + status: 'ready' | 'unavailable'; message: string; }; } -// Built-in providers with code names -const BUILTIN_PROVIDERS = [ - { - id: 'gemini', - name: 'gemini', - desc: 'Google Gemini CLI (OAuth)', - icon: Terminal, - isMcp: false, - }, - { - id: 'web-search-prime', - name: 'web-search-prime', - desc: 'z.ai MCP', - icon: Zap, - isMcp: true, - }, - { - id: 'brave-search', - name: 'brave-search', - desc: 'Brave Search MCP', - icon: Globe, - isMcp: true, - }, - { - id: 'tavily', - name: 'tavily', - desc: 'Tavily MCP', - icon: Globe, - isMcp: true, - }, -]; - export function SettingsPage() { const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); @@ -111,10 +65,6 @@ export function SettingsPage() { const [rawConfig, setRawConfig] = useState(null); const [rawConfigLoading, setRawConfigLoading] = useState(false); const [copied, setCopied] = useState(false); - // BYOM MCP form state - JSON paste - const [mcpJsonInput, setMcpJsonInput] = useState(''); - const [mcpJsonError, setMcpJsonError] = useState(null); - const [showAddMcp, setShowAddMcp] = useState(false); // Load config and status on mount useEffect(() => { @@ -181,173 +131,21 @@ export function SettingsPage() { } }; - // Get active providers from config - const getActiveProviders = (): string[] => { - const active: string[] = []; - if (config?.gemini?.enabled !== false) active.push('gemini'); - if (config?.selectedProviders) { - active.push(...config.selectedProviders.filter((p) => p !== 'gemini')); - } - return active; - }; - - // Toggle a provider - const toggleProvider = (providerId: string) => { - const current = getActiveProviders(); - let updated: string[]; - - if (current.includes(providerId)) { - updated = current.filter((p) => p !== providerId); - } else { - updated = [...current, providerId]; - } - - // Separate gemini from MCP providers - const geminiEnabled = updated.includes('gemini'); - const mcpProviders = updated.filter((p) => p !== 'gemini'); - - // Determine mode: parallel if multiple providers, sequential otherwise - const mode = updated.length > 1 ? 'parallel' : 'sequential'; + // Toggle Gemini provider + const toggleGemini = () => { + const providers = config?.providers || {}; + const currentState = providers.gemini?.enabled ?? false; + const grokState = providers.grok?.enabled ?? false; saveConfig({ - gemini: { - enabled: geminiEnabled, - timeout: config?.gemini?.timeout ?? 55, + enabled: !currentState || grokState, // Enable WebSearch if any provider is enabled + providers: { + ...providers, + gemini: { + ...providers.gemini, + enabled: !currentState, + }, }, - selectedProviders: updated, - mode, - // Auto-enable if any provider is on - enabled: updated.length > 0, - // Set primary provider - provider: (mcpProviders[0] as WebSearchConfig['provider']) || 'auto', - }); - }; - - // Parse JSON input and add custom MCP - const addCustomMcp = () => { - if (!mcpJsonInput.trim()) return; - setMcpJsonError(null); - - // Try to parse JSON - let parsed: unknown; - try { - parsed = JSON.parse(mcpJsonInput); - } catch (_err) { - // Check for common JSON issues - const input = mcpJsonInput.trim(); - if (input.includes('//')) { - setMcpJsonError('Remove comments (// ...) from JSON before pasting'); - } else if (!input.startsWith('{')) { - setMcpJsonError('JSON must start with { - check for extra characters'); - } else if (!input.endsWith('}')) { - setMcpJsonError('JSON must end with } - check for missing closing brace'); - } else { - setMcpJsonError('Invalid JSON syntax. Check for missing quotes, commas, or braces.'); - } - return; - } - - // Validate it's an object - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - setMcpJsonError('Expected a JSON object { ... }, not an array or primitive'); - return; - } - - const obj = parsed as Record; - const serversToAdd: CustomMcpConfig[] = []; - - // Format 1: { "mcpServers": { "name": { ... } } } - Full Claude/CCS format - if (obj.mcpServers && typeof obj.mcpServers === 'object') { - for (const [name, serverConfig] of Object.entries( - obj.mcpServers as Record - )) { - const cfg = serverConfig as Record; - if (cfg && typeof cfg === 'object') { - serversToAdd.push({ - name, - type: (cfg.type as 'http' | 'stdio') || 'http', - url: cfg.url as string, - headers: cfg.headers as Record, - command: cfg.command as string, - args: cfg.args as string[], - env: cfg.env as Record, - }); - } - } - } - // Format 2: { "type": "http", "url": "..." } - Single server without name - else if (obj.type && (obj.url || obj.command)) { - setMcpJsonError( - 'Missing server name. Use format:\n{ "your-server-name": { "type": "http", "url": "..." } }' - ); - return; - } - // Format 3: { "name": { ... } } - Direct server object (partial paste) - else { - for (const [name, value] of Object.entries(obj)) { - const cfg = value as Record; - if (cfg && typeof cfg === 'object' && !Array.isArray(cfg)) { - // Check if this looks like a server config - if (cfg.type || cfg.url || cfg.command) { - serversToAdd.push({ - name, - type: (cfg.type as 'http' | 'stdio') || 'http', - url: cfg.url as string, - headers: cfg.headers as Record, - command: cfg.command as string, - args: cfg.args as string[], - env: cfg.env as Record, - }); - } - } - } - } - - // Validate we found servers - if (serversToAdd.length === 0) { - setMcpJsonError( - 'No valid MCP server found. Expected format:\n{ "server-name": { "type": "http", "url": "..." } }' - ); - return; - } - - // Validate each server - for (const server of serversToAdd) { - if (!server.name) { - setMcpJsonError('Server name is required'); - return; - } - if (server.type === 'http' && !server.url) { - setMcpJsonError(`"${server.name}" is missing "url" field`); - return; - } - if (server.type === 'stdio' && !server.command) { - setMcpJsonError(`"${server.name}" is missing "command" field`); - return; - } - } - - // Check for duplicates - const existing = config?.customMcp || []; - const duplicates = serversToAdd.filter((s) => existing.some((e) => e.name === s.name)); - if (duplicates.length > 0) { - setMcpJsonError(`Server "${duplicates[0].name}" already exists`); - return; - } - - // Success - add the servers - saveConfig({ customMcp: [...existing, ...serversToAdd] }); - setMcpJsonInput(''); - setShowAddMcp(false); - }; - - const removeCustomMcp = (name: string) => { - const current = config?.customMcp || []; - // Also remove from selectedProviders - const selected = config?.selectedProviders || []; - saveConfig({ - customMcp: current.filter((m) => m.name !== name), - selectedProviders: selected.filter((p) => p !== name), }); }; @@ -392,8 +190,25 @@ export function SettingsPage() { } }; - const activeProviders = config ? getActiveProviders() : []; - const activeCount = activeProviders.length; + const isGeminiEnabled = config?.providers?.gemini?.enabled ?? false; + const isGrokEnabled = config?.providers?.grok?.enabled ?? false; + + // Toggle Grok provider + const toggleGrok = () => { + const providers = config?.providers || {}; + const currentState = providers.grok?.enabled ?? false; + + saveConfig({ + enabled: isGeminiEnabled || !currentState, // Enable WebSearch if any provider is enabled + providers: { + ...providers, + grok: { + ...providers.grok, + enabled: !currentState, + }, + }, + }); + }; if (loading) { return ( @@ -419,7 +234,7 @@ export function SettingsPage() {

WebSearch

- Toggle providers • Multiple = parallel + CLI-based web search for third-party profiles

@@ -454,9 +269,7 @@ export function SettingsPage() {

- {activeCount === 0 && 'No providers enabled'} - {activeCount === 1 && '1 provider active'} - {activeCount > 1 && `${activeCount} providers (parallel)`} + {isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'}

{statusLoading ? (

Checking status...

@@ -469,180 +282,139 @@ export function SettingsPage() {
- {/* Built-in Providers */} + {/* CLI Providers */}

Providers

-
- {BUILTIN_PROVIDERS.map((provider) => { - const isActive = activeProviders.includes(provider.id); - const Icon = provider.icon; - const isGemini = provider.id === 'gemini'; - const isInstalled = isGemini - ? status?.geminiCli.installed - : provider.isMcp - ? status?.mcpServers.ccsManaged.includes(provider.id) || - status?.mcpServers.userAdded.includes(provider.id) - : true; - return ( -
-
- -
-
-

{provider.name}

- {isInstalled ? ( - - configured - - ) : ( - - not configured - - )} -
-

{provider.desc}

-
-
- toggleProvider(provider.id)} - disabled={saving || !isInstalled} - /> -
- ); - })} -
-
- - {/* Custom MCPs */} -
-
-

Custom MCPs

- -
- - {showAddMcp && ( -
+ {/* Gemini CLI Provider */} +
+
+
- -

- Paste the JSON config from your MCP provider docs +

+

gemini

+ + FREE + + {status?.geminiCli?.installed ? ( + + installed + + ) : ( + + not installed + + )} +
+

+ Google Gemini CLI (1000 req/day free)

-