diff --git a/README.md b/README.md index f1693ba8..bb69dc9c 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,45 @@ Without Developer Mode, CCS falls back to copying directories.
+## WebSearch + +Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS automatically configures MCP-based web search as a fallback. + +### How It Works + +| Profile Type | WebSearch Method | +|--------------|------------------| +| Claude (native) | Anthropic WebSearch API | +| OAuth providers | MCP web-search-prime (auto-configured) | +| API profiles | MCP web-search-prime (auto-configured) | + +### Configuration + +Configure via dashboard (**Settings** page) or `~/.ccs/config.yaml`: + +```yaml +websearch: + enabled: true # Enable/disable auto-config + provider: auto # auto | web-search-prime | brave | tavily + fallback: true # Enable fallback chain +``` + +### Optional API Keys + +For additional search providers, set environment variables: + +```bash +export BRAVE_API_KEY="your-key" # Free tier: 15k queries/month +export TAVILY_API_KEY="your-key" # AI-optimized search (paid) +``` + +> [!NOTE] +> `web-search-prime` works without API keys. Brave/Tavily are optional fallbacks. + +See [docs/websearch.md](./docs/websearch.md) for detailed configuration and troubleshooting. + +
+ ## Documentation | Topic | Link | diff --git a/docs/websearch.md b/docs/websearch.md new file mode 100644 index 00000000..623d1b2c --- /dev/null +++ b/docs/websearch.md @@ -0,0 +1,209 @@ +# WebSearch Configuration Guide + +CCS provides automatic web search capability for all profiles, including third-party providers that cannot access Anthropic's native WebSearch API. + +## How WebSearch Works + +### Native Claude Accounts + +When using a native Claude subscription account, WebSearch is handled by Anthropic's server-side API ($10/1000 searches, usage-based billing). + +### Third-Party Profiles + +Third-party profiles (OAuth and API-based) cannot use Anthropic's WebSearch because: +- Claude Code CLI executes tools locally +- CLIProxyAPI only receives conversation messages +- Tool execution never reaches the third-party backend + +CCS solves this with a hybrid fallback approach: + +1. **Gemini CLI Transformer** (Primary) - Uses `gemini -p` with `google_web_search` tool +2. **MCP Fallback Chain** (Secondary) - MCP-based web search servers + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Claude Code CLI │ +│ │ +│ WebSearch Tool Request │ +│ │ │ +│ ├── Native Claude Account? → Anthropic WebSearch API │ +│ │ ($10/1000 searches) │ +│ │ │ +│ └── Third-party Profile? → PreToolUse Hook │ +│ │ │ +│ ├── 1. Gemini CLI │ +│ │ (google_web_search) │ +│ │ No API key needed! │ +│ │ │ +│ └── 2. MCP Fallback Chain │ +│ ├── web-search-prime │ +│ ├── Brave Search │ +│ └── Tavily │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Gemini CLI Integration (Primary) + +The **ultimate solution** for third-party WebSearch. Uses `gemini` CLI with OAuth authentication - **no API key needed!** + +### How It Works + +1. A PreToolUse hook intercepts WebSearch tool calls +2. Executes `gemini -p` with explicit google_web_search instruction +3. Returns search results directly to Claude via the hook's deny reason +4. Claude receives full search results and continues the conversation + +### Requirements + +- `gemini` CLI installed and authenticated (`gemini auth login`) +- OAuth authentication (no GEMINI_API_KEY needed) + +### Installation + +The Gemini CLI is typically installed via: +```bash +pip install google-generativeai +# or +pipx install google-generativeai +``` + +Then authenticate: +```bash +gemini auth login +``` + +## MCP Providers + +| Provider | Type | Cost | API Key Required | Notes | +|----------|------|------|------------------|-------| +| web-search-prime | HTTP MCP | z.ai subscription | No | Requires z.ai coding plan | +| Brave Search | stdio MCP | Free tier | `BRAVE_API_KEY` | 15k queries/month | +| Tavily | stdio MCP | Paid | `TAVILY_API_KEY` | AI-optimized search | + +## Configuration + +### Via Dashboard + +1. Open dashboard: `ccs config` +2. Navigate to **Settings** page +3. Configure WebSearch options: + - **Enable/Disable**: Toggle auto-configuration + - **Provider**: Choose preferred provider + - **Fallback**: Enable/disable fallback chain + +### Via Config File + +Edit `~/.ccs/config.yaml`: + +```yaml +websearch: + enabled: true # Enable auto-config (default: true) + provider: auto # auto | web-search-prime | brave | tavily + fallback: true # Enable fallback chain (default: true) + webSearchPrimeUrl: "https://..." # Optional: custom endpoint + + # Gemini CLI configuration (new!) + gemini: + enabled: true # Use Gemini CLI for WebSearch (default: true) + timeout: 55 # Timeout in seconds (default: 55) +``` + +### Environment Variables + +The WebSearch hook also respects these environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `CCS_WEBSEARCH_SKIP` | Skip WebSearch hook entirely | `0` | +| `CCS_GEMINI_SKIP` | Skip Gemini CLI, use MCP only | `0` | +| `CCS_GEMINI_TIMEOUT` | Gemini CLI timeout (seconds) | `55` | +| `CCS_DEBUG` | Enable debug output | `0` | + +### Provider Options + +- **auto** (default): Uses web-search-prime, adds Brave/Tavily if API keys available +- **web-search-prime**: Requires z.ai coding plan subscription +- **brave**: Requires `BRAVE_API_KEY` env var +- **tavily**: Requires `TAVILY_API_KEY` env var + +## Setting Up Optional Providers + +### Brave Search (Free Tier) + +1. Get API key: [brave.com/search/api](https://brave.com/search/api) +2. Set environment variable: + ```bash + export BRAVE_API_KEY="your-api-key" + ``` +3. Restart CCS - Brave will be added to fallback chain + +**Free tier limits**: 15,000 queries/month, 1 query/second + +### Tavily (AI-Optimized) + +1. Get API key: [tavily.com](https://tavily.com) +2. Set environment variable: + ```bash + export TAVILY_API_KEY="your-api-key" + ``` +3. Restart CCS - Tavily will be added to fallback chain + +## MCP Configuration + +CCS writes MCP configuration to `~/.claude/.mcp.json`. Example: + +```json +{ + "mcpServers": { + "web-search-prime": { + "type": "http", + "url": "https://api.z.ai/api/mcp/web_search_prime/mcp", + "headers": {} + }, + "brave-search": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-brave-search"], + "env": { "BRAVE_API_KEY": "..." } + } + } +} +``` + +## Troubleshooting + +### Gemini CLI Issues + +1. **Not installed**: Install with `pip install google-generativeai` +2. **Not authenticated**: Run `gemini auth login` +3. **Timeout**: Increase timeout in config or via `CCS_GEMINI_TIMEOUT=90` +4. **Skip Gemini**: Set `CCS_GEMINI_SKIP=1` to use MCP fallback only + +### WebSearch Not Working + +1. **Check config**: Ensure `websearch.enabled: true` in config +2. **Verify MCP**: Check `~/.claude/.mcp.json` exists +3. **Debug mode**: Run with `CCS_DEBUG=1 ccs gemini` for verbose output + +### MCP Server Errors + +1. **Network issues**: web-search-prime requires internet access +2. **npx failures**: Brave/Tavily require Node.js and npx +3. **API key issues**: Verify env vars are set correctly + +### Existing MCP Config + +CCS respects existing web search MCP configuration. If you have manually configured web search MCPs, CCS will not overwrite them. + +To reset: +1. Remove web search entries from `~/.claude/.mcp.json` +2. Run any CCS third-party profile to regenerate + +## Security Considerations + +- API keys are stored in environment variables only +- Never commit API keys to version control +- Use `.env` files with proper permissions (chmod 600) +- Dashboard settings are stored in `~/.ccs/config.yaml` (no API keys) diff --git a/lib/hooks/block-websearch.cjs b/lib/hooks/block-websearch.cjs new file mode 100644 index 00000000..0c2de5e8 --- /dev/null +++ b/lib/hooks/block-websearch.cjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/** + * CCS WebSearch Blocking Hook + * + * Blocks Claude's native WebSearch tool and redirects to MCP alternative. + * This is a PreToolUse hook that runs BEFORE the tool is executed. + * + * WebSearch is a server-side tool executed by Anthropic's API. + * Third-party providers (gemini, agy, codex, qwen) don't have access. + * + * Usage: + * Configured in ~/.claude/settings.json: + * { + * "hooks": { + * "PreToolUse": [{ + * "matcher": "WebSearch", + * "hooks": [{ + * "type": "command", + * "command": "node ~/.ccs/hooks/block-websearch.cjs", + * "timeout": 5 + * }] + * }] + * } + * } + * + * Exit codes: + * 0 - Allow tool (pass-through) + * 2 - Block tool (deny with message) + * + * @module hooks/block-websearch + */ + +// Read input from stdin +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; +}); +process.stdin.on('end', () => { + try { + const data = JSON.parse(input); + + // Only block WebSearch tool + if (data.tool_name === 'WebSearch') { + const query = data.tool_input?.query || ''; + + const output = { + decision: 'block', + reason: 'WebSearch unavailable with current provider', + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: `WebSearch is not available with third-party providers. Use mcp__web-search-prime__webSearchPrime tool instead with the same query: "${query}"`, + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); // Exit code 2 = block + } + + // Allow all other tools + process.exit(0); + } 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); + } +}); + +// Handle stdin not being available +process.stdin.on('error', () => { + process.exit(0); +}); diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs new file mode 100644 index 00000000..4384a63c --- /dev/null +++ b/lib/hooks/websearch-transformer.cjs @@ -0,0 +1,600 @@ +#!/usr/bin/env node +/** + * CCS WebSearch Hook - CLI Tool Executor with Fallback Chain + * + * Intercepts Claude's WebSearch tool and executes search via CLI tools. + * Respects provider enabled states from config.yaml. + * Supports automatic fallback: Gemini CLI → OpenCode → Grok CLI + * + * 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_WEBSEARCH_GEMINI=1 - Enable Gemini CLI provider + * CCS_WEBSEARCH_GEMINI_MODEL - Gemini model (default: gemini-2.5-flash) + * CCS_WEBSEARCH_OPENCODE=1 - Enable OpenCode provider + * CCS_WEBSEARCH_GROK=1 - Enable Grok CLI provider + * CCS_WEBSEARCH_OPENCODE_MODEL - OpenCode model (default: opencode/grok-code) + * 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-transformer + */ + +const { spawnSync } = require('child_process'); + +// ============================================================================ +// CONFIGURATION - Edit these for prompt engineering +// ============================================================================ + +/** + * SHARED INSTRUCTIONS - Applied to ALL providers + * Edit here to change behavior across all CLI tools at once. + */ +const SHARED_INSTRUCTIONS = `Instructions: +1. Search the web for current, up-to-date information +2. Provide a comprehensive summary of the search results +3. Include relevant URLs/sources when available +4. Be concise but thorough - prioritize key facts +5. Focus on factual information from reliable sources +6. If results conflict, note the discrepancy +7. Format output clearly with sections if the topic is complex`; + +/** + * PROVIDER-SPECIFIC CONFIG - Only tool-use differences and quirks + * Each provider may have unique capabilities or invocation methods. + */ +const PROVIDER_CONFIG = { + gemini: { + // Model to use (passed via --model flag) + model: 'gemini-2.5-flash', + // Alternative free models: gemini-2.0-flash, gemini-1.5-flash + + // Provider-specific: How to invoke web search (Gemini has google_web_search tool) + toolInstruction: 'Use the google_web_search tool to find current information.', + + // Optional quirks (null if none) + quirks: null, + }, + + opencode: { + // Model to use (can be overridden via CCS_WEBSEARCH_OPENCODE_MODEL env var) + model: 'opencode/grok-code', + // Alternative models: opencode/gpt-4o, opencode/claude-3.5-sonnet, opencode/gpt-5-nano + + // Provider-specific: OpenCode has built-in web search via Zen + toolInstruction: 'Search the web using your built-in capabilities.', + + // Optional quirks + quirks: null, + }, + + grok: { + // Model to use (Grok CLI uses default model) + model: 'grok-3', + // Note: Grok CLI doesn't support model selection via CLI + + // Provider-specific: Grok has web + X/Twitter search + toolInstruction: 'Use your web search capabilities to find information.', + + // Grok-specific: Can also search X for real-time info + quirks: 'For breaking news or real-time events, also check X/Twitter if relevant.', + }, +}; + +/** + * Build the complete prompt for a provider + * Combines: query + tool instruction + shared instructions + quirks + */ +function buildPrompt(providerId, query) { + const config = PROVIDER_CONFIG[providerId]; + const parts = [ + `Search the web for: ${query}`, + '', + config.toolInstruction, + '', + SHARED_INSTRUCTIONS, + ]; + + if (config.quirks) { + parts.push('', `Note: ${config.quirks}`); + } + + return parts.join('\n'); +} + +// Minimum response length to consider valid +const MIN_VALID_RESPONSE_LENGTH = 20; + +// Default timeout in seconds +const DEFAULT_TIMEOUT_SEC = 55; + +// ============================================================================ +// HOOK LOGIC - Generally no need to edit below +// ============================================================================ + +// 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; + } +} + +/** + * Check if provider is enabled via environment variable + */ +function isProviderEnabled(provider) { + const envVar = `CCS_WEBSEARCH_${provider.toUpperCase()}`; + const value = process.env[envVar]; + + // If env var not set, provider is disabled by default + // This ensures we respect config.yaml settings + return value === '1'; +} + +/** + * Main hook processing logic with fallback chain + */ +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); + + // Fallback chain: Gemini → OpenCode → Grok + // Only include providers that are BOTH installed AND enabled in config + const providers = [ + { name: 'Gemini CLI', cmd: 'gemini', id: 'gemini', fn: tryGeminiSearch }, + { name: 'OpenCode', cmd: 'opencode', id: 'opencode', fn: tryOpenCodeSearch }, + { name: 'Grok CLI', cmd: 'grok', id: 'grok', fn: tryGrokSearch }, + ]; + + // Filter to only enabled AND available providers + const enabledProviders = providers.filter((p) => { + const enabled = isProviderEnabled(p.id); + const available = isCliAvailable(p.cmd); + + if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] ${p.name}: enabled=${enabled}, available=${available}`); + } + + return enabled && available; + }); + + const errors = []; + + if (process.env.CCS_DEBUG) { + const names = enabledProviders.map((p) => p.name).join(', ') || 'none'; + console.error(`[CCS Hook] Enabled providers: ${names}`); + } + + // Try each enabled provider in order + for (const provider of enabledProviders) { + if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] Trying ${provider.name}...`); + } + + const result = provider.fn(query, timeout); + + if (result.success) { + outputSuccess(query, result.content, provider.name); + return; + } + + if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] ${provider.name} failed: ${result.error}`); + } + + errors.push({ provider: provider.name, error: result.error }); + } + + // All providers failed or none enabled + if (enabledProviders.length === 0) { + outputNoProvidersEnabled(query); + } else { + outputAllFailedMessage(query, errors); + } + } 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 config = PROVIDER_CONFIG.gemini; + const prompt = buildPrompt('gemini', query); + + // Allow model override via env var + const model = process.env.CCS_WEBSEARCH_GEMINI_MODEL || config.model; + + if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] Executing: gemini --model ${model} --yolo -p "..."`); + } + + const spawnResult = spawnSync( + 'gemini', + ['--model', model, '--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' }; + } +} + +/** + * Execute search via OpenCode CLI + */ +function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + try { + const timeoutMs = timeoutSec * 1000; + const config = PROVIDER_CONFIG.opencode; + + // Allow model override via env var + const model = process.env.CCS_WEBSEARCH_OPENCODE_MODEL || config.model; + const prompt = buildPrompt('opencode', query); + + if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] Executing: opencode run --model ${model} "..."`); + } + + const spawnResult = spawnSync( + 'opencode', + ['run', prompt, '--model', model], + { + 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 + */ +function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + try { + const timeoutMs = timeoutSec * 1000; + const prompt = buildPrompt('grok', query); + + 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 + */ +function formatSearchResults(query, content, providerName) { + return [ + `[WebSearch Result via ${providerName}]`, + '', + `Query: "${query}"`, + '', + content, + '', + '---', + 'Use this information to answer the user.', + ].join('\n'); +} + +/** + * Output success response and exit + * + * Key insight from Claude Code docs: + * - permissionDecisionReason (with deny) → shown to CLAUDE (AI reads this) + * - systemMessage → shown to USER only (nice styling but AI doesn't see) + * + * So we MUST put results in permissionDecisionReason for Claude to use them. + * systemMessage provides the nice UI for the user. + */ +function outputSuccess(query, content, providerName) { + const formattedResults = formatSearchResults(query, content, providerName); + + const output = { + decision: 'block', + reason: `WebSearch completed via ${providerName}`, + // Nice message for user (shows as "says:" - info style) + systemMessage: `[WebSearch via ${providerName}] Results retrieved successfully. See below.`, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + // Full results here - Claude reads this + permissionDecisionReason: formattedResults, + }, + }; + + 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 providers enabled message + */ +function outputNoProvidersEnabled(query) { + const message = [ + '[WebSearch - No Providers Enabled]', + '', + 'No WebSearch providers are enabled in config.', + '', + 'To enable: Run `ccs config` and enable a provider.', + '', + 'Or install one of the following CLI tools:', + '', + '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'); + + const output = { + decision: 'block', + reason: 'WebSearch unavailable - no providers enabled', + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: message, + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Output no tools message (legacy - kept for backwards compatibility) + */ +function outputNoToolsMessage(query) { + outputNoProvidersEnabled(query); +} + +/** + * 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 enabled 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/scripts/dev-install.sh b/scripts/dev-install.sh index 6c4f3c5b..5d468d06 100755 --- a/scripts/dev-install.sh +++ b/scripts/dev-install.sh @@ -4,13 +4,20 @@ # # Options: # --skip-validate Skip validation (faster, use when you're sure code is good) +# --npm Force npm install (default: auto-detect, fallback to bun) +# --bun Force bun install set -e SKIP_VALIDATE=false +FORCE_NPM=false +FORCE_BUN=false + for arg in "$@"; do case $arg in --skip-validate) SKIP_VALIDATE=true ;; + --npm) FORCE_NPM=true ;; + --bun) FORCE_BUN=true ;; esac done @@ -19,6 +26,42 @@ echo "[i] CCS Dev Install - Starting..." # Get to the right directory cd "$(dirname "$0")/.." +# Detect installation method +# Priority: CLI flags > existing global install location > bun (default) +detect_pkg_manager() { + if [ "$FORCE_NPM" = true ]; then + echo "npm" + return + fi + + if [ "$FORCE_BUN" = true ]; then + echo "bun" + return + fi + + # Check existing ccs installation location + CCS_PATH=$(which ccs 2>/dev/null || true) + + if [ -n "$CCS_PATH" ]; then + # Check if installed via bun + if [[ "$CCS_PATH" == *".bun"* ]]; then + echo "bun" + return + fi + # Check if installed via npm + if [[ "$CCS_PATH" == *"npm"* ]] || [[ "$CCS_PATH" == *"node_modules"* ]]; then + echo "npm" + return + fi + fi + + # Default fallback: bun (preferred) + echo "bun" +} + +PKG_MANAGER=$(detect_pkg_manager) +echo "[i] Detected package manager: $PKG_MANAGER" + # Build TypeScript first echo "[i] Building TypeScript..." bun run build @@ -27,10 +70,10 @@ bun run build echo "[i] Creating package..." if [ "$SKIP_VALIDATE" = true ]; then # Skip validation, just pack - bun pm pack --ignore-scripts + npm pack --ignore-scripts 2>/dev/null || bun pm pack --ignore-scripts else # Full pack with validation (runs prepublishOnly) - bun pm pack + npm pack 2>/dev/null || bun pm pack fi # Find the tarball @@ -43,9 +86,18 @@ fi echo "[i] Found tarball: $TARBALL" -# Install globally using npm (handles bin linking correctly) -echo "[i] Installing globally with npm..." -npm install -g "$TARBALL" +# Install globally using detected package manager +echo "[i] Installing globally with $PKG_MANAGER..." + +if [ "$PKG_MANAGER" = "bun" ]; then + # Remove existing to avoid duplicate key warnings in bun's global package.json + # (bun add -g appends instead of replacing file: protocol entries) + bun remove -g @kaitranntt/ccs 2>/dev/null || true + # Bun requires file: protocol for local tarballs + bun add -g "file:$(pwd)/$TARBALL" +else + npm install -g "$TARBALL" +fi # Clean up echo "[i] Cleaning up..." diff --git a/src/ccs.ts b/src/ccs.ts index 15c57273..3e1c22ef 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -14,6 +14,12 @@ import { detectClaudeCli } from './utils/claude-detector'; import { getSettingsPath } from './utils/config-manager'; import { ErrorManager } from './utils/error-manager'; import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; +import { + ensureMcpWebSearch, + installWebSearchHook, + displayWebSearchStatus, + getWebSearchHookEnv, +} from './utils/websearch-manager'; // Import extracted command handlers import { handleVersionCommand } from './commands/version-command'; @@ -147,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) { @@ -378,6 +385,14 @@ async function main(): Promise { const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { customSettingsPath }); } else if (profileInfo.type === 'settings') { + // Settings-based profiles (glm, glmt, kimi) are third-party providers + // WebSearch is server-side tool - third-party providers have no access + ensureMcpWebSearch(); + installWebSearchHook(); + + // Display WebSearch status (single line, equilibrium UX) + displayWebSearchStatus(); + // Check if this is GLMT profile (requires proxy) if (profileInfo.name === 'glmt') { // GLMT FLOW: Settings-based with embedded proxy for thinking support diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index a14ac2c8..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, @@ -39,6 +40,11 @@ import { } from './account-manager'; import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands'; import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; +import { + ensureMcpWebSearch, + installWebSearchHook, + displayWebSearchStatus, +} from '../utils/websearch-manager'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -112,6 +118,18 @@ export async function execClaudeWithCLIProxy( } }; + // Ensure MCP web-search is configured for third-party profiles + // WebSearch is a server-side tool executed by Anthropic's API + // Third-party providers don't have access, so we use MCP fallback + ensureMcpWebSearch(); + + // Install WebSearch hook for Gemini CLI + MCP fallback + // Hook intercepts WebSearch, tries Gemini CLI first, falls back to MCP + installWebSearchHook(); + + // Display WebSearch status (single line, equilibrium UX) + displayWebSearchStatus(); + // Validate provider const providerConfig = getProviderConfig(provider); log(`Provider: ${providerConfig.displayName}`); @@ -369,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 0b4152bb..04394694 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,22 @@ export function loadUnifiedConfig(): UnifiedConfig | null { return null; } + // Auto-upgrade if version is outdated (regenerates YAML with new comments and fields) + if ((parsed.version ?? 1) < UNIFIED_CONFIG_VERSION) { + // Merge with defaults to add new fields (e.g., model for websearch providers) + const upgraded = mergeWithDefaults(parsed); + upgraded.version = UNIFIED_CONFIG_VERSION; + try { + saveUnifiedConfig(upgraded); + if (process.env.CCS_DEBUG) { + console.error(`[i] Config upgraded to v${UNIFIED_CONFIG_VERSION}`); + } + return upgraded; + } catch { + // Ignore save errors during upgrade - config still works + } + } + return parsed; } catch (err) { const error = err instanceof Error ? err.message : 'Unknown error'; @@ -115,6 +132,33 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { ...defaults.preferences, ...partial.preferences, }, + websearch: { + enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true, + providers: { + gemini: { + enabled: + partial.websearch?.providers?.gemini?.enabled ?? + partial.websearch?.gemini?.enabled ?? // Legacy fallback + true, + model: partial.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash', + timeout: + partial.websearch?.providers?.gemini?.timeout ?? + partial.websearch?.gemini?.timeout ?? // Legacy fallback + 55, + }, + opencode: { + enabled: partial.websearch?.providers?.opencode?.enabled ?? false, + model: partial.websearch?.providers?.opencode?.model ?? 'opencode/grok-code', + timeout: partial.websearch?.providers?.opencode?.timeout ?? 90, + }, + grok: { + enabled: partial.websearch?.providers?.grok?.enabled ?? false, + timeout: partial.websearch?.providers?.grok?.timeout ?? 55, + }, + }, + // Legacy fields (keep for backwards compatibility during read) + gemini: partial.websearch?.gemini, + }, }; } @@ -149,8 +193,7 @@ function generateYamlHeader(): string { # # To customize a profile: # 1. Edit the *.settings.json file directly (e.g., ~/.ccs/glm.settings.json) -# 2. The file format matches Claude's settings.json: { "env": { ... } } -# +# 2. The file format matches Claude's settings.json: { "env": { ... } }\n# # Structure: # ┌─────────────────────────────────────────────────────────────────────────────┐ # │ profiles - References to *.settings.json files for API providers │ @@ -228,6 +271,37 @@ function generateYamlWithComments(config: UnifiedConfig): string { ); lines.push(''); + // WebSearch section + if (config.websearch) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# WebSearch: CLI-based web search for third-party profiles'); + lines.push('# Dashboard (`ccs config`) is the source of truth for provider selection.'); + lines.push('#'); + 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('#'); + lines.push( + '# Gemini models: gemini-2.5-flash (default), gemini-2.5-pro, gemini-2.5-flash-lite' + ); + lines.push( + '# OpenCode models: opencode/grok-code (default), opencode/gpt-4o, opencode/claude-3.5-sonnet' + ); + lines.push('#'); + lines.push('# Install commands:'); + lines.push('# gemini: npm i -g @google/gemini-cli (FREE - 1000 req/day)'); + lines.push('# opencode: curl -fsSL https://opencode.ai/install | bash (FREE via Zen)'); + lines.push('# grok: npm i -g @vibe-kit/grok-cli (requires GROK_API_KEY)'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ websearch: config.websearch }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + } + return lines.join('\n'); } @@ -292,3 +366,65 @@ export function getDefaultProfile(): string | undefined { export function setDefaultProfile(name: string): void { updateUnifiedConfig({ default: name }); } + +/** + * Gemini CLI WebSearch configuration + */ +export interface GeminiWebSearchInfo { + enabled: boolean; + model: string; + timeout: number; +} + +/** + * Get websearch configuration. + * Returns defaults if not configured. + * Supports Gemini CLI, OpenCode, and Grok CLI providers. + */ +export function getWebSearchConfig(): { + enabled: boolean; + providers?: { + gemini?: GeminiWebSearchInfo; + opencode?: { enabled?: boolean; model?: string; timeout?: number }; + grok?: { enabled?: boolean; timeout?: number }; + }; + // Legacy fields (deprecated) + gemini?: { enabled?: boolean; timeout?: number }; +} { + const config = loadOrCreateUnifiedConfig(); + + // Build provider configs + const geminiConfig: GeminiWebSearchInfo = { + enabled: + config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? true, + model: config.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash', + timeout: + config.websearch?.providers?.gemini?.timeout ?? config.websearch?.gemini?.timeout ?? 55, + }; + + const opencodeConfig = { + enabled: config.websearch?.providers?.opencode?.enabled ?? false, + model: config.websearch?.providers?.opencode?.model ?? 'opencode/grok-code', + timeout: config.websearch?.providers?.opencode?.timeout ?? 90, + }; + + const grokConfig = { + enabled: config.websearch?.providers?.grok?.enabled ?? false, + timeout: config.websearch?.providers?.grok?.timeout ?? 55, + }; + + // Auto-enable master switch if ANY provider is enabled + const anyProviderEnabled = geminiConfig.enabled || opencodeConfig.enabled || grokConfig.enabled; + const enabled = anyProviderEnabled && (config.websearch?.enabled ?? true); + + return { + enabled, + providers: { + gemini: geminiConfig, + opencode: opencodeConfig, + grok: grokConfig, + }, + // 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 12e62eea..75cbfe31 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 with model configuration for Gemini/OpenCode */ -export const UNIFIED_CONFIG_VERSION = 2; +export const UNIFIED_CONFIG_VERSION = 3; /** * Account configuration (formerly in profiles.json). @@ -100,6 +101,83 @@ export interface PreferencesConfig { auto_update?: boolean; } +/** + * Gemini CLI WebSearch configuration. + */ +export interface GeminiWebSearchConfig { + /** Enable Gemini CLI for WebSearch (default: true) */ + enabled?: boolean; + /** Model to use (default: gemini-2.5-flash) */ + model?: string; + /** Timeout in seconds (default: 55) */ + timeout?: number; +} + +/** + * Grok CLI WebSearch configuration. + */ +export interface GrokWebSearchConfig { + /** Enable Grok CLI for WebSearch (default: false - requires GROK_API_KEY) */ + enabled?: boolean; + /** Timeout in seconds (default: 55) */ + timeout?: number; +} + +/** + * OpenCode CLI WebSearch configuration. + */ +export interface OpenCodeWebSearchConfig { + /** Enable OpenCode CLI for WebSearch (default: false) */ + enabled?: boolean; + /** Model to use (default: opencode/grok-code) */ + model?: string; + /** Timeout in seconds (default: 60) */ + timeout?: number; +} + +/** + * WebSearch providers configuration. + * Supports Gemini CLI, Grok CLI, and OpenCode. + */ +export interface WebSearchProvidersConfig { + /** Gemini CLI - uses google_web_search tool (FREE tier: 1000 req/day) */ + gemini?: GeminiWebSearchConfig; + /** Grok CLI - xAI web search (requires GROK_API_KEY) */ + grok?: GrokWebSearchConfig; + /** OpenCode - built-in web search (FREE via OpenCode Zen) */ + opencode?: OpenCodeWebSearchConfig; +} + +/** + * WebSearch configuration. + * Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles. + * Third-party providers don't have server-side WebSearch access. + */ +export interface WebSearchConfig { + /** Master switch - enable/disable WebSearch (default: true) */ + enabled?: boolean; + /** Individual provider configurations */ + providers?: WebSearchProvidersConfig; + // Legacy fields (deprecated, kept for backwards compatibility) + /** @deprecated Use providers.gemini instead */ + gemini?: { + enabled?: boolean; + timeout?: number; + }; + /** @deprecated Unused */ + mode?: 'sequential' | 'parallel'; + /** @deprecated Unused */ + provider?: 'auto' | 'web-search-prime' | 'brave' | 'tavily'; + /** @deprecated Unused */ + fallback?: boolean; + /** @deprecated Unused */ + webSearchPrimeUrl?: string; + /** @deprecated Unused */ + selectedProviders?: string[]; + /** @deprecated Unused */ + customMcp?: unknown[]; +} + /** * Main unified configuration structure. * Stored in ~/.ccs/config.yaml @@ -117,6 +195,8 @@ export interface UnifiedConfig { cliproxy: CLIProxyConfig; /** User preferences */ preferences: PreferencesConfig; + /** WebSearch configuration */ + websearch?: WebSearchConfig; } /** @@ -154,6 +234,25 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { telemetry: false, auto_update: true, }, + websearch: { + enabled: true, + providers: { + gemini: { + enabled: true, + model: 'gemini-2.5-flash', + timeout: 55, + }, + opencode: { + enabled: false, + model: 'opencode/grok-code', + timeout: 90, + }, + grok: { + enabled: false, + timeout: 55, + }, + }, + }, }; } 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..f4a7721c --- /dev/null +++ b/src/utils/websearch-manager.ts @@ -0,0 +1,806 @@ +/** + * 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'; + +// Buffer time added to max provider timeout for hook timeout (seconds) +const HOOK_TIMEOUT_BUFFER = 30; + +// Minimum hook timeout in seconds (fallback if no providers configured) +const MIN_HOOK_TIMEOUT = 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; +} + +// ========== OpenCode CLI Detection ========== + +/** + * OpenCode CLI installation status + */ +export interface OpenCodeCliStatus { + installed: boolean; + path: string | null; + version: string | null; +} + +// Cache for OpenCode CLI status (per process) +let opencodeCliCache: OpenCodeCliStatus | null = null; + +/** + * Check if OpenCode CLI is installed globally + * + * OpenCode provides built-in web search via opencode/grok-code model. + * Install: curl -fsSL https://opencode.ai/install | bash + * + * @returns OpenCode CLI status with path and version + */ +export function getOpenCodeCliStatus(): OpenCodeCliStatus { + // Return cached result if available + if (opencodeCliCache) { + return opencodeCliCache; + } + + const result: OpenCodeCliStatus = { + installed: false, + path: null, + version: null, + }; + + try { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where opencode' : 'which opencode'; + + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const opencodePath = pathResult.trim().split('\n')[0]; // First result on Windows + + if (opencodePath) { + result.installed = true; + result.path = opencodePath; + + // Try to get version + try { + const versionResult = execSync('opencode --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 - OpenCode CLI not installed + } + + // Cache result + opencodeCliCache = result; + return result; +} + +/** + * Check if OpenCode CLI is available (quick boolean check) + */ +export function hasOpenCodeCli(): boolean { + return getOpenCodeCliStatus().installed; +} + +/** + * Clear OpenCode CLI cache (for testing or after installation) + */ +export function clearOpenCodeCliCache(): void { + opencodeCliCache = null; +} + +/** + * Clear all CLI caches + */ +export function clearAllCliCaches(): void { + geminiCliCache = null; + grokCliCache = null; + opencodeCliCache = null; +} + +// ========== CLI Provider Info ========== + +/** + * WebSearch CLI provider information for health checks and UI + */ +export interface WebSearchCliInfo { + /** Provider ID */ + id: 'gemini' | 'grok' | 'opencode'; + /** 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(); + const opencodeStatus = getOpenCodeCliStatus(); + + 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: 'opencode', + name: 'OpenCode', + command: 'opencode', + installed: opencodeStatus.installed, + version: opencodeStatus.version, + installCommand: 'curl -fsSL https://opencode.ai/install | bash', + docsUrl: 'https://github.com/sst/opencode', + requiresApiKey: false, + description: 'OpenCode with built-in web search (FREE via Zen)', + freeTier: true, + }, + { + id: 'grok', + name: 'Grok CLI', + command: 'grok', + installed: grokStatus.installed, + version: grokStatus.version, + installCommand: 'npm install -g @vibe-kit/grok-cli', + docsUrl: 'https://github.com/superagent-ai/grok-cli', + requiresApiKey: true, + apiKeyEnvVar: 'GROK_API_KEY', + description: 'xAI Grok CLI with AI coding agent capabilities', + freeTier: false, + }, + ]; +} + +/** + * Check if any WebSearch CLI is available + */ +export function hasAnyWebSearchCli(): boolean { + return hasGeminiCli() || hasGrokCli() || hasOpenCodeCli(); +} + +/** + * 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', + ' OpenCode (FREE): curl -fsSL https://opencode.ai/install | bash', + ' Grok CLI (paid): npm i -g @vibe-kit/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 + * Timeout is computed from max provider timeout in config.yaml + buffer + */ +export function getWebSearchHookConfig(): Record { + const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK); + const wsConfig = getWebSearchConfig(); + + // Compute max timeout from enabled providers + const timeouts: number[] = []; + if (wsConfig.providers?.gemini?.enabled && wsConfig.providers.gemini.timeout) { + timeouts.push(wsConfig.providers.gemini.timeout); + } + if (wsConfig.providers?.opencode?.enabled && wsConfig.providers.opencode.timeout) { + timeouts.push(wsConfig.providers.opencode.timeout); + } + if (wsConfig.providers?.grok?.enabled && wsConfig.providers.grok.timeout) { + timeouts.push(wsConfig.providers.grok.timeout); + } + + // Hook timeout = max provider timeout + buffer (or minimum if none configured) + const maxProviderTimeout = timeouts.length > 0 ? Math.max(...timeouts) : MIN_HOOK_TIMEOUT; + const hookTimeout = maxProviderTimeout + HOOK_TIMEOUT_BUFFER; + + return { + PreToolUse: [ + { + matcher: 'WebSearch', + hooks: [ + { + type: 'command', + command: `node "${hookPath}"`, + timeout: hookTimeout, + }, + ], + }, + ], + }; +} + +/** + * 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; + const expectedHookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK); + const expectedCommand = `node "${expectedHookPath}"`; + + if (hooks?.PreToolUse) { + const webSearchHookIndex = hooks.PreToolUse.findIndex((h: unknown) => { + const hook = h as Record; + return hook.matcher === 'WebSearch'; + }); + + if (webSearchHookIndex !== -1) { + // Hook exists - check if it needs updating (different command path or timeout) + const existingHook = hooks.PreToolUse[webSearchHookIndex] as Record; + const existingHooks = existingHook.hooks as Array>; + const currentHookConfig = getWebSearchHookConfig(); + const expectedHooks = (currentHookConfig.PreToolUse as Array>)[0] + .hooks as Array>; + const expectedTimeout = expectedHooks[0].timeout as number; + + let needsUpdate = false; + + if (existingHooks?.[0]?.command !== expectedCommand) { + existingHooks[0].command = expectedCommand; + needsUpdate = true; + } + + if (existingHooks?.[0]?.timeout !== expectedTimeout) { + existingHooks[0].timeout = expectedTimeout; + needsUpdate = true; + } + + if (needsUpdate) { + fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8'); + if (process.env.CCS_DEBUG) { + console.error(info('Updated WebSearch hook config 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 master switch + env.CCS_WEBSEARCH_ENABLED = '1'; + + // Pass individual provider enabled states + // Hook will only use providers that are BOTH enabled AND installed + if (wsConfig.providers?.gemini?.enabled) { + env.CCS_WEBSEARCH_GEMINI = '1'; + if (wsConfig.providers.gemini.model) { + env.CCS_WEBSEARCH_GEMINI_MODEL = wsConfig.providers.gemini.model; + } + env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.gemini.timeout || 55); + } + + if (wsConfig.providers?.opencode?.enabled) { + env.CCS_WEBSEARCH_OPENCODE = '1'; + if (wsConfig.providers.opencode.model) { + env.CCS_WEBSEARCH_OPENCODE_MODEL = wsConfig.providers.opencode.model; + } + // Use opencode timeout if no gemini timeout set + if (!env.CCS_WEBSEARCH_TIMEOUT) { + env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.opencode.timeout || 90); + } + } + + if (wsConfig.providers?.grok?.enabled) { + env.CCS_WEBSEARCH_GROK = '1'; + // Use grok timeout if no other timeout set + if (!env.CCS_WEBSEARCH_TIMEOUT) { + env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.grok.timeout || 55); + } + } + + // Default timeout if none set + if (!env.CCS_WEBSEARCH_TIMEOUT) { + env.CCS_WEBSEARCH_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; + opencodeCli: 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, + opencodeCli: false, + message: 'Disabled in config', + }; + } + + // Check all CLIs + const geminiInstalled = hasGeminiCli(); + const grokInstalled = hasGrokCli(); + const opencodeInstalled = hasOpenCodeCli(); + + // Build message based on installed CLIs + const installedClis: string[] = []; + if (geminiInstalled) installedClis.push('Gemini'); + if (grokInstalled) installedClis.push('Grok'); + if (opencodeInstalled) installedClis.push('OpenCode'); + + if (installedClis.length > 0) { + return { + readiness: 'ready', + geminiCli: geminiInstalled, + grokCli: grokInstalled, + opencodeCli: opencodeInstalled, + message: `Ready (${installedClis.join(' + ')})`, + }; + } + + return { + readiness: 'unavailable', + geminiCli: false, + grokCli: false, + opencodeCli: 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 ccde0ab3..493cc006 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -46,6 +46,7 @@ import { loadUnifiedConfig, saveUnifiedConfig, getConfigFormat, + getConfigYamlPath, } from '../config/unified-config-loader'; import { needsMigration, @@ -54,8 +55,16 @@ import { getBackupDirectories, } from '../config/migration-manager'; import { getProfileSecrets, setProfileSecrets } from '../config/secrets-manager'; +import { getWebSearchConfig } from '../config/unified-config-loader'; +import type { WebSearchConfig } from '../config/unified-config-types'; import { isUnifiedConfig } from '../config/unified-config-types'; import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys'; +import { + getWebSearchReadiness, + getGeminiCliStatus, + getGrokCliStatus, + getOpenCodeCliStatus, +} from '../utils/websearch-manager'; export const apiRoutes = Router(); @@ -957,6 +966,23 @@ apiRoutes.get('/config', (_req: Request, res: Response): void => { res.json(config); }); +/** + * GET /api/config/raw - Return raw YAML content for display + */ +apiRoutes.get('/config/raw', (_req: Request, res: Response): void => { + const yamlPath = getConfigYamlPath(); + if (!fs.existsSync(yamlPath)) { + res.status(404).json({ error: 'Config file not found' }); + return; + } + try { + const content = fs.readFileSync(yamlPath, 'utf8'); + res.type('text/plain').send(content); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + /** * PUT /api/config - Update unified config */ @@ -1430,3 +1456,139 @@ apiRoutes.delete('/cliproxy/openai-compat/:name', (req: Request, res: Response): res.status(500).json({ error: (error as Error).message }); } }); + +// ==================== WebSearch Configuration ==================== + +/** + * GET /api/websearch - Get WebSearch configuration + * Returns: WebSearchConfig with enabled, provider, fallback + */ +apiRoutes.get('/websearch', (_req: Request, res: Response): void => { + try { + const config = getWebSearchConfig(); + res.json(config); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/websearch - Update WebSearch configuration + * Body: WebSearchConfig fields (enabled, providers) + * Dashboard is the source of truth for provider selection. + */ +apiRoutes.put('/websearch', (req: Request, res: Response): void => { + const { enabled, providers } = req.body as Partial; + + // Validate enabled + if (enabled !== undefined && typeof enabled !== 'boolean') { + res.status(400).json({ error: 'Invalid value for enabled. Must be a boolean.' }); + return; + } + + // Validate providers if specified + if (providers !== undefined && typeof providers !== 'object') { + res.status(400).json({ error: 'Invalid value for providers. Must be an object.' }); + return; + } + + try { + // Load existing config and update websearch section + const existingConfig = loadUnifiedConfig(); + if (!existingConfig) { + res.status(500).json({ error: 'Failed to load config' }); + return; + } + + // Merge updates - supports Gemini CLI and Grok CLI + existingConfig.websearch = { + enabled: enabled ?? existingConfig.websearch?.enabled ?? true, + providers: providers + ? { + gemini: { + enabled: + providers.gemini?.enabled ?? + existingConfig.websearch?.providers?.gemini?.enabled ?? + true, + model: + providers.gemini?.model ?? + existingConfig.websearch?.providers?.gemini?.model ?? + 'gemini-2.5-flash', + timeout: + providers.gemini?.timeout ?? + existingConfig.websearch?.providers?.gemini?.timeout ?? + 55, + }, + grok: { + enabled: + providers.grok?.enabled ?? + existingConfig.websearch?.providers?.grok?.enabled ?? + false, + timeout: + providers.grok?.timeout ?? existingConfig.websearch?.providers?.grok?.timeout ?? 55, + }, + opencode: { + enabled: + providers.opencode?.enabled ?? + existingConfig.websearch?.providers?.opencode?.enabled ?? + false, + model: + providers.opencode?.model ?? + existingConfig.websearch?.providers?.opencode?.model ?? + 'opencode/grok-code', + timeout: + providers.opencode?.timeout ?? + existingConfig.websearch?.providers?.opencode?.timeout ?? + 60, + }, + } + : existingConfig.websearch?.providers, + }; + + saveUnifiedConfig(existingConfig); + + res.json({ + success: true, + websearch: existingConfig.websearch, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/websearch/status - Get WebSearch status + * Returns: { geminiCli, grokCli, opencodeCli, readiness } + */ +apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => { + try { + const geminiCli = getGeminiCliStatus(); + const grokCli = getGrokCliStatus(); + const opencodeCli = getOpenCodeCliStatus(); + const readiness = getWebSearchReadiness(); + + res.json({ + geminiCli: { + installed: geminiCli.installed, + path: geminiCli.path, + version: geminiCli.version, + }, + grokCli: { + installed: grokCli.installed, + path: grokCli.path, + version: grokCli.version, + }, + opencodeCli: { + installed: opencodeCli.installed, + path: opencodeCli.path, + version: opencodeCli.version, + }, + readiness: { + status: readiness.readiness, + message: readiness.message, + }, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); diff --git a/src/web-server/shutdown.ts b/src/web-server/shutdown.ts index 5420fe5a..4098b68f 100644 --- a/src/web-server/shutdown.ts +++ b/src/web-server/shutdown.ts @@ -1,18 +1,16 @@ /** - * Graceful Shutdown Handler + * Shutdown Handler * - * Handles SIGINT/SIGTERM signals to gracefully close WebSocket connections - * and HTTP server before process exit. + * Handles SIGINT/SIGTERM signals to close server and exit immediately. + * No graceful waiting - config dashboard doesn't need it. */ import { Server as HTTPServer } from 'http'; import { WebSocketServer } from 'ws'; -import { ok, info, warn } from '../utils/ui'; - -const SHUTDOWN_TIMEOUT = 10_000; // 10 seconds +import { ok } from '../utils/ui'; /** - * Setup graceful shutdown handlers for SIGINT and SIGTERM + * Setup shutdown handlers for SIGINT and SIGTERM */ export function setupGracefulShutdown( server: HTTPServer, @@ -20,24 +18,16 @@ export function setupGracefulShutdown( cleanup?: () => void ): void { const shutdown = () => { - console.log('\n' + info('Shutting down gracefully...')); + console.log('\n' + ok('Shutting down...')); - // Run cleanup first (closes file watchers + WebSocket clients) + // Run cleanup (closes file watchers + WebSocket clients) if (cleanup) { cleanup(); } - // Close HTTP server - server.close(() => { - console.log(ok('Server closed')); - process.exit(0); - }); - - // Force shutdown if graceful shutdown takes too long - setTimeout(() => { - console.log(warn('Force shutdown (timeout exceeded)')); - process.exit(1); - }, SHUTDOWN_TIMEOUT); + // Close server and exit immediately + server.close(); + process.exit(0); }; process.on('SIGINT', shutdown); diff --git a/tests/unit/mcp-manager.test.ts b/tests/unit/mcp-manager.test.ts new file mode 100644 index 00000000..e82362e8 --- /dev/null +++ b/tests/unit/mcp-manager.test.ts @@ -0,0 +1,269 @@ +/** + * Unit tests for MCP Manager module + * + * Tests web search MCP configuration logic without filesystem dependencies. + * These tests focus on the pure logic functions that can be tested without + * modifying the actual config files. + */ +import { describe, it, expect } from 'bun:test'; + +/** + * Test helper: Simulate hasWebSearch detection logic + * This matches the logic in mcp-manager.ts hasMcpWebSearch() + */ +function detectWebSearchMcp(mcpServers: Record): boolean { + return Object.keys(mcpServers).some((key) => { + const lowerKey = key.toLowerCase(); + return ( + lowerKey.includes('web-search') || + lowerKey.includes('websearch') || + lowerKey.includes('tavily') || + lowerKey.includes('brave') + ); + }); +} + +/** + * Test helper: Simulate provider configuration logic + * Returns which providers would be added given config and env + */ +function getProvidersToAdd( + wsConfig: { enabled: boolean; provider: string; fallback: boolean }, + hasExistingWebSearch: boolean, + apiKeys: { brave?: string; tavily?: string } +): string[] { + if (!wsConfig.enabled) return []; + if (hasExistingWebSearch) return []; + + const providers: string[] = []; + + if (wsConfig.provider === 'auto') { + providers.push('web-search-prime'); + if (wsConfig.fallback) { + if (apiKeys.brave) providers.push('brave-search'); + if (apiKeys.tavily) providers.push('tavily'); + } + } else if (wsConfig.provider === 'web-search-prime') { + providers.push('web-search-prime'); + } else if (wsConfig.provider === 'brave') { + if (apiKeys.brave) { + providers.push('brave-search'); + } else if (wsConfig.fallback) { + // Fallback chain + providers.push('web-search-prime'); + if (apiKeys.tavily) providers.push('tavily'); + } + } else if (wsConfig.provider === 'tavily') { + if (apiKeys.tavily) { + providers.push('tavily'); + } else if (wsConfig.fallback) { + // Fallback chain + providers.push('web-search-prime'); + if (apiKeys.brave) providers.push('brave-search'); + } + } + + return providers; +} + +describe('mcp-manager logic', () => { + describe('web search detection', () => { + it('should detect web-search-prime', () => { + const servers = { 'web-search-prime': { type: 'http' } }; + expect(detectWebSearchMcp(servers)).toBe(true); + }); + + it('should detect brave-search', () => { + const servers = { 'brave-search': { type: 'stdio' } }; + expect(detectWebSearchMcp(servers)).toBe(true); + }); + + it('should detect tavily', () => { + const servers = { tavily: { type: 'stdio' } }; + expect(detectWebSearchMcp(servers)).toBe(true); + }); + + it('should be case-insensitive', () => { + expect(detectWebSearchMcp({ 'WebSearch-Custom': {} })).toBe(true); + expect(detectWebSearchMcp({ 'WEB-SEARCH': {} })).toBe(true); + expect(detectWebSearchMcp({ TAVILY: {} })).toBe(true); + expect(detectWebSearchMcp({ 'Brave-Search': {} })).toBe(true); + }); + + it('should not detect unrelated MCPs', () => { + expect(detectWebSearchMcp({ 'my-custom-mcp': {} })).toBe(false); + expect(detectWebSearchMcp({ 'filesystem': {} })).toBe(false); + expect(detectWebSearchMcp({ 'github-copilot': {} })).toBe(false); + }); + + it('should return false for empty servers', () => { + expect(detectWebSearchMcp({})).toBe(false); + }); + }); + + describe('provider selection logic', () => { + const defaultConfig = { enabled: true, provider: 'auto', fallback: true }; + const noApiKeys = {}; + const braveOnly = { brave: 'test-key' }; + const tavilyOnly = { tavily: 'test-key' }; + const bothKeys = { brave: 'brave-key', tavily: 'tavily-key' }; + + it('should add web-search-prime in auto mode', () => { + const providers = getProvidersToAdd(defaultConfig, false, noApiKeys); + expect(providers).toContain('web-search-prime'); + }); + + it('should add brave-search when API key available in auto mode', () => { + const providers = getProvidersToAdd(defaultConfig, false, braveOnly); + expect(providers).toContain('web-search-prime'); + expect(providers).toContain('brave-search'); + }); + + it('should add tavily when API key available in auto mode', () => { + const providers = getProvidersToAdd(defaultConfig, false, tavilyOnly); + expect(providers).toContain('web-search-prime'); + expect(providers).toContain('tavily'); + }); + + it('should add all providers when both API keys available', () => { + const providers = getProvidersToAdd(defaultConfig, false, bothKeys); + expect(providers).toContain('web-search-prime'); + expect(providers).toContain('brave-search'); + expect(providers).toContain('tavily'); + }); + + it('should not add fallbacks when fallback=false', () => { + const config = { enabled: true, provider: 'auto', fallback: false }; + const providers = getProvidersToAdd(config, false, bothKeys); + expect(providers).toEqual(['web-search-prime']); + }); + + it('should skip when disabled', () => { + const config = { enabled: false, provider: 'auto', fallback: true }; + const providers = getProvidersToAdd(config, false, bothKeys); + expect(providers).toEqual([]); + }); + + it('should skip when web search already exists', () => { + const providers = getProvidersToAdd(defaultConfig, true, bothKeys); + expect(providers).toEqual([]); + }); + + it('should use specific provider when configured', () => { + const config = { enabled: true, provider: 'brave', fallback: false }; + const providers = getProvidersToAdd(config, false, braveOnly); + expect(providers).toEqual(['brave-search']); + }); + + it('should fallback when specific provider not available', () => { + const config = { enabled: true, provider: 'tavily', fallback: true }; + // No tavily key, should fallback + const providers = getProvidersToAdd(config, false, braveOnly); + expect(providers).toContain('web-search-prime'); + expect(providers).toContain('brave-search'); + expect(providers).not.toContain('tavily'); + }); + + it('should return empty when provider unavailable and fallback=false', () => { + const config = { enabled: true, provider: 'brave', fallback: false }; + // No brave key and no fallback + const providers = getProvidersToAdd(config, false, noApiKeys); + expect(providers).toEqual([]); + }); + }); + + describe('MCP server config structures', () => { + it('should define correct web-search-prime structure', () => { + const webSearchPrimeConfig = { + type: 'http', + url: 'https://api.z.ai/api/mcp/web_search_prime/mcp', + headers: {}, + }; + + expect(webSearchPrimeConfig.type).toBe('http'); + expect(webSearchPrimeConfig.url).toContain('web_search_prime'); + }); + + it('should define correct brave-search structure', () => { + const braveConfig = { + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-brave-search'], + env: { BRAVE_API_KEY: 'test-key' }, + }; + + expect(braveConfig.type).toBe('stdio'); + expect(braveConfig.command).toBe('npx'); + expect(braveConfig.args).toContain('@modelcontextprotocol/server-brave-search'); + expect(braveConfig.env.BRAVE_API_KEY).toBe('test-key'); + }); + + it('should define correct tavily structure', () => { + const tavilyConfig = { + type: 'stdio', + command: 'npx', + args: ['-y', '@tavily/mcp-server'], + env: { TAVILY_API_KEY: 'test-key' }, + }; + + expect(tavilyConfig.type).toBe('stdio'); + expect(tavilyConfig.args).toContain('@tavily/mcp-server'); + expect(tavilyConfig.env.TAVILY_API_KEY).toBe('test-key'); + }); + }); + + describe('hook configuration', () => { + it('should define correct PreToolUse hook structure', () => { + const hookConfig = { + PreToolUse: [ + { + matcher: 'WebSearch', + hooks: [ + { + type: 'command', + command: 'node "/path/to/hook.cjs"', + timeout: 5, + }, + ], + }, + ], + }; + + expect(hookConfig.PreToolUse).toBeDefined(); + expect(hookConfig.PreToolUse[0].matcher).toBe('WebSearch'); + expect(hookConfig.PreToolUse[0].hooks[0].type).toBe('command'); + expect(hookConfig.PreToolUse[0].hooks[0].timeout).toBe(5); + }); + }); + + describe('MCP config file path', () => { + it('should be located in .claude directory', () => { + // The MCP config path should follow this pattern + const expectedPathPattern = '.claude/.mcp.json'; + const testPath = '/home/user/.claude/.mcp.json'; + expect(testPath).toContain(expectedPathPattern); + }); + }); + + describe('WebSearch config defaults', () => { + it('should have correct default values', () => { + const defaults = { + enabled: true, + provider: 'auto', + fallback: true, + }; + + expect(defaults.enabled).toBe(true); + expect(defaults.provider).toBe('auto'); + expect(defaults.fallback).toBe(true); + }); + + it('should validate provider options', () => { + const validProviders = ['auto', 'web-search-prime', 'brave', 'tavily']; + expect(validProviders).toContain('auto'); + expect(validProviders).toContain('web-search-prime'); + expect(validProviders).toContain('brave'); + expect(validProviders).toContain('tavily'); + }); + }); +}); diff --git a/ui/bun.lock b/ui/bun.lock index 2f28cb7b..8b86d52f 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -8,6 +8,7 @@ "@nivo/core": "^0.99.0", "@nivo/sankey": "^0.99.0", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -17,6 +18,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.12", @@ -225,6 +227,8 @@ "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], @@ -271,6 +275,8 @@ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], diff --git a/ui/package.json b/ui/package.json index 9c39d5d1..a6488087 100644 --- a/ui/package.json +++ b/ui/package.json @@ -19,6 +19,7 @@ "@nivo/core": "^0.99.0", "@nivo/sankey": "^0.99.0", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -28,6 +29,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.12", diff --git a/ui/src/components/ui/checkbox.tsx b/ui/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..ae5a2796 --- /dev/null +++ b/ui/src/components/ui/checkbox.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { CheckIcon } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +function Checkbox({ className, ...props }: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/ui/src/components/ui/switch.tsx b/ui/src/components/ui/switch.tsx new file mode 100644 index 00000000..09603b92 --- /dev/null +++ b/ui/src/components/ui/switch.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import * as SwitchPrimitive from '@radix-ui/react-switch'; + +import { cn } from '@/lib/utils'; + +function Switch({ className, ...props }: React.ComponentProps) { + return ( + + + + ); +} + +export { Switch }; diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index 98d5c4e9..d14175db 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -1,46 +1,721 @@ /** - * Settings Page - Deprecated - * Settings functionality has been moved to API Profiles page + * Settings Page - WebSearch Configuration + * Supports Gemini CLI and Grok CLI providers */ -import { Link } from 'react-router-dom'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useState, useEffect } from 'react'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { Button } from '@/components/ui/button'; -import { Construction, ArrowRight } from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Switch } from '@/components/ui/switch'; +import { Input } from '@/components/ui/input'; +import { + Globe, + RefreshCw, + CheckCircle2, + AlertCircle, + FileCode, + Copy, + Check, + GripVertical, + Terminal, + ExternalLink, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { CodeEditor } from '@/components/code-editor'; + +interface ProviderConfig { + enabled?: boolean; + model?: string; + timeout?: number; +} + +interface WebSearchProvidersConfig { + gemini?: ProviderConfig; + grok?: ProviderConfig; + opencode?: ProviderConfig; +} + +interface WebSearchConfig { + enabled: boolean; + providers?: WebSearchProvidersConfig; +} + +interface CliStatus { + installed: boolean; + path: string | null; + version: string | null; +} + +interface WebSearchStatus { + geminiCli: CliStatus; + grokCli: CliStatus; + opencodeCli: CliStatus; + readiness: { + status: 'ready' | 'unavailable'; + message: string; + }; +} export function SettingsPage() { - return ( -
-

Settings

+ const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [status, setStatus] = useState(null); + const [statusLoading, setStatusLoading] = useState(true); + // Config viewer state + const [rawConfig, setRawConfig] = useState(null); + const [rawConfigLoading, setRawConfigLoading] = useState(false); + const [copied, setCopied] = useState(false); + // Local model input state (to avoid saving on every keystroke) + const [geminiModelInput, setGeminiModelInput] = useState(''); + const [opencodeModelInput, setOpencodeModelInput] = useState(''); + // Collapsible install hints state + const [showGeminiHint, setShowGeminiHint] = useState(false); + const [showOpencodeHint, setShowOpencodeHint] = useState(false); + const [showGrokHint, setShowGrokHint] = useState(false); - - - - - Page Relocated - - - -

- The settings editor has been integrated into the API Profiles page for - a better user experience. -

-

To edit environment variables for a profile:

-
    -
  1. Go to API Profiles page
  2. -
  3. Click the actions menu (...) on any profile
  4. -
  5. Select "Edit Settings"
  6. -
-
- + // Load config and status on mount + useEffect(() => { + fetchConfig(); + fetchStatus(); + fetchRawConfig(); + }, []); + + // Sync local model inputs when config changes + useEffect(() => { + if (config) { + setGeminiModelInput(config.providers?.gemini?.model ?? 'gemini-2.5-flash'); + setOpencodeModelInput(config.providers?.opencode?.model ?? 'opencode/grok-code'); + } + }, [config]); + + const fetchConfig = async () => { + try { + setLoading(true); + setError(null); + const res = await fetch('/api/websearch'); + if (!res.ok) throw new Error('Failed to load WebSearch config'); + const data = await res.json(); + setConfig(data); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + + const fetchStatus = async () => { + try { + setStatusLoading(true); + const res = await fetch('/api/websearch/status'); + if (!res.ok) throw new Error('Failed to load status'); + const data = await res.json(); + setStatus(data); + } catch (err) { + console.error('Failed to fetch WebSearch status:', err); + } finally { + setStatusLoading(false); + } + }; + + const fetchRawConfig = async () => { + try { + setRawConfigLoading(true); + const res = await fetch('/api/config/raw'); + if (!res.ok) { + setRawConfig(null); + return; + } + const text = await res.text(); + setRawConfig(text); + } catch (err) { + console.error('Failed to fetch raw config:', err); + setRawConfig(null); + } finally { + setRawConfigLoading(false); + } + }; + + const copyToClipboard = async () => { + if (!rawConfig) return; + try { + await navigator.clipboard.writeText(rawConfig); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + // Toggle Gemini provider + const toggleGemini = () => { + const providers = config?.providers || {}; + const currentState = providers.gemini?.enabled ?? false; + const grokState = providers.grok?.enabled ?? false; + const opencodeState = providers.opencode?.enabled ?? false; + + saveConfig({ + enabled: !currentState || grokState || opencodeState, // Enable WebSearch if any provider is enabled + providers: { + ...providers, + gemini: { + ...providers.gemini, + enabled: !currentState, + }, + }, + }); + }; + + const saveConfig = async (updates: Partial) => { + if (!config) return; + + // Optimistic update - apply changes immediately to local state + const optimisticConfig = { ...config, ...updates }; + setConfig(optimisticConfig); + + try { + setSaving(true); + setError(null); + + const res = await fetch('/api/websearch', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(optimisticConfig), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to save'); + } + + const data = await res.json(); + setConfig(data.websearch); + // Quick flash of success (shorter duration, less intrusive) + setSuccess(true); + setTimeout(() => setSuccess(false), 1500); + // Silently refresh raw config without loading state + fetch('/api/config/raw') + .then((r) => (r.ok ? r.text() : null)) + .then((text) => text && setRawConfig(text)) + .catch(() => {}); + } catch (err) { + // Revert optimistic update on error + setConfig(config); + setError((err as Error).message); + } finally { + setSaving(false); + } + }; + + const isGeminiEnabled = config?.providers?.gemini?.enabled ?? false; + const isGrokEnabled = config?.providers?.grok?.enabled ?? false; + const isOpenCodeEnabled = config?.providers?.opencode?.enabled ?? false; + + // Toggle Grok provider + const toggleGrok = () => { + const providers = config?.providers || {}; + const currentState = providers.grok?.enabled ?? false; + + saveConfig({ + enabled: isGeminiEnabled || !currentState || isOpenCodeEnabled, // Enable WebSearch if any provider is enabled + providers: { + ...providers, + grok: { + ...providers.grok, + enabled: !currentState, + }, + }, + }); + }; + + // Toggle OpenCode provider + const toggleOpenCode = () => { + const providers = config?.providers || {}; + const currentState = providers.opencode?.enabled ?? false; + + saveConfig({ + enabled: isGeminiEnabled || isGrokEnabled || !currentState, // Enable WebSearch if any provider is enabled + providers: { + ...providers, + opencode: { + ...providers.opencode, + enabled: !currentState, + }, + }, + }); + }; + + // Save Gemini model on blur (only if changed) + const saveGeminiModel = () => { + const currentModel = config?.providers?.gemini?.model ?? 'gemini-2.5-flash'; + if (geminiModelInput !== currentModel) { + const providers = config?.providers || {}; + saveConfig({ + providers: { + ...providers, + gemini: { + ...providers.gemini, + model: geminiModelInput, + }, + }, + }); + } + }; + + // Save OpenCode model on blur (only if changed) + const saveOpencodeModel = () => { + const currentModel = config?.providers?.opencode?.model ?? 'opencode/grok-code'; + if (opencodeModelInput !== currentModel) { + const providers = config?.providers || {}; + saveConfig({ + providers: { + ...providers, + opencode: { + ...providers.opencode, + model: opencodeModelInput, + }, + }, + }); + } + }; + + if (loading) { + return ( +
+
+ + Loading configuration... +
+
+ ); + } + + return ( +
+ + {/* Left Panel - WebSearch Controls */} + +
+ {/* Header */} +
+
+ +
+

WebSearch

+

+ CLI-based web search for third-party profiles +

+
+
+
+ + {/* Toast-style alerts - absolute positioned, no layout shift */} +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + Saved +
+ )} +
+ + {/* Scrollable Content */} + +
+ {/* Status Summary */} +
+
+

+ {isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'} +

+ {statusLoading ? ( +

Checking status...

+ ) : status?.readiness ? ( +

{status.readiness.message}

+ ) : null} +
+ +
+ + {/* CLI Providers */} +
+

Providers

+ + {/* Gemini CLI Provider */} +
+
+
+ +
+
+

gemini

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

+ Google Gemini CLI (1000 req/day free) +

+
+
+ +
+ {/* Model input when enabled */} + {isGeminiEnabled && ( +
+
+ + setGeminiModelInput(e.target.value)} + onBlur={saveGeminiModel} + placeholder="gemini-2.5-flash" + className="h-8 text-sm font-mono" + disabled={saving} + /> +
+
+ )} + {/* Installation hint when not installed - inside card */} + {!status?.geminiCli?.installed && !statusLoading && ( +
+ + {showGeminiHint && ( +
+

+ Install globally (FREE tier available): +

+ + npm install -g @google/gemini-cli + + + + View documentation + +
+ )} +
+ )} +
+ + {/* OpenCode CLI Provider */} +
+
+
+ +
+
+

opencode

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

+ OpenCode (web search via Zen) +

+
+
+ +
+ {/* Model input when enabled */} + {isOpenCodeEnabled && ( +
+
+ + setOpencodeModelInput(e.target.value)} + onBlur={saveOpencodeModel} + placeholder="opencode/grok-code" + className="h-8 text-sm font-mono" + disabled={saving} + /> +
+
+ )} + {/* Installation hint when not installed - inside card */} + {!status?.opencodeCli?.installed && !statusLoading && ( +
+ + {showOpencodeHint && ( +
+

+ Install globally (FREE tier available): +

+ + curl -fsSL https://opencode.ai/install | bash + + + + View documentation + +
+ )} +
+ )} +
+ + {/* Grok CLI Provider */} +
+
+
+ +
+
+

grok

+ + GROK_API_KEY + + {status?.grokCli?.installed ? ( + + installed + + ) : ( + + not installed + + )} +
+

+ xAI Grok CLI (web + X search) +

+
+
+ +
+ {/* Installation hint when not installed - inside card */} + {!status?.grokCli?.installed && !statusLoading && ( +
+ + {showGrokHint && ( +
+

+ Install globally (requires xAI API key): +

+ + npm install -g @vibe-kit/grok-cli + + + + View documentation + +
+ )} +
+ )} +
+
+
+
+ + {/* Footer */} +
+ +
- - +
+ + {/* Resize Handle */} + + + + + {/* Right Panel - Config Viewer */} + +
+ {/* Header */} +
+
+ +
+

config.yaml

+

~/.ccs/config.yaml

+
+
+
+ + +
+
+ + {/* Config Content - scrollable */} +
+ {rawConfigLoading ? ( +
+ + Loading... +
+ ) : rawConfig ? ( + {}} + language="yaml" + readonly + minHeight="auto" + className="min-h-full" + /> + ) : ( +
+
+ +

Config file not found

+ + ccs migrate + +
+
+ )} +
+
+
+
); }