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