mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 22:21:20 +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
|
||||
- Tool execution never reaches the third-party backend
|
||||
|
||||
CCS solves this by automatically configuring MCP (Model Context Protocol) web search servers.
|
||||
CCS solves this with a hybrid fallback approach:
|
||||
|
||||
1. **Gemini CLI Transformer** (Primary) - Uses `gemini -p` with `google_web_search` tool
|
||||
2. **MCP Fallback Chain** (Secondary) - MCP-based web search servers
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -28,19 +31,54 @@ CCS solves this by automatically configuring MCP (Model Context Protocol) web se
|
||||
│ ├── Native Claude Account? → Anthropic WebSearch API │
|
||||
│ │ ($10/1000 searches) │
|
||||
│ │ │
|
||||
│ └── Third-party Profile? → MCP Fallback Chain │
|
||||
│ └── Third-party Profile? → PreToolUse Hook │
|
||||
│ │ │
|
||||
│ ├── 1. web-search-prime │
|
||||
│ ├── 2. Brave Search (free) │
|
||||
│ └── 3. Tavily (paid) │
|
||||
│ ├── 1. Gemini CLI │
|
||||
│ │ (google_web_search) │
|
||||
│ │ No API key needed! │
|
||||
│ │ │
|
||||
│ └── 2. MCP Fallback Chain │
|
||||
│ ├── web-search-prime │
|
||||
│ ├── Brave Search │
|
||||
│ └── Tavily │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Gemini CLI Integration (Primary)
|
||||
|
||||
The **ultimate solution** for third-party WebSearch. Uses `gemini` CLI with OAuth authentication - **no API key needed!**
|
||||
|
||||
### How It Works
|
||||
|
||||
1. A PreToolUse hook intercepts WebSearch tool calls
|
||||
2. Executes `gemini -p` with explicit google_web_search instruction
|
||||
3. Returns search results directly to Claude via the hook's deny reason
|
||||
4. Claude receives full search results and continues the conversation
|
||||
|
||||
### Requirements
|
||||
|
||||
- `gemini` CLI installed and authenticated (`gemini auth login`)
|
||||
- OAuth authentication (no GEMINI_API_KEY needed)
|
||||
|
||||
### Installation
|
||||
|
||||
The Gemini CLI is typically installed via:
|
||||
```bash
|
||||
pip install google-generativeai
|
||||
# or
|
||||
pipx install google-generativeai
|
||||
```
|
||||
|
||||
Then authenticate:
|
||||
```bash
|
||||
gemini auth login
|
||||
```
|
||||
|
||||
## MCP Providers
|
||||
|
||||
| Provider | Type | Cost | API Key Required | Notes |
|
||||
|----------|------|------|------------------|-------|
|
||||
| web-search-prime | HTTP MCP | Free | No | Primary, always available |
|
||||
| web-search-prime | HTTP MCP | z.ai subscription | No | Requires z.ai coding plan |
|
||||
| Brave Search | stdio MCP | Free tier | `BRAVE_API_KEY` | 15k queries/month |
|
||||
| Tavily | stdio MCP | Paid | `TAVILY_API_KEY` | AI-optimized search |
|
||||
|
||||
@@ -65,12 +103,28 @@ websearch:
|
||||
provider: auto # auto | web-search-prime | brave | tavily
|
||||
fallback: true # Enable fallback chain (default: true)
|
||||
webSearchPrimeUrl: "https://..." # Optional: custom endpoint
|
||||
|
||||
# Gemini CLI configuration (new!)
|
||||
gemini:
|
||||
enabled: true # Use Gemini CLI for WebSearch (default: true)
|
||||
timeout: 55 # Timeout in seconds (default: 55)
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The WebSearch hook also respects these environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `CCS_WEBSEARCH_SKIP` | Skip WebSearch hook entirely | `0` |
|
||||
| `CCS_GEMINI_SKIP` | Skip Gemini CLI, use MCP only | `0` |
|
||||
| `CCS_GEMINI_TIMEOUT` | Gemini CLI timeout (seconds) | `55` |
|
||||
| `CCS_DEBUG` | Enable debug output | `0` |
|
||||
|
||||
### Provider Options
|
||||
|
||||
- **auto** (default): Uses web-search-prime, adds Brave/Tavily if API keys available
|
||||
- **web-search-prime**: Free, no API key needed
|
||||
- **web-search-prime**: Requires z.ai coding plan subscription
|
||||
- **brave**: Requires `BRAVE_API_KEY` env var
|
||||
- **tavily**: Requires `TAVILY_API_KEY` env var
|
||||
|
||||
@@ -120,6 +174,13 @@ CCS writes MCP configuration to `~/.claude/.mcp.json`. Example:
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Gemini CLI Issues
|
||||
|
||||
1. **Not installed**: Install with `pip install google-generativeai`
|
||||
2. **Not authenticated**: Run `gemini auth login`
|
||||
3. **Timeout**: Increase timeout in config or via `CCS_GEMINI_TIMEOUT=90`
|
||||
4. **Skip Gemini**: Set `CCS_GEMINI_SKIP=1` to use MCP fallback only
|
||||
|
||||
### WebSearch Not Working
|
||||
|
||||
1. **Check config**: Ensure `websearch.enabled: true` in config
|
||||
|
||||
@@ -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 { ErrorManager } from './utils/error-manager';
|
||||
import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy';
|
||||
import { ensureMcpWebSearch } from './utils/mcp-manager';
|
||||
import { ensureMcpWebSearch, installWebSearchHook } from './utils/mcp-manager';
|
||||
|
||||
// Import extracted command handlers
|
||||
import { handleVersionCommand } from './commands/version-command';
|
||||
@@ -382,6 +382,7 @@ async function main(): Promise<void> {
|
||||
// Settings-based profiles (glm, glmt, kimi) are third-party providers
|
||||
// WebSearch is server-side tool - third-party providers have no access
|
||||
ensureMcpWebSearch();
|
||||
installWebSearchHook();
|
||||
|
||||
// Check if this is GLMT profile (requires proxy)
|
||||
if (profileInfo.name === 'glmt') {
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
} from './account-manager';
|
||||
import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands';
|
||||
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
||||
import { ensureMcpWebSearch } from '../utils/mcp-manager';
|
||||
import { ensureMcpWebSearch, installWebSearchHook } from '../utils/mcp-manager';
|
||||
|
||||
/** Default executor configuration */
|
||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||
@@ -118,6 +118,10 @@ export async function execClaudeWithCLIProxy(
|
||||
// Third-party providers don't have access, so we use MCP fallback
|
||||
ensureMcpWebSearch();
|
||||
|
||||
// Install WebSearch hook for Gemini CLI + MCP fallback
|
||||
// Hook intercepts WebSearch, tries Gemini CLI first, falls back to MCP
|
||||
installWebSearchHook();
|
||||
|
||||
// Validate provider
|
||||
const providerConfig = getProviderConfig(provider);
|
||||
log(`Provider: ${providerConfig.displayName}`);
|
||||
|
||||
@@ -325,6 +325,10 @@ export function getWebSearchConfig(): {
|
||||
provider: 'auto' | 'web-search-prime' | 'brave' | 'tavily';
|
||||
fallback: boolean;
|
||||
webSearchPrimeUrl?: string;
|
||||
gemini: {
|
||||
enabled: boolean;
|
||||
timeout: number;
|
||||
};
|
||||
} {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return {
|
||||
@@ -332,5 +336,9 @@ export function getWebSearchConfig(): {
|
||||
provider: config.websearch?.provider ?? 'auto',
|
||||
fallback: config.websearch?.fallback ?? true,
|
||||
webSearchPrimeUrl: config.websearch?.webSearchPrimeUrl,
|
||||
gemini: {
|
||||
enabled: config.websearch?.gemini?.enabled ?? true,
|
||||
timeout: config.websearch?.gemini?.timeout ?? 55,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +113,17 @@ export interface WebSearchConfig {
|
||||
fallback?: boolean;
|
||||
/** Custom URL for web-search-prime provider (optional, overrides default) */
|
||||
webSearchPrimeUrl?: string;
|
||||
/**
|
||||
* Gemini CLI configuration for WebSearch transformation.
|
||||
* Uses `gemini -p` with google_web_search tool as primary method.
|
||||
* No API key needed - uses OAuth authentication.
|
||||
*/
|
||||
gemini?: {
|
||||
/** Enable Gemini CLI for WebSearch (default: true) */
|
||||
enabled?: boolean;
|
||||
/** Timeout in seconds for Gemini CLI (default: 55) */
|
||||
timeout?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,6 +186,10 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
||||
enabled: true,
|
||||
provider: 'auto',
|
||||
fallback: true,
|
||||
gemini: {
|
||||
enabled: true,
|
||||
timeout: 55,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+211
-22
@@ -42,7 +42,7 @@ interface McpConfig {
|
||||
|
||||
/**
|
||||
* Default web-search-prime MCP configuration (primary)
|
||||
* HTTP-based, no API key needed
|
||||
* HTTP-based, requires z.ai coding plan subscription
|
||||
*/
|
||||
const WEB_SEARCH_PRIME_CONFIG: McpServerConfig = {
|
||||
type: 'http',
|
||||
@@ -86,7 +86,7 @@ const TAVILY_CONFIG: McpServerConfig = {
|
||||
* - fallback: false → don't add fallback providers
|
||||
*
|
||||
* Multi-tier MCP fallback chain:
|
||||
* 1. web-search-prime (primary, no API key needed)
|
||||
* 1. web-search-prime (primary, requires z.ai subscription)
|
||||
* 2. brave-search (if BRAVE_API_KEY set)
|
||||
* 3. tavily (if TAVILY_API_KEY set)
|
||||
*
|
||||
@@ -290,31 +290,45 @@ export function getMcpConfigPath(): string {
|
||||
|
||||
// CCS hooks directory
|
||||
const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks');
|
||||
// Legacy hook for simple MCP redirect (deprecated)
|
||||
const BLOCK_WEBSEARCH_HOOK = 'block-websearch.cjs';
|
||||
// New hybrid hook: Gemini CLI first, MCP fallback
|
||||
const GEMINI_TRANSFORMER_HOOK = 'websearch-gemini-transformer.cjs';
|
||||
// Default hook timeout in seconds (Gemini CLI needs time)
|
||||
const HOOK_TIMEOUT_SECONDS = 60;
|
||||
// Path to Claude settings.json
|
||||
const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
|
||||
|
||||
/**
|
||||
* Install WebSearch blocking hook to ~/.ccs/hooks/
|
||||
* Install WebSearch transformer hook to ~/.ccs/hooks/
|
||||
*
|
||||
* This hook blocks native WebSearch and directs Claude to use MCP.
|
||||
* Optional feature - must be explicitly enabled.
|
||||
* This hook intercepts WebSearch and:
|
||||
* 1. Tries Gemini CLI with google_web_search (no API key needed)
|
||||
* 2. Falls back to MCP redirect if Gemini fails
|
||||
*
|
||||
* This is the ultimate solution for third-party profiles.
|
||||
*
|
||||
* @param options - Installation options
|
||||
* @param options.legacy - Install legacy block-websearch.cjs instead (default: false)
|
||||
* @returns true if hook installed successfully
|
||||
*/
|
||||
export function installWebSearchHook(): boolean {
|
||||
export function installWebSearchHook(options?: { legacy?: boolean }): boolean {
|
||||
try {
|
||||
// Ensure hooks directory exists
|
||||
if (!fs.existsSync(CCS_HOOKS_DIR)) {
|
||||
fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK);
|
||||
// Determine which hook to install
|
||||
const hookFileName = options?.legacy ? BLOCK_WEBSEARCH_HOOK : GEMINI_TRANSFORMER_HOOK;
|
||||
const hookPath = path.join(CCS_HOOKS_DIR, hookFileName);
|
||||
|
||||
// Find the bundled hook script
|
||||
// In npm package: node_modules/ccs/lib/hooks/block-websearch.cjs
|
||||
// In development: lib/hooks/block-websearch.cjs
|
||||
// In npm package: node_modules/ccs/lib/hooks/
|
||||
// In development: lib/hooks/
|
||||
const possiblePaths = [
|
||||
path.join(__dirname, '..', '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK),
|
||||
path.join(__dirname, '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK),
|
||||
path.join(__dirname, '..', '..', 'lib', 'hooks', hookFileName),
|
||||
path.join(__dirname, '..', 'lib', 'hooks', hookFileName),
|
||||
];
|
||||
|
||||
let sourcePath: string | null = null;
|
||||
@@ -327,7 +341,7 @@ export function installWebSearchHook(): boolean {
|
||||
|
||||
if (!sourcePath) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn('WebSearch hook source not found'));
|
||||
console.error(warn(`WebSearch hook source not found: ${hookFileName}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -336,10 +350,31 @@ export function installWebSearchHook(): boolean {
|
||||
fs.copyFileSync(sourcePath, hookPath);
|
||||
fs.chmodSync(hookPath, 0o755);
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info(`Installed WebSearch blocking hook to ${hookPath}`));
|
||||
// Also install the legacy hook for backward compatibility
|
||||
if (!options?.legacy) {
|
||||
const legacyHookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK);
|
||||
const legacySourcePaths = [
|
||||
path.join(__dirname, '..', '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK),
|
||||
path.join(__dirname, '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK),
|
||||
];
|
||||
|
||||
for (const p of legacySourcePaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
fs.copyFileSync(p, legacyHookPath);
|
||||
fs.chmodSync(legacyHookPath, 0o755);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info(`Installed WebSearch hook: ${hookPath}`));
|
||||
}
|
||||
|
||||
// Also ensure the hook is configured in settings.json
|
||||
// This is called after the hook file is installed
|
||||
ensureWebSearchHookConfigInternal();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
@@ -352,10 +387,17 @@ export function installWebSearchHook(): boolean {
|
||||
/**
|
||||
* Get WebSearch hook configuration for settings.json
|
||||
*
|
||||
* @returns hook configuration object
|
||||
* Returns config for the Gemini transformer hook (default) or legacy hook.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @param options.legacy - Use legacy block-websearch.cjs instead (default: false)
|
||||
* @returns hook configuration object for settings.json
|
||||
*/
|
||||
export function getWebSearchHookConfig(): Record<string, unknown> {
|
||||
const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK);
|
||||
export function getWebSearchHookConfig(options?: { legacy?: boolean }): Record<string, unknown> {
|
||||
const hookFileName = options?.legacy ? BLOCK_WEBSEARCH_HOOK : GEMINI_TRANSFORMER_HOOK;
|
||||
const hookPath = path.join(CCS_HOOKS_DIR, hookFileName);
|
||||
// Legacy hook only needs 5s, Gemini hook needs 60s for CLI execution
|
||||
const timeout = options?.legacy ? 5 : HOOK_TIMEOUT_SECONDS;
|
||||
|
||||
return {
|
||||
PreToolUse: [
|
||||
@@ -365,7 +407,7 @@ export function getWebSearchHookConfig(): Record<string, unknown> {
|
||||
{
|
||||
type: 'command',
|
||||
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 {
|
||||
const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK);
|
||||
return fs.existsSync(hookPath);
|
||||
const geminiHookPath = path.join(CCS_HOOKS_DIR, GEMINI_TRANSFORMER_HOOK);
|
||||
const legacyHookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK);
|
||||
return fs.existsSync(geminiHookPath) || fs.existsSync(legacyHookPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about installed WebSearch hooks
|
||||
*
|
||||
* @returns Object with hook status details
|
||||
*/
|
||||
export function getWebSearchHookStatus(): {
|
||||
installed: boolean;
|
||||
geminiTransformer: boolean;
|
||||
legacyBlock: boolean;
|
||||
activePath: string | null;
|
||||
} {
|
||||
const geminiHookPath = path.join(CCS_HOOKS_DIR, GEMINI_TRANSFORMER_HOOK);
|
||||
const legacyHookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK);
|
||||
const hasGemini = fs.existsSync(geminiHookPath);
|
||||
const hasLegacy = fs.existsSync(legacyHookPath);
|
||||
|
||||
return {
|
||||
installed: hasGemini || hasLegacy,
|
||||
geminiTransformer: hasGemini,
|
||||
legacyBlock: hasLegacy,
|
||||
// Gemini transformer takes priority
|
||||
activePath: hasGemini ? geminiHookPath : hasLegacy ? legacyHookPath : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for WebSearch hook configuration.
|
||||
*
|
||||
* These env vars are read by the websearch-gemini-transformer.cjs hook
|
||||
* to control its behavior without requiring file I/O.
|
||||
*
|
||||
* @returns Record of environment variables to set before spawning Claude
|
||||
*/
|
||||
export function getWebSearchHookEnv(): Record<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">
|
||||
Preferred Provider
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Primary web search provider to use
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Primary web search provider to use</p>
|
||||
</div>
|
||||
<Select
|
||||
value={config?.provider || 'auto'}
|
||||
@@ -223,7 +221,7 @@ export function SettingsPage() {
|
||||
<div>
|
||||
<p className="font-medium">web-search-prime</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user