feat(websearch): add Grok CLI support and improve install guidance

- Add Grok CLI detection (grok-4-cli by lalomorales22) alongside Gemini CLI
- Add WebSearch health check group to health-service.ts
- Update settings UI to show both Gemini and Grok CLI providers
- Add detailed install hints when no WebSearch CLI is installed
- Update API routes to return grokCli status
- Both CLI and dashboard users now see clear installation guidance

Providers:
- Gemini CLI: FREE tier (1000 req/day), no API key needed
- Grok CLI: Requires xAI API key (XAI_API_KEY), web + X search
This commit is contained in:
kaitranntt
2025-12-16 21:00:35 -05:00
parent 66c3edc7e9
commit c0938e1c82
12 changed files with 1309 additions and 1726 deletions
+4 -2
View File
@@ -18,7 +18,8 @@ import {
ensureMcpWebSearch,
installWebSearchHook,
displayWebSearchStatus,
} from './utils/mcp-manager';
getWebSearchHookEnv,
} from './utils/websearch-manager';
// Import extracted command handlers
import { handleVersionCommand } from './commands/version-command';
@@ -152,7 +153,8 @@ async function execClaudeWithProxy(
const isWindows = process.platform === 'win32';
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
const env = { ...process.env, ...envVars };
const webSearchEnv = getWebSearchHookEnv();
const env = { ...process.env, ...envVars, ...webSearchEnv };
let claude: ChildProcess;
if (needsShell) {
+7 -2
View File
@@ -28,6 +28,7 @@ import {
import { isAuthenticated } from './auth-handler';
import { CLIProxyProvider, ExecutorConfig } from './types';
import { configureProviderModel, getCurrentModel } from './model-config';
import { getWebSearchHookEnv } from '../utils/websearch-manager';
import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog';
import {
findAccountByQuery,
@@ -43,7 +44,7 @@ import {
ensureMcpWebSearch,
installWebSearchHook,
displayWebSearchStatus,
} from '../utils/mcp-manager';
} from '../utils/websearch-manager';
/** Default executor configuration */
const DEFAULT_CONFIG: ExecutorConfig = {
@@ -386,10 +387,14 @@ export async function execClaudeWithCLIProxy(
// 7. Execute Claude CLI with proxied environment
// Uses custom settings path (for variants), user settings, or bundled defaults
const envVars = getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath);
const env = { ...process.env, ...envVars };
const webSearchEnv = getWebSearchHookEnv();
const env = { ...process.env, ...envVars, ...webSearchEnv };
log(`Claude env: ANTHROPIC_BASE_URL=${envVars.ANTHROPIC_BASE_URL}`);
log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`);
if (Object.keys(webSearchEnv).length > 0) {
log(`Claude env: WebSearch config=${JSON.stringify(webSearchEnv)}`);
}
// Filter out CCS-specific flags before passing to Claude CLI
const ccsFlags = [
+70 -43
View File
@@ -64,6 +64,7 @@ export function getConfigFormat(): 'yaml' | 'json' | 'none' {
/**
* Load unified config from YAML file.
* Returns null if file doesn't exist or format check fails.
* Auto-upgrades config if version is outdated (regenerates comments).
*/
export function loadUnifiedConfig(): UnifiedConfig | null {
const yamlPath = getConfigYamlPath();
@@ -82,6 +83,19 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
return null;
}
// Auto-upgrade if version is outdated (regenerates YAML with new comments)
if ((parsed.version ?? 1) < UNIFIED_CONFIG_VERSION) {
parsed.version = UNIFIED_CONFIG_VERSION;
try {
saveUnifiedConfig(parsed);
if (process.env.CCS_DEBUG) {
console.error(`[i] Config upgraded to v${UNIFIED_CONFIG_VERSION}`);
}
} catch {
// Ignore save errors during upgrade - config still works
}
}
return parsed;
} catch (err) {
const error = err instanceof Error ? err.message : 'Unknown error';
@@ -117,18 +131,20 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
},
websearch: {
enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true,
provider: partial.websearch?.provider ?? defaults.websearch?.provider ?? 'auto',
fallback: partial.websearch?.fallback ?? defaults.websearch?.fallback ?? true,
webSearchPrimeUrl:
partial.websearch?.webSearchPrimeUrl ?? defaults.websearch?.webSearchPrimeUrl,
gemini: {
enabled: partial.websearch?.gemini?.enabled ?? defaults.websearch?.gemini?.enabled ?? true,
timeout: partial.websearch?.gemini?.timeout ?? defaults.websearch?.gemini?.timeout ?? 55,
providers: {
gemini: {
enabled:
partial.websearch?.providers?.gemini?.enabled ??
partial.websearch?.gemini?.enabled ?? // Legacy fallback
true,
timeout:
partial.websearch?.providers?.gemini?.timeout ??
partial.websearch?.gemini?.timeout ?? // Legacy fallback
55,
},
},
mode: partial.websearch?.mode ?? defaults.websearch?.mode ?? 'sequential',
selectedProviders:
partial.websearch?.selectedProviders ?? defaults.websearch?.selectedProviders ?? [],
customMcp: partial.websearch?.customMcp ?? defaults.websearch?.customMcp ?? [],
// Legacy fields (keep for backwards compatibility during read)
gemini: partial.websearch?.gemini,
},
};
}
@@ -245,11 +261,19 @@ function generateYamlWithComments(config: UnifiedConfig): string {
// WebSearch section
if (config.websearch) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# WebSearch: MCP auto-configuration for third-party profiles');
lines.push('# enabled: true/false - Enable/disable MCP web-search auto-config');
lines.push('# provider: auto | web-search-prime | brave | tavily');
lines.push('# fallback: true/false - Enable fallback chain when provider fails');
lines.push('# API keys: Set BRAVE_API_KEY or TAVILY_API_KEY env vars');
lines.push('# WebSearch: Provider configuration for third-party profiles');
lines.push('# Dashboard (`ccs config`) is the source of truth for provider selection.');
lines.push('#');
lines.push('# enabled: Master switch - auto-disables when no providers enabled');
lines.push('# mode: sequential (try in order) | parallel (merge all results)');
lines.push('#');
lines.push('# providers:');
lines.push('# gemini: Uses Gemini CLI with google_web_search tool');
lines.push('# web-search-prime: HTTP MCP endpoint (z.ai subscription)');
lines.push('# brave: Brave Search API (requires BRAVE_API_KEY)');
lines.push('# tavily: Tavily Search API (requires TAVILY_API_KEY)');
lines.push('#');
lines.push('# customMcp: User-defined MCP servers (BYOM - Bring Your Own MCP)');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
@@ -324,43 +348,46 @@ export function setDefaultProfile(name: string): void {
updateUnifiedConfig({ default: name });
}
/**
* Gemini CLI WebSearch configuration
*/
export interface GeminiWebSearchInfo {
enabled: boolean;
timeout: number;
}
/**
* Get websearch configuration.
* Returns defaults if not configured.
* Simplified: Gemini CLI only (future CLI tools can be added).
*/
export function getWebSearchConfig(): {
enabled: boolean;
provider: 'auto' | 'web-search-prime' | 'brave' | 'tavily';
fallback: boolean;
webSearchPrimeUrl?: string;
gemini: {
enabled: boolean;
timeout: number;
providers?: {
gemini?: GeminiWebSearchInfo;
};
mode: 'sequential' | 'parallel';
selectedProviders: string[];
customMcp: Array<{
name: string;
type: 'http' | 'stdio';
url?: string;
headers?: Record<string, string>;
command?: string;
args?: string[];
env?: Record<string, string>;
}>;
// Legacy fields (deprecated)
gemini?: { enabled?: boolean; timeout?: number };
} {
const config = loadOrCreateUnifiedConfig();
// Build provider config from new format or legacy fallbacks
const geminiConfig: GeminiWebSearchInfo = {
enabled:
config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? true,
timeout:
config.websearch?.providers?.gemini?.timeout ?? config.websearch?.gemini?.timeout ?? 55,
};
// Auto-disable master switch if Gemini is not enabled
const enabled = geminiConfig.enabled && (config.websearch?.enabled ?? true);
return {
enabled: config.websearch?.enabled ?? true,
provider: config.websearch?.provider ?? 'auto',
fallback: config.websearch?.fallback ?? true,
webSearchPrimeUrl: config.websearch?.webSearchPrimeUrl,
gemini: {
enabled: config.websearch?.gemini?.enabled ?? true,
timeout: config.websearch?.gemini?.timeout ?? 55,
enabled,
providers: {
gemini: geminiConfig,
},
mode: config.websearch?.mode ?? 'sequential',
selectedProviders: config.websearch?.selectedProviders ?? [],
customMcp: config.websearch?.customMcp ?? [],
// Legacy field for backwards compatibility
gemini: config.websearch?.gemini,
};
}
+43 -41
View File
@@ -12,8 +12,9 @@
/**
* Unified config version.
* Version 2 = YAML unified format
* Version 3 = WebSearch config comments update
*/
export const UNIFIED_CONFIG_VERSION = 2;
export const UNIFIED_CONFIG_VERSION = 3;
/**
* Account configuration (formerly in profiles.json).
@@ -101,55 +102,56 @@ export interface PreferencesConfig {
}
/**
* Custom MCP server configuration for BYOM (Bring Your Own MCP)
* Gemini CLI WebSearch configuration.
*/
export interface CustomMcpConfig {
/** Unique name for this MCP server */
name: string;
/** Server type: HTTP endpoint or stdio command */
type: 'http' | 'stdio';
/** URL for HTTP-based MCP servers */
url?: string;
/** Headers for HTTP requests */
headers?: Record<string, string>;
/** Command for stdio-based MCP servers */
command?: string;
/** Arguments for stdio command */
args?: string[];
/** Environment variables for the server */
env?: Record<string, string>;
export interface GeminiWebSearchConfig {
/** Enable Gemini CLI for WebSearch (default: true) */
enabled?: boolean;
/** Timeout in seconds (default: 55) */
timeout?: number;
}
/**
* WebSearch providers configuration.
* Currently supports Gemini CLI only.
* Future: opencode, grok-cli, etc.
*/
export interface WebSearchProvidersConfig {
/** Gemini CLI - uses google_web_search tool */
gemini?: GeminiWebSearchConfig;
// Future CLI tools can be added here:
// opencode?: { enabled?: boolean; model?: string; };
// grok?: { enabled?: boolean; };
}
/**
* WebSearch configuration.
* Controls MCP web-search auto-configuration for third-party profiles.
* Uses CLI tools (Gemini CLI) to provide WebSearch for third-party profiles.
* Third-party providers don't have server-side WebSearch access.
*/
export interface WebSearchConfig {
/** Enable auto-configuration of MCP web-search (default: true) */
/** Master switch - enable/disable WebSearch (default: true) */
enabled?: boolean;
/** Preferred provider: auto uses fallback chain, or specify one */
provider?: 'auto' | 'web-search-prime' | 'brave' | 'tavily';
/** Enable fallback chain when preferred provider fails (default: true) */
fallback?: boolean;
/** Custom URL for web-search-prime provider (optional, overrides default) */
webSearchPrimeUrl?: string;
/**
* Gemini CLI configuration for WebSearch transformation.
* Uses `gemini -p` with google_web_search tool as primary method.
* No API key needed - uses OAuth authentication.
*/
/** Individual provider configurations */
providers?: WebSearchProvidersConfig;
// Legacy fields (deprecated, kept for backwards compatibility)
/** @deprecated Use providers.gemini instead */
gemini?: {
/** Enable Gemini CLI for WebSearch (default: true) */
enabled?: boolean;
/** Timeout in seconds for Gemini CLI (default: 55) */
timeout?: number;
};
/** Search mode: sequential (default) or parallel */
/** @deprecated Unused */
mode?: 'sequential' | 'parallel';
/** Selected providers for parallel mode */
/** @deprecated Unused */
provider?: 'auto' | 'web-search-prime' | 'brave' | 'tavily';
/** @deprecated Unused */
fallback?: boolean;
/** @deprecated Unused */
webSearchPrimeUrl?: string;
/** @deprecated Unused */
selectedProviders?: string[];
/** Custom MCP servers (BYOM - Bring Your Own MCP) */
customMcp?: CustomMcpConfig[];
/** @deprecated Unused */
customMcp?: unknown[];
}
/**
@@ -210,11 +212,11 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
},
websearch: {
enabled: true,
provider: 'auto',
fallback: true,
gemini: {
enabled: true,
timeout: 55,
providers: {
gemini: {
enabled: true,
timeout: 55,
},
},
},
};
-834
View File
@@ -1,834 +0,0 @@
/**
* MCP Manager - Manages MCP server configuration for CCS
*
* Ensures web-search-prime MCP is available for third-party profiles
* that cannot use Claude's native WebSearch tool.
*
* WebSearch is a server-side tool executed by Anthropic's API.
* Third-party providers (gemini, agy, codex, qwen) don't have access.
* This manager auto-configures MCP web-search as a fallback.
*
* @module utils/mcp-manager
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ok, info, warn, fail } from './ui';
import { getWebSearchConfig } from '../config/unified-config-loader';
// MCP configuration file path
const MCP_CONFIG_PATH = path.join(os.homedir(), '.claude', '.mcp.json');
/**
* MCP server configuration interface
*/
interface McpServerConfig {
type: 'http' | 'stdio';
url?: string;
headers?: Record<string, string>;
command?: string;
args?: string[];
env?: Record<string, string>;
_managedBy?: 'ccs'; // CCS-managed marker (Option C hybrid)
}
/**
* MCP configuration file structure
*/
interface McpConfig {
mcpServers?: Record<string, McpServerConfig>;
[key: string]: unknown;
}
/**
* Default web-search-prime MCP configuration (primary)
* HTTP-based, requires z.ai coding plan subscription
*/
const WEB_SEARCH_PRIME_CONFIG: McpServerConfig = {
type: 'http',
url: 'https://api.z.ai/api/mcp/web_search_prime/mcp',
headers: {},
_managedBy: 'ccs',
};
/**
* Brave Search MCP configuration (secondary fallback)
* Requires BRAVE_API_KEY env var
* Free tier: 15k queries/month, 1 query/sec
* Package: @modelcontextprotocol/server-brave-search
*/
const BRAVE_SEARCH_CONFIG: McpServerConfig = {
type: 'stdio',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-brave-search'],
env: {},
_managedBy: 'ccs',
};
/**
* Tavily MCP configuration (tertiary fallback)
* Requires TAVILY_API_KEY env var
* AI-optimized search, paid service
* Package: @tavily/mcp-server (official)
*/
const TAVILY_CONFIG: McpServerConfig = {
type: 'stdio',
command: 'npx',
args: ['-y', '@tavily/mcp-server'],
env: {},
_managedBy: 'ccs',
};
/**
* Ensure MCP web-search is configured for third-party profiles
*
* Called before spawning Claude CLI for CLIProxy profiles.
* Respects user configuration from ~/.ccs/config.yaml:
* - enabled: false → skip auto-config
* - provider: specific → add only that provider
* - fallback: false → don't add fallback providers
*
* Multi-tier MCP fallback chain:
* 1. web-search-prime (primary, requires z.ai subscription)
* 2. brave-search (if BRAVE_API_KEY set)
* 3. tavily (if TAVILY_API_KEY set)
*
* Only adds MCPs if no web search MCP is already configured.
*
* @returns true if web search MCP is available, false on error
*/
export function ensureMcpWebSearch(): boolean {
try {
// Check user configuration
const wsConfig = getWebSearchConfig();
// If disabled by user, skip auto-configuration
if (!wsConfig.enabled) {
if (process.env.CCS_DEBUG) {
console.error(info('WebSearch auto-config disabled by user'));
}
return false;
}
let config: McpConfig = { mcpServers: {} };
// Read existing config if present
if (fs.existsSync(MCP_CONFIG_PATH)) {
const content = fs.readFileSync(MCP_CONFIG_PATH, 'utf8');
try {
config = JSON.parse(content);
} catch {
// Malformed JSON - start fresh but preserve file as backup
if (process.env.CCS_DEBUG) {
console.error(warn('Malformed .mcp.json - starting fresh'));
}
config = { mcpServers: {} };
}
}
// Initialize mcpServers if missing
if (!config.mcpServers) {
config.mcpServers = {};
}
// Check if any web search MCP already configured (case-insensitive)
const hasWebSearch = Object.keys(config.mcpServers).some((key) => {
const lowerKey = key.toLowerCase();
return (
lowerKey.includes('web-search') ||
lowerKey.includes('websearch') ||
lowerKey.includes('tavily') ||
lowerKey.includes('brave')
);
});
if (hasWebSearch) {
if (process.env.CCS_DEBUG) {
console.error(info('MCP web-search already configured'));
}
return true;
}
// Track what we add for logging
const addedMcps: string[] = [];
// Helper to add a specific provider
const addProvider = (provider: 'web-search-prime' | 'brave' | 'tavily'): boolean => {
// mcpServers is guaranteed to exist here (initialized above)
const servers = config.mcpServers as Record<string, McpServerConfig>;
if (provider === 'web-search-prime') {
const webSearchPrimeConfig = { ...WEB_SEARCH_PRIME_CONFIG };
// Use configurable URL if provided
if (wsConfig.webSearchPrimeUrl) {
webSearchPrimeConfig.url = wsConfig.webSearchPrimeUrl;
}
servers['web-search-prime'] = webSearchPrimeConfig;
addedMcps.push('web-search-prime');
return true;
}
if (provider === 'brave') {
const braveApiKey = process.env.BRAVE_API_KEY;
if (braveApiKey) {
const braveConfig = { ...BRAVE_SEARCH_CONFIG };
braveConfig.env = { BRAVE_API_KEY: braveApiKey };
servers['brave-search'] = braveConfig;
addedMcps.push('brave-search');
return true;
}
return false;
}
if (provider === 'tavily') {
const tavilyApiKey = process.env.TAVILY_API_KEY;
if (tavilyApiKey) {
const tavilyConfig = { ...TAVILY_CONFIG };
tavilyConfig.env = { TAVILY_API_KEY: tavilyApiKey };
servers['tavily'] = tavilyConfig;
addedMcps.push('tavily');
return true;
}
return false;
}
return false;
};
// Apply user's provider preference
if (wsConfig.provider === 'auto') {
// Auto mode: add all available providers
addProvider('web-search-prime');
if (wsConfig.fallback) {
addProvider('brave');
addProvider('tavily');
}
} else {
// Specific provider requested
const added = addProvider(wsConfig.provider);
if (!added && wsConfig.fallback) {
// Fallback if preferred provider not available
if (process.env.CCS_DEBUG) {
console.error(
warn(`Preferred provider ${wsConfig.provider} not available, using fallback`)
);
}
addProvider('web-search-prime');
addProvider('brave');
addProvider('tavily');
}
}
// Add custom MCPs from config (BYOM)
if (wsConfig.customMcp && wsConfig.customMcp.length > 0) {
const servers = config.mcpServers as Record<string, McpServerConfig>;
for (const custom of wsConfig.customMcp) {
const serverConfig: McpServerConfig = {
type: custom.type,
_managedBy: 'ccs',
};
if (custom.type === 'http') {
serverConfig.url = custom.url;
serverConfig.headers = custom.headers || {};
} else {
serverConfig.command = custom.command;
serverConfig.args = custom.args || [];
serverConfig.env = custom.env || {};
}
servers[custom.name] = serverConfig;
addedMcps.push(custom.name);
}
}
// If nothing was added, return false
if (addedMcps.length === 0) {
if (process.env.CCS_DEBUG) {
console.error(warn('No web search MCP could be configured'));
}
return false;
}
// Ensure ~/.claude directory exists
const claudeDir = path.dirname(MCP_CONFIG_PATH);
if (!fs.existsSync(claudeDir)) {
fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 });
}
// Write config with proper formatting
fs.writeFileSync(MCP_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
if (process.env.CCS_DEBUG) {
console.error(info(`Added MCP servers for web search: ${addedMcps.join(', ')}`));
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to configure MCP: ${(error as Error).message}`));
}
return false;
}
}
/**
* Check if MCP web-search is configured
*
* @returns true if any web search MCP is present
*/
export function hasMcpWebSearch(): boolean {
try {
if (!fs.existsSync(MCP_CONFIG_PATH)) {
return false;
}
const content = fs.readFileSync(MCP_CONFIG_PATH, 'utf8');
const config: McpConfig = JSON.parse(content);
if (!config.mcpServers) {
return false;
}
// Case-insensitive check for web search MCP
return Object.keys(config.mcpServers).some((key) => {
const lowerKey = key.toLowerCase();
return (
lowerKey.includes('web-search') ||
lowerKey.includes('websearch') ||
lowerKey.includes('tavily') ||
lowerKey.includes('brave')
);
});
} catch {
return false;
}
}
/**
* Get path to MCP config file
*
* @returns absolute path to .mcp.json
*/
export function getMcpConfigPath(): string {
return MCP_CONFIG_PATH;
}
// 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 transformer hook to ~/.ccs/hooks/
*
* 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(options?: { legacy?: boolean }): boolean {
try {
// Ensure hooks directory exists
if (!fs.existsSync(CCS_HOOKS_DIR)) {
fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 });
}
// 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/
// In development: lib/hooks/
const possiblePaths = [
path.join(__dirname, '..', '..', 'lib', 'hooks', hookFileName),
path.join(__dirname, '..', 'lib', 'hooks', hookFileName),
];
let sourcePath: string | null = null;
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
sourcePath = p;
break;
}
}
if (!sourcePath) {
if (process.env.CCS_DEBUG) {
console.error(warn(`WebSearch hook source not found: ${hookFileName}`));
}
return false;
}
// Copy hook to ~/.ccs/hooks/
fs.copyFileSync(sourcePath, hookPath);
fs.chmodSync(hookPath, 0o755);
// 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) {
console.error(warn(`Failed to install WebSearch hook: ${(error as Error).message}`));
}
return false;
}
}
/**
* Get WebSearch hook configuration for settings.json
*
* 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(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: [
{
matcher: 'WebSearch',
hooks: [
{
type: 'command',
command: `node "${hookPath}"`,
timeout: timeout,
},
],
},
],
};
}
/**
* Check if WebSearch hook is installed
*
* Checks for both the new Gemini transformer and legacy block hook.
*
* @returns true if any WebSearch hook exists
*/
export function hasWebSearchHook(): boolean {
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);
}
// Set search mode (sequential or parallel)
if (wsConfig.mode === 'parallel') {
env.CCS_WEBSEARCH_MODE = 'parallel';
// Pass selected providers for parallel mode
if (wsConfig.selectedProviders && wsConfig.selectedProviders.length > 0) {
env.CCS_WEBSEARCH_PROVIDERS = wsConfig.selectedProviders.join(',');
}
}
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;
}
}
// ========== Phase 1: MCP Status Functions ==========
/**
* MCP web search status with ownership tracking
*/
export interface McpWebSearchStatus {
configured: boolean;
ccsManaged: string[]; // CCS-managed server names
userAdded: string[]; // User-added server names
}
/**
* Get comprehensive MCP web search status
*
* Distinguishes CCS-managed vs user-added MCP servers
* based on _managedBy marker.
*/
export function getMcpWebSearchStatus(): McpWebSearchStatus {
const result: McpWebSearchStatus = {
configured: false,
ccsManaged: [],
userAdded: [],
};
try {
if (!fs.existsSync(MCP_CONFIG_PATH)) return result;
const content = fs.readFileSync(MCP_CONFIG_PATH, 'utf8');
const config: McpConfig = JSON.parse(content);
if (!config.mcpServers) return result;
for (const [name, server] of Object.entries(config.mcpServers)) {
// Check if it's a web search server
const lowerName = name.toLowerCase();
const isWebSearch =
lowerName.includes('web-search') ||
lowerName.includes('websearch') ||
lowerName.includes('tavily') ||
lowerName.includes('brave');
if (!isWebSearch) continue;
if (server._managedBy === 'ccs') {
result.ccsManaged.push(name);
} else {
result.userAdded.push(name);
}
}
result.configured = result.ccsManaged.length > 0 || result.userAdded.length > 0;
return result;
} catch {
return result;
}
}
/**
* Check if CCS-managed web search MCP is configured
*/
export function hasCcsManagedWebSearch(): boolean {
const status = getMcpWebSearchStatus();
return status.ccsManaged.length > 0;
}
// ========== Phase 2: Gemini CLI Detection ==========
import { execSync } from 'child_process';
/**
* Gemini CLI installation status
*/
export interface GeminiCliStatus {
installed: boolean;
path: string | null;
version: string | null;
}
// Cache for Gemini CLI status (per process)
let geminiCliCache: GeminiCliStatus | null = null;
/**
* Check if Gemini CLI is installed globally
*
* Requires global install: `npm install -g @google/gemini-cli`
* No npx fallback - must be in PATH
*
* @returns Gemini CLI status with path and version
*/
export function getGeminiCliStatus(): GeminiCliStatus {
// Return cached result if available
if (geminiCliCache) {
return geminiCliCache;
}
const result: GeminiCliStatus = {
installed: false,
path: null,
version: null,
};
try {
const isWindows = process.platform === 'win32';
const whichCmd = isWindows ? 'where gemini' : 'which gemini';
const pathResult = execSync(whichCmd, {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
});
const geminiPath = pathResult.trim().split('\n')[0]; // First result on Windows
if (geminiPath) {
result.installed = true;
result.path = geminiPath;
// Try to get version
try {
const versionResult = execSync('gemini --version', {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
});
result.version = versionResult.trim();
} catch {
// Version check failed, but CLI is installed
result.version = 'unknown';
}
}
} catch {
// Command not found - Gemini CLI not installed
}
// Cache result
geminiCliCache = result;
return result;
}
/**
* Check if Gemini CLI is available (quick boolean check)
*/
export function hasGeminiCli(): boolean {
return getGeminiCliStatus().installed;
}
/**
* Clear Gemini CLI cache (for testing or after installation)
*/
export function clearGeminiCliCache(): void {
geminiCliCache = null;
}
// ========== Phase 3: WebSearch Readiness Status ==========
/**
* WebSearch availability status for third-party profiles
*/
export type WebSearchReadiness = 'ready' | 'mcp-only' | 'unavailable';
/**
* WebSearch status for display
*/
export interface WebSearchStatus {
readiness: WebSearchReadiness;
geminiCli: boolean;
mcpConfigured: boolean;
message: string;
}
/**
* Get WebSearch readiness status for display
*
* Called on third-party profile startup to inform user.
*/
export function getWebSearchReadiness(): WebSearchStatus {
const geminiCli = hasGeminiCli();
const mcpStatus = getMcpWebSearchStatus();
const mcpConfigured = mcpStatus.configured;
if (geminiCli) {
return {
readiness: 'ready',
geminiCli: true,
mcpConfigured,
message: 'Ready (Gemini CLI)',
};
}
if (mcpConfigured) {
return {
readiness: 'mcp-only',
geminiCli: false,
mcpConfigured: true,
message: 'MCP fallback only',
};
}
return {
readiness: 'unavailable',
geminiCli: false,
mcpConfigured: false,
message: 'Unavailable (run: ccs config)',
};
}
/**
* Display WebSearch status (single line, equilibrium UX)
*
* Only call for third-party profiles.
*/
export function displayWebSearchStatus(): void {
const status = getWebSearchReadiness();
switch (status.readiness) {
case 'ready':
console.error(ok(`WebSearch: ${status.message}`));
break;
case 'mcp-only':
console.error(info(`WebSearch: ${status.message}`));
break;
case 'unavailable':
console.error(fail(`WebSearch: ${status.message}`));
break;
}
}
+7 -1
View File
@@ -6,6 +6,7 @@
import { spawn, ChildProcess } from 'child_process';
import { ErrorManager } from './error-manager';
import { getWebSearchHookEnv } from './websearch-manager';
/**
* Escape arguments for shell execution (Windows compatibility)
@@ -25,8 +26,13 @@ export function execClaude(
const isWindows = process.platform === 'win32';
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
// Get WebSearch hook config env vars
const webSearchEnv = getWebSearchHookEnv();
// Prepare environment (merge with process.env if envVars provided)
const env = envVars ? { ...process.env, ...envVars } : process.env;
const env = envVars
? { ...process.env, ...envVars, ...webSearchEnv }
: { ...process.env, ...webSearchEnv };
let child: ChildProcess;
if (needsShell) {
+632
View File
@@ -0,0 +1,632 @@
/**
* WebSearch Manager - Manages WebSearch hook for CCS
*
* WebSearch is a server-side tool executed by Anthropic's API.
* Third-party providers (gemini, agy, codex, qwen) don't have access.
* This manager installs a hook that uses CLI tools (Gemini CLI) as fallback.
*
* Simplified Architecture:
* - No MCP complexity
* - Uses CLI tools (currently Gemini CLI)
* - Easy to extend for future CLI tools (opencode, etc.)
*
* @module utils/websearch-manager
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execSync } from 'child_process';
import { ok, info, warn, fail } from './ui';
import { getWebSearchConfig } from '../config/unified-config-loader';
// CCS hooks directory
const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks');
// Hook file name
const WEBSEARCH_HOOK = 'websearch-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');
// ========== Gemini CLI Detection ==========
/**
* Gemini CLI installation status
*/
export interface GeminiCliStatus {
installed: boolean;
path: string | null;
version: string | null;
}
// Cache for Gemini CLI status (per process)
let geminiCliCache: GeminiCliStatus | null = null;
/**
* Check if Gemini CLI is installed globally
*
* Requires global install: `npm install -g @google/gemini-cli`
* No npx fallback - must be in PATH
*
* @returns Gemini CLI status with path and version
*/
export function getGeminiCliStatus(): GeminiCliStatus {
// Return cached result if available
if (geminiCliCache) {
return geminiCliCache;
}
const result: GeminiCliStatus = {
installed: false,
path: null,
version: null,
};
try {
const isWindows = process.platform === 'win32';
const whichCmd = isWindows ? 'where gemini' : 'which gemini';
const pathResult = execSync(whichCmd, {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
});
const geminiPath = pathResult.trim().split('\n')[0]; // First result on Windows
if (geminiPath) {
result.installed = true;
result.path = geminiPath;
// Try to get version
try {
const versionResult = execSync('gemini --version', {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
});
result.version = versionResult.trim();
} catch {
// Version check failed, but CLI is installed
result.version = 'unknown';
}
}
} catch {
// Command not found - Gemini CLI not installed
}
// Cache result
geminiCliCache = result;
return result;
}
/**
* Check if Gemini CLI is available (quick boolean check)
*/
export function hasGeminiCli(): boolean {
return getGeminiCliStatus().installed;
}
/**
* Clear Gemini CLI cache (for testing or after installation)
*/
export function clearGeminiCliCache(): void {
geminiCliCache = null;
}
// ========== Grok CLI Detection ==========
/**
* Grok CLI installation status
*/
export interface GrokCliStatus {
installed: boolean;
path: string | null;
version: string | null;
}
// Cache for Grok CLI status (per process)
let grokCliCache: GrokCliStatus | null = null;
/**
* Check if Grok CLI is installed globally
*
* Grok CLI (grok-4-cli by lalomorales22) provides web search + X search.
* Requires: `npm install -g grok-cli` and XAI_API_KEY env var.
*
* @returns Grok CLI status with path and version
*/
export function getGrokCliStatus(): GrokCliStatus {
// Return cached result if available
if (grokCliCache) {
return grokCliCache;
}
const result: GrokCliStatus = {
installed: false,
path: null,
version: null,
};
try {
const isWindows = process.platform === 'win32';
const whichCmd = isWindows ? 'where grok' : 'which grok';
const pathResult = execSync(whichCmd, {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
});
const grokPath = pathResult.trim().split('\n')[0]; // First result on Windows
if (grokPath) {
result.installed = true;
result.path = grokPath;
// Try to get version
try {
const versionResult = execSync('grok --version', {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
});
result.version = versionResult.trim();
} catch {
// Version check failed, but CLI is installed
result.version = 'unknown';
}
}
} catch {
// Command not found - Grok CLI not installed
}
// Cache result
grokCliCache = result;
return result;
}
/**
* Check if Grok CLI is available (quick boolean check)
*/
export function hasGrokCli(): boolean {
return getGrokCliStatus().installed;
}
/**
* Clear Grok CLI cache (for testing or after installation)
*/
export function clearGrokCliCache(): void {
grokCliCache = null;
}
/**
* Clear all CLI caches
*/
export function clearAllCliCaches(): void {
geminiCliCache = null;
grokCliCache = null;
}
// ========== CLI Provider Info ==========
/**
* WebSearch CLI provider information for health checks and UI
*/
export interface WebSearchCliInfo {
/** Provider ID */
id: 'gemini' | 'grok';
/** Display name */
name: string;
/** CLI command name */
command: string;
/** Whether CLI is installed */
installed: boolean;
/** CLI version if installed */
version: string | null;
/** Install command */
installCommand: string;
/** Docs URL */
docsUrl: string;
/** Whether this provider requires an API key */
requiresApiKey: boolean;
/** API key environment variable name */
apiKeyEnvVar?: string;
/** Brief description */
description: string;
/** Free tier available? */
freeTier: boolean;
}
/**
* Get all WebSearch CLI providers with their status
*/
export function getWebSearchCliProviders(): WebSearchCliInfo[] {
const geminiStatus = getGeminiCliStatus();
const grokStatus = getGrokCliStatus();
return [
{
id: 'gemini',
name: 'Gemini CLI',
command: 'gemini',
installed: geminiStatus.installed,
version: geminiStatus.version,
installCommand: 'npm install -g @google/gemini-cli',
docsUrl: 'https://github.com/google-gemini/gemini-cli',
requiresApiKey: false,
description: 'Google Gemini with web search (FREE tier: 1000 req/day)',
freeTier: true,
},
{
id: 'grok',
name: 'Grok CLI',
command: 'grok',
installed: grokStatus.installed,
version: grokStatus.version,
installCommand: 'npm install -g grok-cli',
docsUrl: 'https://github.com/lalomorales22/grok-4-cli',
requiresApiKey: true,
apiKeyEnvVar: 'XAI_API_KEY',
description: 'xAI Grok with web + X search (requires API key)',
freeTier: false,
},
];
}
/**
* Check if any WebSearch CLI is available
*/
export function hasAnyWebSearchCli(): boolean {
return hasGeminiCli() || hasGrokCli();
}
/**
* Get install hints for CLI-only users when no WebSearch CLI is installed
*/
export function getCliInstallHints(): string[] {
if (hasAnyWebSearchCli()) {
return [];
}
return [
'[i] WebSearch: No CLI tools installed',
' Gemini CLI (FREE): npm i -g @google/gemini-cli',
' Grok CLI (paid): npm i -g grok-cli',
];
}
// ========== Hook Management ==========
/**
* Install WebSearch hook to ~/.ccs/hooks/
*
* This hook intercepts WebSearch and executes via Gemini CLI.
*
* @returns true if hook installed successfully
*/
export function installWebSearchHook(): boolean {
try {
const wsConfig = getWebSearchConfig();
// Skip if disabled
if (!wsConfig.enabled) {
if (process.env.CCS_DEBUG) {
console.error(info('WebSearch disabled - skipping hook install'));
}
return false;
}
// 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, WEBSEARCH_HOOK);
// Find the bundled hook script
// In npm package: node_modules/ccs/lib/hooks/
// In development: lib/hooks/
const possiblePaths = [
path.join(__dirname, '..', '..', 'lib', 'hooks', WEBSEARCH_HOOK),
path.join(__dirname, '..', 'lib', 'hooks', WEBSEARCH_HOOK),
];
let sourcePath: string | null = null;
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
sourcePath = p;
break;
}
}
if (!sourcePath) {
if (process.env.CCS_DEBUG) {
console.error(warn(`WebSearch hook source not found: ${WEBSEARCH_HOOK}`));
}
return false;
}
// Copy hook to ~/.ccs/hooks/
fs.copyFileSync(sourcePath, hookPath);
fs.chmodSync(hookPath, 0o755);
if (process.env.CCS_DEBUG) {
console.error(info(`Installed WebSearch hook: ${hookPath}`));
}
// Ensure hook is configured in settings.json
ensureHookConfig();
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to install WebSearch hook: ${(error as Error).message}`));
}
return false;
}
}
/**
* Check if WebSearch hook is installed
*/
export function hasWebSearchHook(): boolean {
const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK);
return fs.existsSync(hookPath);
}
/**
* Get WebSearch hook configuration for settings.json
*/
export function getWebSearchHookConfig(): Record<string, unknown> {
const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK);
return {
PreToolUse: [
{
matcher: 'WebSearch',
hooks: [
{
type: 'command',
command: `node "${hookPath}"`,
timeout: HOOK_TIMEOUT_SECONDS,
},
],
},
],
};
}
/**
* Ensure WebSearch hook is configured in ~/.claude/settings.json
*/
function ensureHookConfig(): boolean {
try {
const wsConfig = getWebSearchConfig();
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 {
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;
}
}
// ========== Environment Variables for Hook ==========
/**
* Get environment variables for WebSearch hook configuration.
*
* Simple env vars - hook reads these to control behavior.
*
* @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;
}
// Pass simple config to hook
env.CCS_WEBSEARCH_ENABLED = '1';
env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers?.gemini?.timeout || 55);
return env;
}
// ========== WebSearch Readiness Status ==========
/**
* WebSearch availability status for third-party profiles
*/
export type WebSearchReadiness = 'ready' | 'unavailable';
/**
* WebSearch status for display
*/
export interface WebSearchStatus {
readiness: WebSearchReadiness;
geminiCli: boolean;
grokCli: boolean;
message: string;
}
/**
* Get WebSearch readiness status for display
*
* Called on third-party profile startup to inform user.
*/
export function getWebSearchReadiness(): WebSearchStatus {
const wsConfig = getWebSearchConfig();
// Check if WebSearch is disabled entirely
if (!wsConfig.enabled) {
return {
readiness: 'unavailable',
geminiCli: false,
grokCli: false,
message: 'Disabled in config',
};
}
// Check both CLIs
const geminiInstalled = hasGeminiCli();
const grokInstalled = hasGrokCli();
if (geminiInstalled && grokInstalled) {
return {
readiness: 'ready',
geminiCli: true,
grokCli: true,
message: 'Ready (Gemini + Grok CLI)',
};
}
if (geminiInstalled) {
return {
readiness: 'ready',
geminiCli: true,
grokCli: false,
message: 'Ready (Gemini CLI)',
};
}
if (grokInstalled) {
return {
readiness: 'ready',
geminiCli: false,
grokCli: true,
message: 'Ready (Grok CLI)',
};
}
return {
readiness: 'unavailable',
geminiCli: false,
grokCli: false,
message: 'Install: npm i -g @google/gemini-cli',
};
}
/**
* Display WebSearch status (single line, equilibrium UX)
*
* Only call for third-party profiles.
* Shows detailed install hints when no CLI is installed.
*/
export function displayWebSearchStatus(): void {
const status = getWebSearchReadiness();
switch (status.readiness) {
case 'ready':
console.error(ok(`WebSearch: ${status.message}`));
break;
case 'unavailable':
console.error(fail(`WebSearch: ${status.message}`));
// Show install hints for CLI-only users
const hints = getCliInstallHints();
if (hints.length > 0) {
for (const hint of hints) {
console.error(info(hint));
}
}
break;
}
}
// ========== Backward Compatibility Exports ==========
// These are kept for imports that haven't been updated yet
/**
* @deprecated Use installWebSearchHook instead - MCP is no longer used
*/
export function ensureMcpWebSearch(): boolean {
// No-op - MCP is no longer used
return false;
}
/**
* @deprecated MCP is no longer used
*/
export function hasMcpWebSearch(): boolean {
return false;
}
/**
* @deprecated MCP is no longer used
*/
export function getMcpConfigPath(): string {
return path.join(os.homedir(), '.claude', '.mcp.json');
}
+54
View File
@@ -23,6 +23,7 @@ import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
import packageJson from '../../package.json';
import { getEnvironmentDiagnostics } from '../management/environment-diagnostics';
import { checkAuthCodePorts } from '../management/oauth-port-diagnostics';
import { getWebSearchCliProviders, hasAnyWebSearchCli } from '../utils/websearch-manager';
export interface HealthCheck {
id: string;
@@ -136,6 +137,16 @@ export async function runHealthChecks(): Promise<HealthReport> {
checks: oauthReadinessChecks,
});
// Group 8: WebSearch CLI Providers
const websearchChecks: HealthCheck[] = [];
websearchChecks.push(...checkWebSearchClis());
groups.push({
id: 'websearch',
name: 'WebSearch',
icon: 'Search',
checks: websearchChecks,
});
// Flatten all checks for backward compatibility
const allChecks = groups.flatMap((g) => g.checks);
@@ -816,6 +827,49 @@ async function checkOAuthPortsForDashboard(): Promise<HealthCheck[]> {
});
}
// Check 18: WebSearch CLI Providers (Gemini CLI, Grok CLI)
function checkWebSearchClis(): HealthCheck[] {
const providers = getWebSearchCliProviders();
const checks: HealthCheck[] = [];
for (const provider of providers) {
if (provider.installed) {
const freeTag = provider.freeTier ? ' (FREE)' : '';
checks.push({
id: `websearch-${provider.id}`,
name: provider.name,
status: 'ok',
message: `v${provider.version || 'unknown'}${freeTag}`,
details: provider.description,
});
} else {
const keyNote = provider.requiresApiKey ? ` (needs ${provider.apiKeyEnvVar})` : ' (FREE)';
checks.push({
id: `websearch-${provider.id}`,
name: provider.name,
status: 'info',
message: `Not installed${keyNote}`,
fix: provider.installCommand,
details: provider.description,
});
}
}
// Add summary check if no providers installed
if (!hasAnyWebSearchCli()) {
checks.push({
id: 'websearch-summary',
name: 'WebSearch Status',
status: 'warning',
message: 'No CLI tools installed',
fix: 'npm install -g @google/gemini-cli (FREE)',
details: 'Install a WebSearch CLI for real-time web access',
});
}
return checks;
}
/**
* Fix a health issue by its check ID
*/
+30 -67
View File
@@ -60,9 +60,9 @@ import { isUnifiedConfig } from '../config/unified-config-types';
import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys';
import {
getWebSearchReadiness,
getMcpWebSearchStatus,
getGeminiCliStatus,
} from '../utils/mcp-manager';
getGrokCliStatus,
} from '../utils/websearch-manager';
export const apiRoutes = Router();
@@ -1440,19 +1440,11 @@ apiRoutes.get('/websearch', (_req: Request, res: Response): void => {
/**
* PUT /api/websearch - Update WebSearch configuration
* Body: WebSearchConfig fields (enabled, provider, fallback, gemini, mode, selectedProviders, customMcp)
* Body: WebSearchConfig fields (enabled, providers)
* Dashboard is the source of truth for provider selection.
*/
apiRoutes.put('/websearch', (req: Request, res: Response): void => {
const {
enabled,
provider,
fallback,
webSearchPrimeUrl,
gemini,
mode,
selectedProviders,
customMcp,
} = req.body as Partial<WebSearchConfig>;
const { enabled, providers } = req.body as Partial<WebSearchConfig>;
// Validate enabled
if (enabled !== undefined && typeof enabled !== 'boolean') {
@@ -1460,45 +1452,9 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
return;
}
// Validate fallback
if (fallback !== undefined && typeof fallback !== 'boolean') {
res.status(400).json({ error: 'Invalid value for fallback. Must be a boolean.' });
return;
}
// Validate provider if specified
const validProviders = ['auto', 'web-search-prime', 'brave', 'tavily'];
if (provider && !validProviders.includes(provider)) {
res.status(400).json({
error: `Invalid provider. Must be one of: ${validProviders.join(', ')}`,
});
return;
}
// Validate webSearchPrimeUrl if specified
if (webSearchPrimeUrl !== undefined && typeof webSearchPrimeUrl !== 'string') {
res.status(400).json({ error: 'Invalid value for webSearchPrimeUrl. Must be a string.' });
return;
}
// Validate mode if specified
const validModes = ['sequential', 'parallel'];
if (mode && !validModes.includes(mode)) {
res.status(400).json({
error: `Invalid mode. Must be one of: ${validModes.join(', ')}`,
});
return;
}
// Validate selectedProviders if specified
if (selectedProviders !== undefined && !Array.isArray(selectedProviders)) {
res.status(400).json({ error: 'Invalid value for selectedProviders. Must be an array.' });
return;
}
// Validate customMcp if specified
if (customMcp !== undefined && !Array.isArray(customMcp)) {
res.status(400).json({ error: 'Invalid value for customMcp. Must be an array.' });
// Validate providers if specified
if (providers !== undefined && typeof providers !== 'object') {
res.status(400).json({ error: 'Invalid value for providers. Must be an object.' });
return;
}
@@ -1510,16 +1466,23 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
return;
}
// Merge updates - preserve all existing fields
// Merge updates - simple structure (Gemini CLI only for now)
existingConfig.websearch = {
enabled: enabled ?? existingConfig.websearch?.enabled ?? true,
provider: provider ?? existingConfig.websearch?.provider ?? 'auto',
fallback: fallback ?? existingConfig.websearch?.fallback ?? true,
webSearchPrimeUrl: webSearchPrimeUrl ?? existingConfig.websearch?.webSearchPrimeUrl,
gemini: gemini ?? existingConfig.websearch?.gemini ?? { enabled: true, timeout: 55 },
mode: mode ?? existingConfig.websearch?.mode ?? 'sequential',
selectedProviders: selectedProviders ?? existingConfig.websearch?.selectedProviders ?? [],
customMcp: customMcp ?? existingConfig.websearch?.customMcp ?? [],
providers: providers
? {
gemini: {
enabled:
providers.gemini?.enabled ??
existingConfig.websearch?.providers?.gemini?.enabled ??
true,
timeout:
providers.gemini?.timeout ??
existingConfig.websearch?.providers?.gemini?.timeout ??
55,
},
}
: existingConfig.websearch?.providers,
};
saveUnifiedConfig(existingConfig);
@@ -1534,13 +1497,13 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
});
/**
* GET /api/websearch/status - Get comprehensive WebSearch status
* Returns: { geminiCli, mcpServers, readiness }
* GET /api/websearch/status - Get WebSearch status
* Returns: { geminiCli, grokCli, readiness }
*/
apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => {
try {
const geminiCli = getGeminiCliStatus();
const mcpStatus = getMcpWebSearchStatus();
const grokCli = getGrokCliStatus();
const readiness = getWebSearchReadiness();
res.json({
@@ -1549,10 +1512,10 @@ apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => {
path: geminiCli.path,
version: geminiCli.version,
},
mcpServers: {
configured: mcpStatus.configured,
ccsManaged: mcpStatus.ccsManaged,
userAdded: mcpStatus.userAdded,
grokCli: {
installed: grokCli.installed,
path: grokCli.path,
version: grokCli.version,
},
readiness: {
status: readiness.readiness,