mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-30 16:22:09 +00:00
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:
+211
-22
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user