mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-21 22:23:34 +00:00
feat(websearch): respect config provider settings and consolidate prompts
Major refactor of WebSearch hook to: 1. **Respect config.yaml settings**: Hook now reads CCS_WEBSEARCH_GEMINI, CCS_WEBSEARCH_OPENCODE, CCS_WEBSEARCH_GROK env vars to determine which providers to use. Only enabled AND installed providers are tried. 2. **Consolidate prompts/models**: Added PROVIDER_CONFIG section at top of hook file for easy prompt engineering: - gemini: model + prompt template - opencode: model (overridable via env) + prompt template - grok: model + prompt template 3. **Pass provider states via env**: Updated getWebSearchHookEnv() to pass individual provider enabled states as env vars. Breaking change: Hook no longer falls back to all installed CLIs. It now strictly respects config.yaml settings.
This commit is contained in:
@@ -3,13 +3,18 @@
|
|||||||
* CCS WebSearch Hook - CLI Tool Executor with Fallback Chain
|
* 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.
|
||||||
|
* Respects provider enabled states from config.yaml.
|
||||||
* Supports automatic fallback: Gemini CLI → OpenCode → Grok CLI
|
* 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)
|
||||||
* CCS_WEBSEARCH_ENABLED=1 - Enable WebSearch (default: 1)
|
* CCS_WEBSEARCH_ENABLED=1 - Enable WebSearch (default: 1)
|
||||||
* CCS_WEBSEARCH_TIMEOUT=55 - Timeout in seconds (default: 55)
|
* CCS_WEBSEARCH_TIMEOUT=55 - Timeout in seconds (default: 55)
|
||||||
* CCS_DEBUG=1 - Enable debug output
|
* CCS_WEBSEARCH_GEMINI=1 - Enable Gemini CLI provider
|
||||||
|
* CCS_WEBSEARCH_OPENCODE=1 - Enable OpenCode provider
|
||||||
|
* CCS_WEBSEARCH_GROK=1 - Enable Grok CLI provider
|
||||||
|
* CCS_WEBSEARCH_OPENCODE_MODEL - OpenCode model (default: opencode/gpt-5-nano)
|
||||||
|
* CCS_DEBUG=1 - Enable debug output
|
||||||
*
|
*
|
||||||
* Exit codes:
|
* Exit codes:
|
||||||
* 0 - Allow tool (pass-through to native WebSearch)
|
* 0 - Allow tool (pass-through to native WebSearch)
|
||||||
@@ -20,12 +25,64 @@
|
|||||||
|
|
||||||
const { spawnSync } = require('child_process');
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONFIGURATION - Edit these for prompt engineering
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider configurations - models and prompts for each CLI tool.
|
||||||
|
* Edit these to customize search behavior.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
|
||||||
|
// Prompt template - {query} will be replaced with search query
|
||||||
|
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`,
|
||||||
|
},
|
||||||
|
|
||||||
|
opencode: {
|
||||||
|
// Model to use (can be overridden via CCS_WEBSEARCH_OPENCODE_MODEL env var)
|
||||||
|
model: 'opencode/gpt-5-nano',
|
||||||
|
// Alternative models: opencode/gpt-4o, opencode/claude-3.5-sonnet
|
||||||
|
|
||||||
|
// Prompt template
|
||||||
|
prompt: `Search the web for: {query}
|
||||||
|
|
||||||
|
Provide a comprehensive summary with relevant URLs/sources.`,
|
||||||
|
},
|
||||||
|
|
||||||
|
grok: {
|
||||||
|
// Model to use (Grok CLI uses default model)
|
||||||
|
model: 'grok-3',
|
||||||
|
// Note: Grok CLI doesn't support model selection via CLI
|
||||||
|
|
||||||
|
// Prompt template
|
||||||
|
prompt: `Search the web for: {query}
|
||||||
|
|
||||||
|
Provide a comprehensive summary with relevant URLs/sources.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Minimum response length to consider valid
|
// Minimum response length to consider valid
|
||||||
const MIN_VALID_RESPONSE_LENGTH = 20;
|
const MIN_VALID_RESPONSE_LENGTH = 20;
|
||||||
|
|
||||||
// Default timeout in seconds
|
// Default timeout in seconds
|
||||||
const DEFAULT_TIMEOUT_SEC = 55;
|
const DEFAULT_TIMEOUT_SEC = 55;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HOOK LOGIC - Generally no need to edit below
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
// Read input from stdin
|
// Read input from stdin
|
||||||
let input = '';
|
let input = '';
|
||||||
process.stdin.setEncoding('utf8');
|
process.stdin.setEncoding('utf8');
|
||||||
@@ -57,6 +114,18 @@ function isCliAvailable(cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Main hook processing logic with fallback chain
|
||||||
*/
|
*/
|
||||||
@@ -88,22 +157,34 @@ 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);
|
||||||
|
|
||||||
// Fallback chain: Gemini → OpenCode → Grok
|
// Fallback chain: Gemini → OpenCode → Grok
|
||||||
|
// Only include providers that are BOTH installed AND enabled in config
|
||||||
const providers = [
|
const providers = [
|
||||||
{ name: 'Gemini CLI', cmd: 'gemini', fn: tryGeminiSearch },
|
{ name: 'Gemini CLI', cmd: 'gemini', id: 'gemini', fn: tryGeminiSearch },
|
||||||
{ name: 'OpenCode', cmd: 'opencode', fn: tryOpenCodeSearch },
|
{ name: 'OpenCode', cmd: 'opencode', id: 'opencode', fn: tryOpenCodeSearch },
|
||||||
{ name: 'Grok CLI', cmd: 'grok', fn: tryGrokSearch },
|
{ name: 'Grok CLI', cmd: 'grok', id: 'grok', fn: tryGrokSearch },
|
||||||
];
|
];
|
||||||
|
|
||||||
const availableProviders = providers.filter((p) => isCliAvailable(p.cmd));
|
// 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 = [];
|
const errors = [];
|
||||||
|
|
||||||
if (process.env.CCS_DEBUG) {
|
if (process.env.CCS_DEBUG) {
|
||||||
const available = availableProviders.map((p) => p.name).join(', ') || 'none';
|
const names = enabledProviders.map((p) => p.name).join(', ') || 'none';
|
||||||
console.error(`[CCS Hook] Available providers: ${available}`);
|
console.error(`[CCS Hook] Enabled providers: ${names}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try each available provider in order
|
// Try each enabled provider in order
|
||||||
for (const provider of availableProviders) {
|
for (const provider of enabledProviders) {
|
||||||
if (process.env.CCS_DEBUG) {
|
if (process.env.CCS_DEBUG) {
|
||||||
console.error(`[CCS Hook] Trying ${provider.name}...`);
|
console.error(`[CCS Hook] Trying ${provider.name}...`);
|
||||||
}
|
}
|
||||||
@@ -122,9 +203,9 @@ async function processHook() {
|
|||||||
errors.push({ provider: provider.name, error: result.error });
|
errors.push({ provider: provider.name, error: result.error });
|
||||||
}
|
}
|
||||||
|
|
||||||
// All providers failed or none available
|
// All providers failed or none enabled
|
||||||
if (availableProviders.length === 0) {
|
if (enabledProviders.length === 0) {
|
||||||
outputNoToolsMessage(query);
|
outputNoProvidersEnabled(query);
|
||||||
} else {
|
} else {
|
||||||
outputAllFailedMessage(query, errors);
|
outputAllFailedMessage(query, errors);
|
||||||
}
|
}
|
||||||
@@ -142,25 +223,16 @@ async function processHook() {
|
|||||||
function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||||
try {
|
try {
|
||||||
const timeoutMs = timeoutSec * 1000;
|
const timeoutMs = timeoutSec * 1000;
|
||||||
|
const config = PROVIDER_CONFIG.gemini;
|
||||||
const prompt = [
|
const prompt = config.prompt.replace('{query}', query);
|
||||||
`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) {
|
if (process.env.CCS_DEBUG) {
|
||||||
console.error('[CCS Hook] Executing: gemini --model gemini-2.5-flash --yolo -p "..."');
|
console.error(`[CCS Hook] Executing: gemini --model ${config.model} --yolo -p "..."`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const spawnResult = spawnSync(
|
const spawnResult = spawnSync(
|
||||||
'gemini',
|
'gemini',
|
||||||
['--model', 'gemini-2.5-flash', '--yolo', '-p', prompt],
|
['--model', config.model, '--yolo', '-p', prompt],
|
||||||
{
|
{
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
@@ -210,23 +282,23 @@ function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute search via OpenCode CLI
|
* Execute search via OpenCode CLI
|
||||||
* Uses opencode run with gpt-5-nano model for web search
|
|
||||||
*/
|
*/
|
||||||
function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||||
try {
|
try {
|
||||||
const timeoutMs = timeoutSec * 1000;
|
const timeoutMs = timeoutSec * 1000;
|
||||||
|
const config = PROVIDER_CONFIG.opencode;
|
||||||
|
|
||||||
const prompt = `Search the web for: ${query}
|
// Allow model override via env var
|
||||||
|
const model = process.env.CCS_WEBSEARCH_OPENCODE_MODEL || config.model;
|
||||||
Provide a comprehensive summary with relevant URLs/sources.`;
|
const prompt = config.prompt.replace('{query}', query);
|
||||||
|
|
||||||
if (process.env.CCS_DEBUG) {
|
if (process.env.CCS_DEBUG) {
|
||||||
console.error('[CCS Hook] Executing: opencode run --model opencode/gpt-5-nano "..."');
|
console.error(`[CCS Hook] Executing: opencode run --model ${model} "..."`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const spawnResult = spawnSync(
|
const spawnResult = spawnSync(
|
||||||
'opencode',
|
'opencode',
|
||||||
['run', prompt, '--model', 'opencode/gpt-5-nano'],
|
['run', prompt, '--model', model],
|
||||||
{
|
{
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
@@ -276,15 +348,12 @@ Provide a comprehensive summary with relevant URLs/sources.`;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute search via Grok CLI
|
* Execute search via Grok CLI
|
||||||
* Uses grok command for web search (requires GROK_API_KEY)
|
|
||||||
*/
|
*/
|
||||||
function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||||
try {
|
try {
|
||||||
const timeoutMs = timeoutSec * 1000;
|
const timeoutMs = timeoutSec * 1000;
|
||||||
|
const config = PROVIDER_CONFIG.grok;
|
||||||
const prompt = `Search the web for: ${query}
|
const prompt = config.prompt.replace('{query}', query);
|
||||||
|
|
||||||
Provide a comprehensive summary with relevant URLs/sources.`;
|
|
||||||
|
|
||||||
if (process.env.CCS_DEBUG) {
|
if (process.env.CCS_DEBUG) {
|
||||||
console.error('[CCS Hook] Executing: grok "..."');
|
console.error('[CCS Hook] Executing: grok "..."');
|
||||||
@@ -402,15 +471,17 @@ function outputError(query, error, providerName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Output no tools message
|
* Output no providers enabled message
|
||||||
*/
|
*/
|
||||||
function outputNoToolsMessage(query) {
|
function outputNoProvidersEnabled(query) {
|
||||||
const message = [
|
const message = [
|
||||||
'[WebSearch - No CLI Tools Available]',
|
'[WebSearch - No Providers Enabled]',
|
||||||
'',
|
'',
|
||||||
'WebSearch requires a CLI tool to be installed.',
|
'No WebSearch providers are enabled in config.',
|
||||||
'',
|
'',
|
||||||
'Install one of the following (in order of preference):',
|
'To enable: Run `ccs config` and enable a provider.',
|
||||||
|
'',
|
||||||
|
'Or install one of the following CLI tools:',
|
||||||
'',
|
'',
|
||||||
'1. Gemini CLI (FREE, 1000 req/day):',
|
'1. Gemini CLI (FREE, 1000 req/day):',
|
||||||
' npm install -g @google/gemini-cli',
|
' npm install -g @google/gemini-cli',
|
||||||
@@ -427,7 +498,7 @@ function outputNoToolsMessage(query) {
|
|||||||
|
|
||||||
const output = {
|
const output = {
|
||||||
decision: 'block',
|
decision: 'block',
|
||||||
reason: 'WebSearch unavailable - no CLI tools installed',
|
reason: 'WebSearch unavailable - no providers enabled',
|
||||||
hookSpecificOutput: {
|
hookSpecificOutput: {
|
||||||
hookEventName: 'PreToolUse',
|
hookEventName: 'PreToolUse',
|
||||||
permissionDecision: 'deny',
|
permissionDecision: 'deny',
|
||||||
@@ -439,6 +510,13 @@ function outputNoToolsMessage(query) {
|
|||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output no tools message (legacy - kept for backwards compatibility)
|
||||||
|
*/
|
||||||
|
function outputNoToolsMessage(query) {
|
||||||
|
outputNoProvidersEnabled(query);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Output all providers failed message
|
* Output all providers failed message
|
||||||
*/
|
*/
|
||||||
@@ -450,7 +528,7 @@ function outputAllFailedMessage(query, errors) {
|
|||||||
const message = [
|
const message = [
|
||||||
'[WebSearch - All Providers Failed]',
|
'[WebSearch - All Providers Failed]',
|
||||||
'',
|
'',
|
||||||
'Tried all available CLI tools but all failed:',
|
'Tried all enabled CLI tools but all failed:',
|
||||||
errorDetails,
|
errorDetails,
|
||||||
'',
|
'',
|
||||||
`Query: "${query}"`,
|
`Query: "${query}"`,
|
||||||
|
|||||||
@@ -612,9 +612,39 @@ export function getWebSearchHookEnv(): Record<string, string> {
|
|||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass simple config to hook
|
// Pass master switch
|
||||||
env.CCS_WEBSEARCH_ENABLED = '1';
|
env.CCS_WEBSEARCH_ENABLED = '1';
|
||||||
env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers?.gemini?.timeout || 55);
|
|
||||||
|
// Pass individual provider enabled states
|
||||||
|
// Hook will only use providers that are BOTH enabled AND installed
|
||||||
|
if (wsConfig.providers?.gemini?.enabled) {
|
||||||
|
env.CCS_WEBSEARCH_GEMINI = '1';
|
||||||
|
env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.gemini.timeout || 55);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wsConfig.providers?.opencode?.enabled) {
|
||||||
|
env.CCS_WEBSEARCH_OPENCODE = '1';
|
||||||
|
if (wsConfig.providers.opencode.model) {
|
||||||
|
env.CCS_WEBSEARCH_OPENCODE_MODEL = wsConfig.providers.opencode.model;
|
||||||
|
}
|
||||||
|
// Use opencode timeout if no gemini timeout set
|
||||||
|
if (!env.CCS_WEBSEARCH_TIMEOUT) {
|
||||||
|
env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.opencode.timeout || 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wsConfig.providers?.grok?.enabled) {
|
||||||
|
env.CCS_WEBSEARCH_GROK = '1';
|
||||||
|
// Use grok timeout if no other timeout set
|
||||||
|
if (!env.CCS_WEBSEARCH_TIMEOUT) {
|
||||||
|
env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.grok.timeout || 55);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default timeout if none set
|
||||||
|
if (!env.CCS_WEBSEARCH_TIMEOUT) {
|
||||||
|
env.CCS_WEBSEARCH_TIMEOUT = '55';
|
||||||
|
}
|
||||||
|
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user