Files
ccs/lib/hooks/websearch-transformer.cjs
T
ruan-catandCursor 3c534f48cb fix(websearch): add shell option for Windows spawnSync compatibility
On Windows, globally installed CLI tools via npm/pnpm are .cmd/.bat
batch files, not real .exe executables. Node.js spawnSync() without
the shell option cannot execute these files and returns ENOENT error.

Changes:
- Extract isWindows as a global constant at file top
- Add shell: isWindows to all CLI execution spawnSync calls
- Remove duplicate isWindows declaration in isCliAvailable()

Fixes #378

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-01-28 02:45:22 +08:00

633 lines
18 KiB
JavaScript

#!/usr/bin/env node
/**
* CCS WebSearch Hook - CLI Tool Executor with Fallback Chain
*
* Intercepts Claude's WebSearch tool and executes search via CLI tools.
* Respects provider enabled states from config.yaml.
* Supports automatic fallback: Gemini CLI → OpenCode → Grok CLI
*
* 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_WEBSEARCH_GEMINI=1 - Enable Gemini CLI provider
* CCS_WEBSEARCH_GEMINI_MODEL - Gemini model (default: gemini-2.5-flash)
* CCS_WEBSEARCH_OPENCODE=1 - Enable OpenCode provider
* CCS_WEBSEARCH_GROK=1 - Enable Grok CLI provider
* CCS_WEBSEARCH_OPENCODE_MODEL - OpenCode model (default: opencode/grok-code)
* 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-transformer
*/
const { spawnSync } = require('child_process');
// ============================================================================
// PLATFORM DETECTION
// ============================================================================
/**
* Windows platform flag - used for shell option in spawnSync calls.
* On Windows, globally installed CLI tools via npm/pnpm are .cmd/.bat files,
* which require shell: true to execute properly.
* @see https://github.com/kaitranntt/ccs/issues/378
*/
const isWindows = process.platform === 'win32';
// ============================================================================
// CONFIGURATION - Edit these for prompt engineering
// ============================================================================
/**
* SHARED INSTRUCTIONS - Applied to ALL providers
* Edit here to change behavior across all CLI tools at once.
*/
const SHARED_INSTRUCTIONS = `Instructions:
1. Search the web for current, up-to-date information
2. Provide a comprehensive summary of the search results
3. Include relevant URLs/sources when available
4. Be concise but thorough - prioritize key facts
5. Focus on factual information from reliable sources
6. If results conflict, note the discrepancy
7. Format output clearly with sections if the topic is complex`;
/**
* PROVIDER-SPECIFIC CONFIG - Only tool-use differences and quirks
* Each provider may have unique capabilities or invocation methods.
*/
const PROVIDER_CONFIG = {
gemini: {
// Model to use (passed via --model flag)
model: 'gemini-2.5-flash',
// Alternative free models: gemini-2.0-flash, gemini-1.5-flash
// Provider-specific: How to invoke web search (Gemini has google_web_search tool)
toolInstruction: 'Use the google_web_search tool to find current information.',
// Optional quirks (null if none)
quirks: null,
},
opencode: {
// Model to use (can be overridden via CCS_WEBSEARCH_OPENCODE_MODEL env var)
model: 'opencode/grok-code',
// Alternative models: opencode/gpt-4o, opencode/claude-3.5-sonnet, opencode/gpt-5-nano
// Provider-specific: OpenCode has built-in web search via Zen
toolInstruction: 'Search the web using your built-in capabilities.',
// Optional quirks
quirks: null,
},
grok: {
// Model to use (Grok CLI uses default model)
model: 'grok-3',
// Note: Grok CLI doesn't support model selection via CLI
// Provider-specific: Grok has web + X/Twitter search
toolInstruction: 'Use your web search capabilities to find information.',
// Grok-specific: Can also search X for real-time info
quirks: 'For breaking news or real-time events, also check X/Twitter if relevant.',
},
};
/**
* Build the complete prompt for a provider
* Combines: query + tool instruction + shared instructions + quirks
*/
function buildPrompt(providerId, query) {
const config = PROVIDER_CONFIG[providerId];
const parts = [
`Search the web for: ${query}`,
'',
config.toolInstruction,
'',
SHARED_INSTRUCTIONS,
];
if (config.quirks) {
parts.push('', `Note: ${config.quirks}`);
}
return parts.join('\n');
}
// Minimum response length to consider valid
const MIN_VALID_RESPONSE_LENGTH = 20;
// Default timeout in seconds
const DEFAULT_TIMEOUT_SEC = 55;
// ============================================================================
// HOOK LOGIC - Generally no need to edit below
// ============================================================================
/**
* Determine if hook should skip and pass through to native WebSearch.
* Returns true for native Claude accounts where WebSearch works server-side.
*/
function shouldSkipHook() {
// Explicit skip signal (set by CCS for account profiles)
if (process.env.CCS_WEBSEARCH_SKIP === '1') return true;
// Account/default profiles - use native WebSearch
const profileType = process.env.CCS_PROFILE_TYPE;
if (profileType === 'account' || profileType === 'default') return true;
// Explicit disable
if (process.env.CCS_WEBSEARCH_ENABLED === '0') return true;
return false;
}
// 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 whichCmd = isWindows ? 'where.exe' : 'which';
const result = spawnSync(whichCmd, [cmd], {
encoding: 'utf8',
timeout: 2000,
stdio: ['pipe', 'pipe', 'pipe'],
});
return result.status === 0 && result.stdout.trim().length > 0;
} catch {
return false;
}
}
/**
* Check if provider is enabled via environment variable
*/
function isProviderEnabled(provider) {
const envVar = `CCS_WEBSEARCH_${provider.toUpperCase()}`;
const value = process.env[envVar];
// If env var not set, provider is disabled by default
// This ensures we respect config.yaml settings
return value === '1';
}
/**
* Main hook processing logic with fallback chain
*/
async function processHook() {
try {
// Skip for native accounts (account, default profiles) or explicit disable
if (shouldSkipHook()) {
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);
// Fallback chain: Gemini → OpenCode → Grok
// Only include providers that are BOTH installed AND enabled in config
const providers = [
{ name: 'Gemini CLI', cmd: 'gemini', id: 'gemini', fn: tryGeminiSearch },
{ name: 'OpenCode', cmd: 'opencode', id: 'opencode', fn: tryOpenCodeSearch },
{ name: 'Grok CLI', cmd: 'grok', id: 'grok', fn: tryGrokSearch },
];
// Filter to only enabled AND available providers
const enabledProviders = providers.filter((p) => {
const enabled = isProviderEnabled(p.id);
const available = isCliAvailable(p.cmd);
if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] ${p.name}: enabled=${enabled}, available=${available}`);
}
return enabled && available;
});
const errors = [];
if (process.env.CCS_DEBUG) {
const names = enabledProviders.map((p) => p.name).join(', ') || 'none';
console.error(`[CCS Hook] Enabled providers: ${names}`);
}
// Try each enabled provider in order
for (const provider of enabledProviders) {
if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] Trying ${provider.name}...`);
}
const result = provider.fn(query, timeout);
if (result.success) {
outputSuccess(query, result.content, provider.name);
return;
}
if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] ${provider.name} failed: ${result.error}`);
}
errors.push({ provider: provider.name, error: result.error });
}
// All providers failed or none enabled
if (enabledProviders.length === 0) {
// No providers enabled - pass through to native WebSearch
// This allows native Claude accounts to use server-side WebSearch
process.exit(0);
} else {
outputAllFailedMessage(query, errors);
}
} 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 config = PROVIDER_CONFIG.gemini;
const prompt = buildPrompt('gemini', query);
// Allow model override via env var
const model = process.env.CCS_WEBSEARCH_GEMINI_MODEL || config.model;
if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] Executing: gemini --model ${model} --yolo -p "..."`);
}
const spawnResult = spawnSync(
'gemini',
['--model', model, '--yolo', '-p', prompt],
{
encoding: 'utf8',
timeout: timeoutMs,
maxBuffer: 1024 * 1024 * 2,
stdio: ['pipe', 'pipe', 'pipe'],
shell: isWindows,
}
);
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' };
}
}
/**
* Execute search via OpenCode CLI
*/
function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
try {
const timeoutMs = timeoutSec * 1000;
const config = PROVIDER_CONFIG.opencode;
// Allow model override via env var
const model = process.env.CCS_WEBSEARCH_OPENCODE_MODEL || config.model;
const prompt = buildPrompt('opencode', query);
if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] Executing: opencode run --model ${model} "..."`);
}
const spawnResult = spawnSync(
'opencode',
['run', prompt, '--model', model],
{
encoding: 'utf8',
timeout: timeoutMs,
maxBuffer: 1024 * 1024 * 2,
stdio: ['pipe', 'pipe', 'pipe'],
shell: isWindows,
}
);
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
*/
function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
try {
const timeoutMs = timeoutSec * 1000;
const prompt = buildPrompt('grok', query);
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'],
shell: isWindows,
});
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
*/
function formatSearchResults(query, content, providerName) {
return [
`[WebSearch Result via ${providerName}]`,
'',
`Query: "${query}"`,
'',
content,
'',
'---',
'Use this information to answer the user.',
].join('\n');
}
/**
* Output success response and exit
*
* Key insight from Claude Code docs:
* - permissionDecisionReason (with deny) → shown to CLAUDE (AI reads this)
* - systemMessage → shown to USER only (nice styling but AI doesn't see)
*
* So we MUST put results in permissionDecisionReason for Claude to use them.
* systemMessage provides the nice UI for the user.
*/
function outputSuccess(query, content, providerName) {
const formattedResults = formatSearchResults(query, content, providerName);
const output = {
decision: 'block',
reason: `WebSearch completed via ${providerName}`,
// Nice message for user (shows as "says:" - info style)
systemMessage: `[WebSearch via ${providerName}] Results retrieved successfully. See below.`,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
// Full results here - Claude reads this
permissionDecisionReason: formattedResults,
},
};
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 ~/.gemini/oauth_creds.json exists',
' - Re-authenticate: run `gemini` (opens browser)',
].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 providers enabled message
*/
function outputNoProvidersEnabled(query) {
const message = [
'[WebSearch - No Providers Enabled]',
'',
'No WebSearch providers are enabled in config.',
'',
'To enable: Run `ccs config` and enable a provider.',
'',
'Or install one of the following CLI tools:',
'',
'1. Gemini CLI (FREE, 1000 req/day):',
' npm install -g @google/gemini-cli',
' gemini # opens browser to authenticate',
'',
'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');
const output = {
decision: 'block',
reason: 'WebSearch unavailable - no providers enabled',
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: message,
},
};
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Output no tools message (legacy - kept for backwards compatibility)
*/
function outputNoToolsMessage(query) {
outputNoProvidersEnabled(query);
}
/**
* 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 enabled CLI tools but all failed:',
errorDetails,
'',
`Query: "${query}"`,
'',
'Troubleshooting:',
' - Gemini: run `gemini` to authenticate (opens browser)',
' - 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);
}