feat(websearch): add MCP fallback and Gemini CLI hook for third-party profiles

- Add Gemini CLI transformer hook (websearch-gemini-transformer.cjs)
- Implement MCP auto-configuration with web-search-prime, brave, tavily fallback
- Add installWebSearchHook() to activate hook on profile launch
- Update settings.tsx to clarify web-search-prime requires z.ai subscription
- Add WebSearch config types and loader support
- Create implementation plan for WebSearch UX enhancement (startup status, dashboard)

Third-party profiles (gemini, agy, codex, qwen, glm, kimi) now have WebSearch
capability via Gemini CLI primary + MCP fallback chain.
This commit is contained in:
kaitranntt
2025-12-16 02:49:07 -05:00
parent 071ec041ed
commit fd99ebca98
8 changed files with 635 additions and 35 deletions
+211 -22
View File
@@ -42,7 +42,7 @@ interface McpConfig {
/**
* Default web-search-prime MCP configuration (primary)
* HTTP-based, no API key needed
* HTTP-based, requires z.ai coding plan subscription
*/
const WEB_SEARCH_PRIME_CONFIG: McpServerConfig = {
type: 'http',
@@ -86,7 +86,7 @@ const TAVILY_CONFIG: McpServerConfig = {
* - fallback: false → don't add fallback providers
*
* Multi-tier MCP fallback chain:
* 1. web-search-prime (primary, no API key needed)
* 1. web-search-prime (primary, requires z.ai subscription)
* 2. brave-search (if BRAVE_API_KEY set)
* 3. tavily (if TAVILY_API_KEY set)
*
@@ -290,31 +290,45 @@ export function getMcpConfigPath(): string {
// CCS hooks directory
const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks');
// Legacy hook for simple MCP redirect (deprecated)
const BLOCK_WEBSEARCH_HOOK = 'block-websearch.cjs';
// New hybrid hook: Gemini CLI first, MCP fallback
const GEMINI_TRANSFORMER_HOOK = 'websearch-gemini-transformer.cjs';
// Default hook timeout in seconds (Gemini CLI needs time)
const HOOK_TIMEOUT_SECONDS = 60;
// Path to Claude settings.json
const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
/**
* Install WebSearch blocking hook to ~/.ccs/hooks/
* Install WebSearch transformer hook to ~/.ccs/hooks/
*
* This hook blocks native WebSearch and directs Claude to use MCP.
* Optional feature - must be explicitly enabled.
* This hook intercepts WebSearch and:
* 1. Tries Gemini CLI with google_web_search (no API key needed)
* 2. Falls back to MCP redirect if Gemini fails
*
* This is the ultimate solution for third-party profiles.
*
* @param options - Installation options
* @param options.legacy - Install legacy block-websearch.cjs instead (default: false)
* @returns true if hook installed successfully
*/
export function installWebSearchHook(): boolean {
export function installWebSearchHook(options?: { legacy?: boolean }): boolean {
try {
// Ensure hooks directory exists
if (!fs.existsSync(CCS_HOOKS_DIR)) {
fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 });
}
const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK);
// Determine which hook to install
const hookFileName = options?.legacy ? BLOCK_WEBSEARCH_HOOK : GEMINI_TRANSFORMER_HOOK;
const hookPath = path.join(CCS_HOOKS_DIR, hookFileName);
// Find the bundled hook script
// In npm package: node_modules/ccs/lib/hooks/block-websearch.cjs
// In development: lib/hooks/block-websearch.cjs
// In npm package: node_modules/ccs/lib/hooks/
// In development: lib/hooks/
const possiblePaths = [
path.join(__dirname, '..', '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK),
path.join(__dirname, '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK),
path.join(__dirname, '..', '..', 'lib', 'hooks', hookFileName),
path.join(__dirname, '..', 'lib', 'hooks', hookFileName),
];
let sourcePath: string | null = null;
@@ -327,7 +341,7 @@ export function installWebSearchHook(): boolean {
if (!sourcePath) {
if (process.env.CCS_DEBUG) {
console.error(warn('WebSearch hook source not found'));
console.error(warn(`WebSearch hook source not found: ${hookFileName}`));
}
return false;
}
@@ -336,10 +350,31 @@ export function installWebSearchHook(): boolean {
fs.copyFileSync(sourcePath, hookPath);
fs.chmodSync(hookPath, 0o755);
if (process.env.CCS_DEBUG) {
console.error(info(`Installed WebSearch blocking hook to ${hookPath}`));
// Also install the legacy hook for backward compatibility
if (!options?.legacy) {
const legacyHookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK);
const legacySourcePaths = [
path.join(__dirname, '..', '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK),
path.join(__dirname, '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK),
];
for (const p of legacySourcePaths) {
if (fs.existsSync(p)) {
fs.copyFileSync(p, legacyHookPath);
fs.chmodSync(legacyHookPath, 0o755);
break;
}
}
}
if (process.env.CCS_DEBUG) {
console.error(info(`Installed WebSearch hook: ${hookPath}`));
}
// Also ensure the hook is configured in settings.json
// This is called after the hook file is installed
ensureWebSearchHookConfigInternal();
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
@@ -352,10 +387,17 @@ export function installWebSearchHook(): boolean {
/**
* Get WebSearch hook configuration for settings.json
*
* @returns hook configuration object
* Returns config for the Gemini transformer hook (default) or legacy hook.
*
* @param options - Configuration options
* @param options.legacy - Use legacy block-websearch.cjs instead (default: false)
* @returns hook configuration object for settings.json
*/
export function getWebSearchHookConfig(): Record<string, unknown> {
const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK);
export function getWebSearchHookConfig(options?: { legacy?: boolean }): Record<string, unknown> {
const hookFileName = options?.legacy ? BLOCK_WEBSEARCH_HOOK : GEMINI_TRANSFORMER_HOOK;
const hookPath = path.join(CCS_HOOKS_DIR, hookFileName);
// Legacy hook only needs 5s, Gemini hook needs 60s for CLI execution
const timeout = options?.legacy ? 5 : HOOK_TIMEOUT_SECONDS;
return {
PreToolUse: [
@@ -365,7 +407,7 @@ export function getWebSearchHookConfig(): Record<string, unknown> {
{
type: 'command',
command: `node "${hookPath}"`,
timeout: 5,
timeout: timeout,
},
],
},
@@ -374,11 +416,158 @@ export function getWebSearchHookConfig(): Record<string, unknown> {
}
/**
* Check if WebSearch blocking hook is installed
* Check if WebSearch hook is installed
*
* @returns true if hook exists
* Checks for both the new Gemini transformer and legacy block hook.
*
* @returns true if any WebSearch hook exists
*/
export function hasWebSearchHook(): boolean {
const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK);
return fs.existsSync(hookPath);
const geminiHookPath = path.join(CCS_HOOKS_DIR, GEMINI_TRANSFORMER_HOOK);
const legacyHookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK);
return fs.existsSync(geminiHookPath) || fs.existsSync(legacyHookPath);
}
/**
* Get information about installed WebSearch hooks
*
* @returns Object with hook status details
*/
export function getWebSearchHookStatus(): {
installed: boolean;
geminiTransformer: boolean;
legacyBlock: boolean;
activePath: string | null;
} {
const geminiHookPath = path.join(CCS_HOOKS_DIR, GEMINI_TRANSFORMER_HOOK);
const legacyHookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK);
const hasGemini = fs.existsSync(geminiHookPath);
const hasLegacy = fs.existsSync(legacyHookPath);
return {
installed: hasGemini || hasLegacy,
geminiTransformer: hasGemini,
legacyBlock: hasLegacy,
// Gemini transformer takes priority
activePath: hasGemini ? geminiHookPath : hasLegacy ? legacyHookPath : null,
};
}
/**
* Get environment variables for WebSearch hook configuration.
*
* These env vars are read by the websearch-gemini-transformer.cjs hook
* to control its behavior without requiring file I/O.
*
* @returns Record of environment variables to set before spawning Claude
*/
export function getWebSearchHookEnv(): Record<string, string> {
const wsConfig = getWebSearchConfig();
const env: Record<string, string> = {};
// Skip hook entirely if disabled
if (!wsConfig.enabled) {
env.CCS_WEBSEARCH_SKIP = '1';
return env;
}
// Skip Gemini CLI if specifically disabled
if (!wsConfig.gemini.enabled) {
env.CCS_GEMINI_SKIP = '1';
}
// Set Gemini timeout
if (wsConfig.gemini.timeout) {
env.CCS_GEMINI_TIMEOUT = String(wsConfig.gemini.timeout);
}
return env;
}
/**
* Ensure WebSearch hook is configured in ~/.claude/settings.json
*
* Merges the hook configuration into existing settings.json without
* overwriting other settings. Only adds if not already present.
*
* This is an internal function called by installWebSearchHook.
*
* @returns true if hook config is present (already existed or was added)
*/
function ensureWebSearchHookConfigInternal(): boolean {
try {
const wsConfig = getWebSearchConfig();
// Skip if disabled
if (!wsConfig.enabled) {
return false;
}
// Read existing settings or start fresh
let settings: Record<string, unknown> = {};
if (fs.existsSync(CLAUDE_SETTINGS_PATH)) {
try {
const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8');
settings = JSON.parse(content);
} catch {
// Malformed JSON - start fresh
if (process.env.CCS_DEBUG) {
console.error(warn('Malformed settings.json - will merge carefully'));
}
}
}
// Check if WebSearch hook already configured
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
if (hooks?.PreToolUse) {
const hasWebSearchHook = hooks.PreToolUse.some((h: unknown) => {
const hook = h as Record<string, unknown>;
return hook.matcher === 'WebSearch';
});
if (hasWebSearchHook) {
if (process.env.CCS_DEBUG) {
console.error(info('WebSearch hook already configured in settings.json'));
}
return true;
}
}
// Get hook config
const hookConfig = getWebSearchHookConfig();
// Merge hook config into settings
if (!settings.hooks) {
settings.hooks = {};
}
const settingsHooks = settings.hooks as Record<string, unknown[]>;
if (!settingsHooks.PreToolUse) {
settingsHooks.PreToolUse = [];
}
// Add our hook config
const preToolUseHooks = hookConfig.PreToolUse as unknown[];
settingsHooks.PreToolUse.push(...preToolUseHooks);
// Ensure ~/.claude directory exists
const claudeDir = path.dirname(CLAUDE_SETTINGS_PATH);
if (!fs.existsSync(claudeDir)) {
fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 });
}
// Write updated settings
fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
if (process.env.CCS_DEBUG) {
console.error(info('Added WebSearch hook to settings.json'));
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to configure WebSearch hook: ${(error as Error).message}`));
}
return false;
}
}