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:
kaitranntt
2025-12-16 02:49:07 -05:00
parent 071ec041ed
commit fd99ebca98
8 changed files with 635 additions and 35 deletions
+68 -7
View File
@@ -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
+324
View File
@@ -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
View File
@@ -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') {
+5 -1
View File
@@ -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}`);
+8
View File
@@ -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,
},
};
}
+15
View File
@@ -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
View File
@@ -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;
}
}
+2 -4
View File
@@ -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>