feat(websearch): implement fallback chain for CLI providers

Add automatic fallback chain (Gemini -> OpenCode -> Grok) when multiple
WebSearch CLI providers are available. The hook now tries each provider
in order until one succeeds.

Changes:
- Update websearch-transformer.cjs with tryOpenCodeSearch() and tryGrokSearch()
- Implement fallback chain logic in processHook()
- Add outputAllFailedMessage() for when all providers fail
- Update outputNoToolsMessage() with all three install options
- Update config.yaml comments to document OpenCode and fallback behavior
This commit is contained in:
kaitranntt
2025-12-16 21:42:49 -05:00
parent 482cda0f8e
commit e6aa8ac453
2 changed files with 209 additions and 18 deletions
+207 -18
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env node
/**
* CCS WebSearch Hook - CLI Tool Executor
* CCS WebSearch Hook - CLI Tool Executor with Fallback Chain
*
* Intercepts Claude's WebSearch tool and executes search via CLI tools.
* Currently supports Gemini CLI, designed for easy addition of future tools.
* Supports automatic fallback: Gemini CLI → OpenCode → Grok CLI
*
* Environment Variables (set by CCS):
* CCS_WEBSEARCH_SKIP=1 - Skip this hook entirely (for official Claude)
@@ -15,7 +15,7 @@
* 0 - Allow tool (pass-through to native WebSearch)
* 2 - Block tool (deny with results/message)
*
* @module hooks/websearch-gemini-transformer
* @module hooks/websearch-transformer
*/
const { spawnSync } = require('child_process');
@@ -58,7 +58,7 @@ function isCliAvailable(cmd) {
}
/**
* Main hook processing logic
* Main hook processing logic with fallback chain
*/
async function processHook() {
try {
@@ -87,30 +87,47 @@ async function processHook() {
const timeout = parseInt(process.env.CCS_WEBSEARCH_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
// Try Gemini CLI (currently the only supported CLI tool)
if (isCliAvailable('gemini')) {
// Fallback chain: Gemini → OpenCode → Grok
const providers = [
{ name: 'Gemini CLI', cmd: 'gemini', fn: tryGeminiSearch },
{ name: 'OpenCode', cmd: 'opencode', fn: tryOpenCodeSearch },
{ name: 'Grok CLI', cmd: 'grok', fn: tryGrokSearch },
];
const availableProviders = providers.filter((p) => isCliAvailable(p.cmd));
const errors = [];
if (process.env.CCS_DEBUG) {
const available = availableProviders.map((p) => p.name).join(', ') || 'none';
console.error(`[CCS Hook] Available providers: ${available}`);
}
// Try each available provider in order
for (const provider of availableProviders) {
if (process.env.CCS_DEBUG) {
console.error('[CCS Hook] Executing Gemini CLI...');
console.error(`[CCS Hook] Trying ${provider.name}...`);
}
const result = tryGeminiSearch(query, timeout);
const result = provider.fn(query, timeout);
if (result.success) {
outputSuccess(query, result.content, 'Gemini CLI');
outputSuccess(query, result.content, provider.name);
return;
}
if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] Gemini failed: ${result.error}`);
console.error(`[CCS Hook] ${provider.name} failed: ${result.error}`);
}
// Gemini failed - show error message
outputError(query, result.error, 'Gemini CLI');
return;
errors.push({ provider: provider.name, error: result.error });
}
// No CLI tools available
outputNoToolsMessage(query);
// All providers failed or none available
if (availableProviders.length === 0) {
outputNoToolsMessage(query);
} else {
outputAllFailedMessage(query, errors);
}
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[CCS Hook] Parse error:', err.message);
@@ -191,6 +208,134 @@ function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
}
}
/**
* Execute search via OpenCode CLI
* Uses opencode run with gpt-5-nano model for web search
*/
function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
try {
const timeoutMs = timeoutSec * 1000;
const prompt = `Search the web for: ${query}
Provide a comprehensive summary with relevant URLs/sources.`;
if (process.env.CCS_DEBUG) {
console.error('[CCS Hook] Executing: opencode run --model opencode/gpt-5-nano "..."');
}
const spawnResult = spawnSync(
'opencode',
['run', prompt, '--model', 'opencode/gpt-5-nano'],
{
encoding: 'utf8',
timeout: timeoutMs,
maxBuffer: 1024 * 1024 * 2,
stdio: ['pipe', 'pipe', 'pipe'],
}
);
if (spawnResult.error) {
if (spawnResult.error.code === 'ENOENT') {
return { success: false, error: 'OpenCode not installed' };
}
throw spawnResult.error;
}
if (spawnResult.status !== 0) {
const stderr = (spawnResult.stderr || '').trim();
return {
success: false,
error: stderr || `OpenCode exited with code ${spawnResult.status}`,
};
}
const result = (spawnResult.stdout || '').trim();
if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) {
return { success: false, error: 'Empty or too short response from OpenCode' };
}
const lowerResult = result.toLowerCase();
if (
lowerResult.includes('error:') ||
lowerResult.includes('failed to') ||
lowerResult.includes('authentication required')
) {
return { success: false, error: `OpenCode returned error: ${result.substring(0, 100)}` };
}
return { success: true, content: result };
} catch (err) {
if (err.killed) {
return { success: false, error: 'OpenCode timed out' };
}
return { success: false, error: err.message || 'Unknown OpenCode error' };
}
}
/**
* Execute search via Grok CLI
* Uses grok command for web search (requires GROK_API_KEY)
*/
function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
try {
const timeoutMs = timeoutSec * 1000;
const prompt = `Search the web for: ${query}
Provide a comprehensive summary with relevant URLs/sources.`;
if (process.env.CCS_DEBUG) {
console.error('[CCS Hook] Executing: grok "..."');
}
const spawnResult = spawnSync('grok', [prompt], {
encoding: 'utf8',
timeout: timeoutMs,
maxBuffer: 1024 * 1024 * 2,
stdio: ['pipe', 'pipe', 'pipe'],
});
if (spawnResult.error) {
if (spawnResult.error.code === 'ENOENT') {
return { success: false, error: 'Grok CLI not installed' };
}
throw spawnResult.error;
}
if (spawnResult.status !== 0) {
const stderr = (spawnResult.stderr || '').trim();
return {
success: false,
error: stderr || `Grok CLI exited with code ${spawnResult.status}`,
};
}
const result = (spawnResult.stdout || '').trim();
if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) {
return { success: false, error: 'Empty or too short response from Grok' };
}
const lowerResult = result.toLowerCase();
if (
lowerResult.includes('error:') ||
lowerResult.includes('failed to') ||
lowerResult.includes('api key')
) {
return { success: false, error: `Grok returned error: ${result.substring(0, 100)}` };
}
return { success: true, content: result };
} catch (err) {
if (err.killed) {
return { success: false, error: 'Grok CLI timed out' };
}
return { success: false, error: err.message || 'Unknown Grok error' };
}
}
/**
* Format search results for Claude
*/
@@ -265,9 +410,17 @@ function outputNoToolsMessage(query) {
'',
'WebSearch requires a CLI tool to be installed.',
'',
'Install Gemini CLI (free, uses Google OAuth):',
' npm install -g @google/gemini-cli',
' gemini auth login',
'Install one of the following (in order of preference):',
'',
'1. Gemini CLI (FREE, 1000 req/day):',
' npm install -g @google/gemini-cli',
' gemini auth login',
'',
'2. OpenCode (FREE via Zen):',
' curl -fsSL https://opencode.ai/install | bash',
'',
'3. Grok CLI (requires XAI_API_KEY):',
' npm install -g @vibe-kit/grok-cli',
'',
`Query: "${query}"`,
].join('\n');
@@ -285,3 +438,39 @@ function outputNoToolsMessage(query) {
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Output all providers failed message
*/
function outputAllFailedMessage(query, errors) {
const errorDetails = errors
.map((e) => ` - ${e.provider}: ${e.error}`)
.join('\n');
const message = [
'[WebSearch - All Providers Failed]',
'',
'Tried all available CLI tools but all failed:',
errorDetails,
'',
`Query: "${query}"`,
'',
'Troubleshooting:',
' - Gemini: gemini auth status / gemini auth login',
' - OpenCode: opencode --version',
' - Grok: Check XAI_API_KEY environment variable',
].join('\n');
const output = {
decision: 'block',
reason: 'WebSearch failed - all providers failed',
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: message,
},
};
console.log(JSON.stringify(output));
process.exit(2);
}
+2
View File
@@ -267,8 +267,10 @@ function generateYamlWithComments(config: UnifiedConfig): string {
lines.push('# Third-party providers (gemini, codex, agy, etc.) do not have access to');
lines.push("# Anthropic's WebSearch tool. These CLI tools provide fallback web search.");
lines.push('#');
lines.push('# Fallback chain: Gemini -> OpenCode -> Grok (tries in order until success)');
lines.push('# providers:');
lines.push('# gemini: Gemini CLI (FREE) - npm i -g @google/gemini-cli');
lines.push('# opencode: OpenCode (FREE) - curl -fsSL https://opencode.ai/install | bash');
lines.push('# grok: Grok CLI (paid) - npm i -g @vibe-kit/grok-cli (needs GROK_API_KEY)');
lines.push('#');
lines.push('# enabled: Master switch - auto-disables when no providers enabled');