mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 22:21:20 +00:00
feat(websearch): add Grok CLI support and improve install guidance
- Add Grok CLI detection (grok-4-cli by lalomorales22) alongside Gemini CLI - Add WebSearch health check group to health-service.ts - Update settings UI to show both Gemini and Grok CLI providers - Add detailed install hints when no WebSearch CLI is installed - Update API routes to return grokCli status - Both CLI and dashboard users now see clear installation guidance Providers: - Gemini CLI: FREE tier (1000 req/day), no API key needed - Grok CLI: Requires xAI API key (XAI_API_KEY), web + X search
This commit is contained in:
@@ -1,333 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CCS WebSearch Gemini Transformer Hook
|
||||
*
|
||||
* Intercepts Claude's WebSearch tool and executes search via Gemini CLI.
|
||||
* This is the ultimate solution for third-party providers that lack
|
||||
* native WebSearch (gemini, agy, codex, qwen profiles).
|
||||
*
|
||||
* Strategy: Execute-and-Deny Pattern
|
||||
* 1. Intercept WebSearch PreToolUse event
|
||||
* 2. Execute `gemini -p` with google_web_search tool
|
||||
* 3. Return deny with search results in permissionDecisionReason
|
||||
* 4. Claude receives results as feedback, continues conversation
|
||||
*
|
||||
* If Gemini CLI fails, falls back to MCP redirect.
|
||||
*
|
||||
* Usage:
|
||||
* Configured in ~/.claude/settings.json:
|
||||
* {
|
||||
* "hooks": {
|
||||
* "PreToolUse": [{
|
||||
* "matcher": "WebSearch",
|
||||
* "hooks": [{
|
||||
* "type": "command",
|
||||
* "command": "node ~/.ccs/hooks/websearch-gemini-transformer.cjs",
|
||||
* "timeout": 60
|
||||
* }]
|
||||
* }]
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Environment variables:
|
||||
* CCS_DEBUG=1 - Enable debug output
|
||||
* CCS_WEBSEARCH_SKIP=1 - Skip this hook entirely (allow WebSearch)
|
||||
* CCS_GEMINI_SKIP=1 - Skip Gemini CLI, use MCP fallback only
|
||||
* CCS_GEMINI_TIMEOUT=55 - Gemini CLI timeout in seconds (default: 55)
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 - Allow tool (pass-through) or deny with results
|
||||
* 2 - Block tool (deny with message)
|
||||
*
|
||||
* @module hooks/websearch-gemini-transformer
|
||||
*/
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
// Minimum response length to consider valid (prevent false positives from error messages)
|
||||
const MIN_VALID_RESPONSE_LENGTH = 20;
|
||||
|
||||
// Read input from stdin
|
||||
let input = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
input += chunk;
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
processHook();
|
||||
});
|
||||
|
||||
// Handle stdin not being available
|
||||
process.stdin.on('error', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Main hook processing logic
|
||||
*/
|
||||
function processHook() {
|
||||
try {
|
||||
// Skip if disabled
|
||||
if (process.env.CCS_WEBSEARCH_SKIP === '1') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const data = JSON.parse(input);
|
||||
|
||||
// Only handle WebSearch tool
|
||||
if (data.tool_name !== 'WebSearch') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const query = data.tool_input?.query || '';
|
||||
|
||||
if (!query) {
|
||||
// No query provided - allow native handling (will fail anyway)
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Check if Gemini CLI is specifically disabled - skip straight to MCP
|
||||
if (process.env.CCS_GEMINI_SKIP === '1') {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[CCS Hook] Gemini CLI disabled, using MCP fallback');
|
||||
}
|
||||
outputMcpFallback(query, 'Gemini CLI disabled by configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try Gemini CLI first
|
||||
const geminiResult = tryGeminiSearch(query);
|
||||
|
||||
if (geminiResult.success) {
|
||||
// Success! Use deny with clear success messaging
|
||||
// Note: "deny" prevents native WebSearch from running (which would fail anyway)
|
||||
// The permissionDecisionReason contains the actual search results
|
||||
const output = {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: formatGeminiResults(query, geminiResult.content),
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Gemini failed - fall back to MCP redirect
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[CCS Hook] Gemini failed: ${geminiResult.error}, falling back to MCP`);
|
||||
}
|
||||
|
||||
outputMcpFallback(query, geminiResult.error);
|
||||
} catch (err) {
|
||||
// Don't block on parse errors - allow tool to proceed
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[CCS Hook] Parse error:', err.message);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to execute search via Gemini CLI
|
||||
*
|
||||
* @param {string} query - Search query
|
||||
* @returns {{ success: boolean, content?: string, error?: string }}
|
||||
*/
|
||||
function tryGeminiSearch(query) {
|
||||
try {
|
||||
// Get timeout from env or default to 55 seconds (under 60s hook timeout)
|
||||
const timeoutSec = parseInt(process.env.CCS_GEMINI_TIMEOUT || '55', 10);
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
|
||||
// Build prompt for Gemini with explicit instruction to use web search
|
||||
const prompt = buildGeminiPrompt(query);
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[CCS Hook] Executing: gemini --model gemini-2.5-flash --yolo -p "..."`);
|
||||
}
|
||||
|
||||
// Execute gemini CLI with required flags:
|
||||
// --model gemini-2.5-flash: Use the flash model for fast responses
|
||||
// --yolo: Skip confirmation prompts for tool use
|
||||
// -p: Provide prompt
|
||||
const spawnResult = spawnSync('gemini', [
|
||||
'--model', 'gemini-2.5-flash',
|
||||
'--yolo',
|
||||
'-p', prompt
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 1024 * 1024 * 2, // 2MB buffer for large responses
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Handle spawn errors
|
||||
if (spawnResult.error) {
|
||||
throw spawnResult.error;
|
||||
}
|
||||
|
||||
// Check for non-zero exit code
|
||||
if (spawnResult.status !== 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Gemini CLI exited with code ${spawnResult.status}: ${(spawnResult.stderr || '').substring(0, 200)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = spawnResult.stdout || '';
|
||||
const trimmedResult = result.trim();
|
||||
|
||||
// Check if result looks like an error or empty
|
||||
if (!trimmedResult || trimmedResult.length < MIN_VALID_RESPONSE_LENGTH) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Empty or too short response from Gemini',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for common error patterns
|
||||
const lowerResult = trimmedResult.toLowerCase();
|
||||
if (
|
||||
lowerResult.includes('error:') ||
|
||||
lowerResult.includes('failed to') ||
|
||||
lowerResult.includes('unable to') ||
|
||||
lowerResult.includes('authentication required')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Gemini returned error: ${trimmedResult.substring(0, 100)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: trimmedResult,
|
||||
};
|
||||
} catch (err) {
|
||||
// Handle different error types
|
||||
if (err.killed) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Gemini CLI timed out',
|
||||
};
|
||||
}
|
||||
|
||||
if (err.code === 'ENOENT') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Gemini CLI not installed. Install with: npm install -g @google/gemini-cli',
|
||||
};
|
||||
}
|
||||
|
||||
// Check stderr for more details
|
||||
if (err.stderr) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Gemini CLI error: ${err.stderr.substring(0, 200)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.message || 'Unknown error executing Gemini CLI',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prompt for Gemini CLI
|
||||
* Instructs Gemini to use google_web_search tool for the query
|
||||
*
|
||||
* @param {string} query - Original search query
|
||||
* @returns {string} Formatted prompt
|
||||
*/
|
||||
function buildGeminiPrompt(query) {
|
||||
return [
|
||||
`Search the web for: ${query}`,
|
||||
'',
|
||||
'Instructions:',
|
||||
'1. Use the google_web_search tool to find current information',
|
||||
'2. Provide a comprehensive summary of the search results',
|
||||
'3. Include relevant URLs/sources when available',
|
||||
'4. Be concise but thorough',
|
||||
'5. Focus on factual, up-to-date information',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Gemini search results for Claude
|
||||
*
|
||||
* @param {string} query - Original query
|
||||
* @param {string} content - Gemini response content
|
||||
* @returns {string} Formatted message for Claude
|
||||
*/
|
||||
function formatGeminiResults(query, content) {
|
||||
return [
|
||||
'=== WEBSEARCH COMPLETED SUCCESSFULLY ===',
|
||||
'(via Gemini CLI - this is NOT an error)',
|
||||
'',
|
||||
`Query: "${query}"`,
|
||||
'',
|
||||
content,
|
||||
'',
|
||||
'=========================================',
|
||||
'Use this information to answer the user. Search again if needed.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP fallback message when Gemini fails
|
||||
*
|
||||
* @param {string} query - Original query
|
||||
* @param {string} error - Error message from Gemini attempt
|
||||
* @returns {string} Message directing Claude to use MCP
|
||||
*/
|
||||
function getMcpFallbackMessage(query, error) {
|
||||
return [
|
||||
'[WebSearch Unavailable - Use MCP Fallback]',
|
||||
'',
|
||||
`Gemini CLI failed: ${error || 'Unknown error'}`,
|
||||
'',
|
||||
'To enable Gemini CLI WebSearch:',
|
||||
' 1. Install: npm install -g @google/gemini-cli',
|
||||
' 2. Authenticate: gemini auth',
|
||||
'',
|
||||
'The native WebSearch tool is not available with your current provider.',
|
||||
'Please use one of the following MCP tools instead:',
|
||||
'',
|
||||
'1. mcp__web-search-prime__webSearchPrime (requires z.ai subscription)',
|
||||
'2. mcp__brave-search__brave_web_search (if configured)',
|
||||
'3. mcp__tavily__search (if configured)',
|
||||
'',
|
||||
`Use the same query: "${query}"`,
|
||||
'',
|
||||
'Example:',
|
||||
'```',
|
||||
'mcp__web-search-prime__webSearchPrime',
|
||||
`search_query: "${query}"`,
|
||||
'```',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Output MCP fallback response and exit
|
||||
*
|
||||
* @param {string} query - Original query
|
||||
* @param {string} error - Error message
|
||||
*/
|
||||
function outputMcpFallback(query, error) {
|
||||
const output = {
|
||||
decision: 'block',
|
||||
reason: 'WebSearch unavailable - use MCP fallback',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: getMcpFallbackMessage(query, error),
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CCS WebSearch Hook - CLI Tool Executor
|
||||
*
|
||||
* Intercepts Claude's WebSearch tool and executes search via CLI tools.
|
||||
* Currently supports Gemini CLI, designed for easy addition of future tools.
|
||||
*
|
||||
* Environment Variables (set by CCS):
|
||||
* CCS_WEBSEARCH_SKIP=1 - Skip this hook entirely (for official Claude)
|
||||
* CCS_WEBSEARCH_ENABLED=1 - Enable WebSearch (default: 1)
|
||||
* CCS_WEBSEARCH_TIMEOUT=55 - Timeout in seconds (default: 55)
|
||||
* CCS_DEBUG=1 - Enable debug output
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 - Allow tool (pass-through to native WebSearch)
|
||||
* 2 - Block tool (deny with results/message)
|
||||
*
|
||||
* @module hooks/websearch-gemini-transformer
|
||||
*/
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
// Minimum response length to consider valid
|
||||
const MIN_VALID_RESPONSE_LENGTH = 20;
|
||||
|
||||
// Default timeout in seconds
|
||||
const DEFAULT_TIMEOUT_SEC = 55;
|
||||
|
||||
// Read input from stdin
|
||||
let input = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
input += chunk;
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
processHook();
|
||||
});
|
||||
|
||||
// Handle stdin not being available
|
||||
process.stdin.on('error', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if a CLI tool is available
|
||||
*/
|
||||
function isCliAvailable(cmd) {
|
||||
try {
|
||||
const result = spawnSync('which', [cmd], {
|
||||
encoding: 'utf8',
|
||||
timeout: 2000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
return result.status === 0 && result.stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main hook processing logic
|
||||
*/
|
||||
async function processHook() {
|
||||
try {
|
||||
// Skip if disabled (for official Claude subscriptions)
|
||||
if (process.env.CCS_WEBSEARCH_SKIP === '1') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Check if enabled (default: enabled)
|
||||
if (process.env.CCS_WEBSEARCH_ENABLED === '0') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const data = JSON.parse(input);
|
||||
|
||||
// Only handle WebSearch tool
|
||||
if (data.tool_name !== 'WebSearch') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const query = data.tool_input?.query || '';
|
||||
|
||||
if (!query) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const timeout = parseInt(process.env.CCS_WEBSEARCH_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
||||
|
||||
// Try Gemini CLI (currently the only supported CLI tool)
|
||||
if (isCliAvailable('gemini')) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[CCS Hook] Executing Gemini CLI...');
|
||||
}
|
||||
|
||||
const result = tryGeminiSearch(query, timeout);
|
||||
|
||||
if (result.success) {
|
||||
outputSuccess(query, result.content, 'Gemini CLI');
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[CCS Hook] Gemini failed: ${result.error}`);
|
||||
}
|
||||
|
||||
// Gemini failed - show error message
|
||||
outputError(query, result.error, 'Gemini CLI');
|
||||
return;
|
||||
}
|
||||
|
||||
// No CLI tools available
|
||||
outputNoToolsMessage(query);
|
||||
} catch (err) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[CCS Hook] Parse error:', err.message);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute search via Gemini CLI
|
||||
*/
|
||||
function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||
try {
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
|
||||
const prompt = [
|
||||
`Search the web for: ${query}`,
|
||||
'',
|
||||
'Instructions:',
|
||||
'1. Use the google_web_search tool to find current information',
|
||||
'2. Provide a comprehensive summary of the search results',
|
||||
'3. Include relevant URLs/sources when available',
|
||||
'4. Be concise but thorough',
|
||||
'5. Focus on factual, up-to-date information',
|
||||
].join('\n');
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[CCS Hook] Executing: gemini --model gemini-2.5-flash --yolo -p "..."');
|
||||
}
|
||||
|
||||
const spawnResult = spawnSync(
|
||||
'gemini',
|
||||
['--model', 'gemini-2.5-flash', '--yolo', '-p', prompt],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 1024 * 1024 * 2,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}
|
||||
);
|
||||
|
||||
if (spawnResult.error) {
|
||||
if (spawnResult.error.code === 'ENOENT') {
|
||||
return { success: false, error: 'Gemini CLI not installed' };
|
||||
}
|
||||
throw spawnResult.error;
|
||||
}
|
||||
|
||||
if (spawnResult.status !== 0) {
|
||||
const stderr = (spawnResult.stderr || '').trim();
|
||||
return {
|
||||
success: false,
|
||||
error: stderr || `Gemini CLI exited with code ${spawnResult.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = (spawnResult.stdout || '').trim();
|
||||
|
||||
if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) {
|
||||
return { success: false, error: 'Empty or too short response from Gemini' };
|
||||
}
|
||||
|
||||
const lowerResult = result.toLowerCase();
|
||||
if (
|
||||
lowerResult.includes('error:') ||
|
||||
lowerResult.includes('failed to') ||
|
||||
lowerResult.includes('authentication required')
|
||||
) {
|
||||
return { success: false, error: `Gemini returned error: ${result.substring(0, 100)}` };
|
||||
}
|
||||
|
||||
return { success: true, content: result };
|
||||
} catch (err) {
|
||||
if (err.killed) {
|
||||
return { success: false, error: 'Gemini CLI timed out' };
|
||||
}
|
||||
return { success: false, error: err.message || 'Unknown Gemini error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search results for Claude
|
||||
*/
|
||||
function formatSearchResults(query, content, providerName) {
|
||||
return [
|
||||
'=== WEBSEARCH COMPLETED SUCCESSFULLY ===',
|
||||
`(via ${providerName} - this is NOT an error)`,
|
||||
'',
|
||||
`Query: "${query}"`,
|
||||
'',
|
||||
content,
|
||||
'',
|
||||
'=========================================',
|
||||
'Use this information to answer the user. Search again if needed.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Output success response and exit
|
||||
*/
|
||||
function outputSuccess(query, content, providerName) {
|
||||
const output = {
|
||||
decision: 'block',
|
||||
reason: `WebSearch completed via ${providerName}`,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: formatSearchResults(query, content, providerName),
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output error message
|
||||
*/
|
||||
function outputError(query, error, providerName) {
|
||||
const message = [
|
||||
`[WebSearch - ${providerName} Error]`,
|
||||
'',
|
||||
`Error: ${error}`,
|
||||
'',
|
||||
`Query: "${query}"`,
|
||||
'',
|
||||
'Troubleshooting:',
|
||||
' - Check if Gemini CLI is authenticated: gemini auth status',
|
||||
' - Re-authenticate if needed: gemini auth login',
|
||||
].join('\n');
|
||||
|
||||
const output = {
|
||||
decision: 'block',
|
||||
reason: `WebSearch failed: ${error}`,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: message,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output no tools message
|
||||
*/
|
||||
function outputNoToolsMessage(query) {
|
||||
const message = [
|
||||
'[WebSearch - No CLI Tools Available]',
|
||||
'',
|
||||
'WebSearch requires a CLI tool to be installed.',
|
||||
'',
|
||||
'Install Gemini CLI (free, uses Google OAuth):',
|
||||
' npm install -g @google/gemini-cli',
|
||||
' gemini auth login',
|
||||
'',
|
||||
`Query: "${query}"`,
|
||||
].join('\n');
|
||||
|
||||
const output = {
|
||||
decision: 'block',
|
||||
reason: 'WebSearch unavailable - no CLI tools installed',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: message,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
Reference in New Issue
Block a user