mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user