mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 14:19:56 +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
|
#!/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.
|
* 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):
|
* Environment Variables (set by CCS):
|
||||||
* CCS_WEBSEARCH_SKIP=1 - Skip this hook entirely (for official Claude)
|
* CCS_WEBSEARCH_SKIP=1 - Skip this hook entirely (for official Claude)
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
* 0 - Allow tool (pass-through to native WebSearch)
|
* 0 - Allow tool (pass-through to native WebSearch)
|
||||||
* 2 - Block tool (deny with results/message)
|
* 2 - Block tool (deny with results/message)
|
||||||
*
|
*
|
||||||
* @module hooks/websearch-gemini-transformer
|
* @module hooks/websearch-transformer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { spawnSync } = require('child_process');
|
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() {
|
async function processHook() {
|
||||||
try {
|
try {
|
||||||
@@ -87,30 +87,47 @@ async function processHook() {
|
|||||||
|
|
||||||
const timeout = parseInt(process.env.CCS_WEBSEARCH_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
const timeout = parseInt(process.env.CCS_WEBSEARCH_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
||||||
|
|
||||||
// Try Gemini CLI (currently the only supported CLI tool)
|
// Fallback chain: Gemini → OpenCode → Grok
|
||||||
if (isCliAvailable('gemini')) {
|
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) {
|
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) {
|
if (result.success) {
|
||||||
outputSuccess(query, result.content, 'Gemini CLI');
|
outputSuccess(query, result.content, provider.name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.CCS_DEBUG) {
|
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
|
errors.push({ provider: provider.name, error: result.error });
|
||||||
outputError(query, result.error, 'Gemini CLI');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No CLI tools available
|
// All providers failed or none available
|
||||||
outputNoToolsMessage(query);
|
if (availableProviders.length === 0) {
|
||||||
|
outputNoToolsMessage(query);
|
||||||
|
} else {
|
||||||
|
outputAllFailedMessage(query, errors);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (process.env.CCS_DEBUG) {
|
if (process.env.CCS_DEBUG) {
|
||||||
console.error('[CCS Hook] Parse error:', err.message);
|
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
|
* Format search results for Claude
|
||||||
*/
|
*/
|
||||||
@@ -265,9 +410,17 @@ function outputNoToolsMessage(query) {
|
|||||||
'',
|
'',
|
||||||
'WebSearch requires a CLI tool to be installed.',
|
'WebSearch requires a CLI tool to be installed.',
|
||||||
'',
|
'',
|
||||||
'Install Gemini CLI (free, uses Google OAuth):',
|
'Install one of the following (in order of preference):',
|
||||||
' npm install -g @google/gemini-cli',
|
'',
|
||||||
' gemini auth login',
|
'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}"`,
|
`Query: "${query}"`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
@@ -285,3 +438,39 @@ function outputNoToolsMessage(query) {
|
|||||||
console.log(JSON.stringify(output));
|
console.log(JSON.stringify(output));
|
||||||
process.exit(2);
|
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('# 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("# Anthropic's WebSearch tool. These CLI tools provide fallback web search.");
|
||||||
lines.push('#');
|
lines.push('#');
|
||||||
|
lines.push('# Fallback chain: Gemini -> OpenCode -> Grok (tries in order until success)');
|
||||||
lines.push('# providers:');
|
lines.push('# providers:');
|
||||||
lines.push('# gemini: Gemini CLI (FREE) - npm i -g @google/gemini-cli');
|
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('# grok: Grok CLI (paid) - npm i -g @vibe-kit/grok-cli (needs GROK_API_KEY)');
|
||||||
lines.push('#');
|
lines.push('#');
|
||||||
lines.push('# enabled: Master switch - auto-disables when no providers enabled');
|
lines.push('# enabled: Master switch - auto-disables when no providers enabled');
|
||||||
|
|||||||
Reference in New Issue
Block a user