From 5e4fa200df87861e9b078f1696b95569c570ea84 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 12:35:37 -0500 Subject: [PATCH] refactor(utils): modularize websearch-manager into websearch/ directory - extract gemini-cli, grok-cli, opencode-cli providers - extract hook-config, hook-env, hook-installer, status, types - slim websearch-manager.ts from 867 to 103 lines (88% reduction) - add barrel export at websearch/index.ts --- src/utils/websearch-manager.ts | 858 ++------------------------ src/utils/websearch/gemini-cli.ts | 114 ++++ src/utils/websearch/grok-cli.ts | 85 +++ src/utils/websearch/hook-config.ts | 180 ++++++ src/utils/websearch/hook-env.ts | 66 ++ src/utils/websearch/hook-installer.ts | 127 ++++ src/utils/websearch/index.ts | 54 ++ src/utils/websearch/opencode-cli.ts | 85 +++ src/utils/websearch/status.ts | 191 ++++++ src/utils/websearch/types.ts | 100 +++ 10 files changed, 1047 insertions(+), 813 deletions(-) create mode 100644 src/utils/websearch/gemini-cli.ts create mode 100644 src/utils/websearch/grok-cli.ts create mode 100644 src/utils/websearch/hook-config.ts create mode 100644 src/utils/websearch/hook-env.ts create mode 100644 src/utils/websearch/hook-installer.ts create mode 100644 src/utils/websearch/index.ts create mode 100644 src/utils/websearch/opencode-cli.ts create mode 100644 src/utils/websearch/status.ts create mode 100644 src/utils/websearch/types.ts diff --git a/src/utils/websearch-manager.ts b/src/utils/websearch-manager.ts index 7ae85d89..fe7f8eb7 100644 --- a/src/utils/websearch-manager.ts +++ b/src/utils/websearch-manager.ts @@ -13,842 +13,74 @@ * @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'); +// Re-export types +export type { + GeminiCliStatus, + GrokCliStatus, + OpenCodeCliStatus, + WebSearchReadiness, + WebSearchStatus, + WebSearchCliInfo, +} from './websearch/types'; -// Hook file name -const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; +// Re-export CLI detection functions +export { + getGeminiCliStatus, + hasGeminiCli, + isGeminiAuthenticated, + clearGeminiCliCache, +} from './websearch/gemini-cli'; -// Buffer time added to max provider timeout for hook timeout (seconds) -const HOOK_TIMEOUT_BUFFER = 30; +export { getGrokCliStatus, hasGrokCli, clearGrokCliCache } from './websearch/grok-cli'; -// Minimum hook timeout in seconds (fallback if no providers configured) -const MIN_HOOK_TIMEOUT = 60; +export { + getOpenCodeCliStatus, + hasOpenCodeCli, + clearOpenCodeCliCache, +} from './websearch/opencode-cli'; -// Path to Claude settings.json -const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); +// Re-export hook management functions +export { + getHookPath, + hasWebSearchHook, + getWebSearchHookConfig, + installWebSearchHook, + uninstallWebSearchHook, +} from './websearch/hook-installer'; -// ========== Gemini CLI Detection ========== +// Re-export hook environment +export { getWebSearchHookEnv } from './websearch/hook-env'; -/** - * Gemini CLI installation status - */ -export interface GeminiCliStatus { - installed: boolean; - path: string | null; - version: string | null; -} +// Re-export status and readiness functions +export { + getWebSearchCliProviders, + hasAnyWebSearchCli, + getCliInstallHints, + getWebSearchReadiness, + displayWebSearchStatus, +} from './websearch/status'; -// 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; -} - -/** - * Check if Gemini CLI is authenticated - * - * Gemini CLI stores OAuth credentials in ~/.gemini/oauth_creds.json - * Authentication is done by running `gemini` which opens browser for OAuth. - * Note: There is NO `gemini auth login` command - just run `gemini` to authenticate. - * - * @returns true if oauth_creds.json exists with access_token - */ -export function isGeminiAuthenticated(): boolean { - const oauthPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); - - if (!fs.existsSync(oauthPath)) { - return false; - } - - try { - const content = fs.readFileSync(oauthPath, 'utf8'); - const creds = JSON.parse(content); - // Check if access_token exists (doesn't validate expiry - Gemini CLI handles refresh) - return !!creds.access_token; - } catch { - return false; - } -} - -/** - * 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; -} - -// ========== OpenCode CLI Detection ========== - -/** - * OpenCode CLI installation status - */ -export interface OpenCodeCliStatus { - installed: boolean; - path: string | null; - version: string | null; -} - -// Cache for OpenCode CLI status (per process) -let opencodeCliCache: OpenCodeCliStatus | null = null; - -/** - * Check if OpenCode CLI is installed globally - * - * OpenCode provides built-in web search via opencode/grok-code model. - * Install: curl -fsSL https://opencode.ai/install | bash - * - * @returns OpenCode CLI status with path and version - */ -export function getOpenCodeCliStatus(): OpenCodeCliStatus { - // Return cached result if available - if (opencodeCliCache) { - return opencodeCliCache; - } - - const result: OpenCodeCliStatus = { - installed: false, - path: null, - version: null, - }; - - try { - const isWindows = process.platform === 'win32'; - const whichCmd = isWindows ? 'where opencode' : 'which opencode'; - - const pathResult = execSync(whichCmd, { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - const opencodePath = pathResult.trim().split('\n')[0]; // First result on Windows - - if (opencodePath) { - result.installed = true; - result.path = opencodePath; - - // Try to get version - try { - const versionResult = execSync('opencode --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 - OpenCode CLI not installed - } - - // Cache result - opencodeCliCache = result; - return result; -} - -/** - * Check if OpenCode CLI is available (quick boolean check) - */ -export function hasOpenCodeCli(): boolean { - return getOpenCodeCliStatus().installed; -} - -/** - * Clear OpenCode CLI cache (for testing or after installation) - */ -export function clearOpenCodeCliCache(): void { - opencodeCliCache = null; -} +// Import for local use +import { clearGeminiCliCache, clearGrokCliCache, clearOpenCodeCliCache } from './websearch'; /** * Clear all CLI caches */ export function clearAllCliCaches(): void { - geminiCliCache = null; - grokCliCache = null; - opencodeCliCache = null; -} - -// ========== CLI Provider Info ========== - -/** - * WebSearch CLI provider information for health checks and UI - */ -export interface WebSearchCliInfo { - /** Provider ID */ - id: 'gemini' | 'grok' | 'opencode'; - /** 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(); - const opencodeStatus = getOpenCodeCliStatus(); - - 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: 'opencode', - name: 'OpenCode', - command: 'opencode', - installed: opencodeStatus.installed, - version: opencodeStatus.version, - installCommand: 'curl -fsSL https://opencode.ai/install | bash', - docsUrl: 'https://github.com/sst/opencode', - requiresApiKey: false, - description: 'OpenCode with built-in web search (FREE via Zen)', - freeTier: true, - }, - { - id: 'grok', - name: 'Grok CLI', - command: 'grok', - installed: grokStatus.installed, - version: grokStatus.version, - installCommand: 'npm install -g @vibe-kit/grok-cli', - docsUrl: 'https://github.com/superagent-ai/grok-cli', - requiresApiKey: true, - apiKeyEnvVar: 'GROK_API_KEY', - description: 'xAI Grok CLI with AI coding agent capabilities', - freeTier: false, - }, - ]; -} - -/** - * Check if any WebSearch CLI is available - */ -export function hasAnyWebSearchCli(): boolean { - return hasGeminiCli() || hasGrokCli() || hasOpenCodeCli(); -} - -/** - * 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', - ' OpenCode (FREE): curl -fsSL https://opencode.ai/install | bash', - ' Grok CLI (paid): npm i -g @vibe-kit/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 - * Timeout is computed from max provider timeout in config.yaml + buffer - */ -export function getWebSearchHookConfig(): Record { - const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK); - const wsConfig = getWebSearchConfig(); - - // Compute max timeout from enabled providers - const timeouts: number[] = []; - if (wsConfig.providers?.gemini?.enabled && wsConfig.providers.gemini.timeout) { - timeouts.push(wsConfig.providers.gemini.timeout); - } - if (wsConfig.providers?.opencode?.enabled && wsConfig.providers.opencode.timeout) { - timeouts.push(wsConfig.providers.opencode.timeout); - } - if (wsConfig.providers?.grok?.enabled && wsConfig.providers.grok.timeout) { - timeouts.push(wsConfig.providers.grok.timeout); - } - - // Hook timeout = max provider timeout + buffer (or minimum if none configured) - const maxProviderTimeout = timeouts.length > 0 ? Math.max(...timeouts) : MIN_HOOK_TIMEOUT; - const hookTimeout = maxProviderTimeout + HOOK_TIMEOUT_BUFFER; - - return { - PreToolUse: [ - { - matcher: 'WebSearch', - hooks: [ - { - type: 'command', - command: `node "${hookPath}"`, - timeout: hookTimeout, - }, - ], - }, - ], - }; -} - -/** - * 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 = {}; - 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 | undefined; - const expectedHookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK); - const expectedCommand = `node "${expectedHookPath}"`; - - if (hooks?.PreToolUse) { - const webSearchHookIndex = hooks.PreToolUse.findIndex((h: unknown) => { - const hook = h as Record; - return hook.matcher === 'WebSearch'; - }); - - if (webSearchHookIndex !== -1) { - // Hook exists - check if it needs updating (different command path or timeout) - const existingHook = hooks.PreToolUse[webSearchHookIndex] as Record; - const existingHooks = existingHook.hooks as Array>; - const currentHookConfig = getWebSearchHookConfig(); - const expectedHooks = (currentHookConfig.PreToolUse as Array>)[0] - .hooks as Array>; - const expectedTimeout = expectedHooks[0].timeout as number; - - let needsUpdate = false; - - if (existingHooks?.[0]?.command !== expectedCommand) { - existingHooks[0].command = expectedCommand; - needsUpdate = true; - } - - if (existingHooks?.[0]?.timeout !== expectedTimeout) { - existingHooks[0].timeout = expectedTimeout; - needsUpdate = true; - } - - if (needsUpdate) { - fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8'); - if (process.env.CCS_DEBUG) { - console.error(info('Updated WebSearch hook config 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; - 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 { - const wsConfig = getWebSearchConfig(); - const env: Record = {}; - - // Skip hook entirely if disabled - if (!wsConfig.enabled) { - env.CCS_WEBSEARCH_SKIP = '1'; - return env; - } - - // Pass master switch - env.CCS_WEBSEARCH_ENABLED = '1'; - - // 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'; - if (wsConfig.providers.gemini.model) { - env.CCS_WEBSEARCH_GEMINI_MODEL = wsConfig.providers.gemini.model; - } - 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 || 90); - } - } - - 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; -} - -// ========== WebSearch Readiness Status ========== - -/** - * WebSearch availability status for third-party profiles - */ -export type WebSearchReadiness = 'ready' | 'needs_auth' | 'unavailable'; - -/** - * WebSearch status for display - */ -export interface WebSearchStatus { - readiness: WebSearchReadiness; - geminiCli: boolean; - geminiAuthenticated: boolean; - grokCli: boolean; - opencodeCli: boolean; - message: string; -} - -/** - * Get WebSearch readiness status for display - * - * Called on third-party profile startup to inform user. - * Checks both installation AND authentication status for Gemini CLI. - */ -export function getWebSearchReadiness(): WebSearchStatus { - const wsConfig = getWebSearchConfig(); - - // Check if WebSearch is disabled entirely - if (!wsConfig.enabled) { - return { - readiness: 'unavailable', - geminiCli: false, - geminiAuthenticated: false, - grokCli: false, - opencodeCli: false, - message: 'Disabled in config', - }; - } - - // Check all CLIs - const geminiInstalled = hasGeminiCli(); - const geminiAuthed = geminiInstalled && isGeminiAuthenticated(); - const grokInstalled = hasGrokCli(); - const opencodeInstalled = hasOpenCodeCli(); - - // Build message based on installed + authenticated CLIs - const readyClis: string[] = []; - const needsAuthClis: string[] = []; - - // Gemini requires auth check - if (geminiInstalled) { - if (geminiAuthed) { - readyClis.push('Gemini'); - } else { - needsAuthClis.push('Gemini'); - } - } - - // Other CLIs don't require auth check (for now) - if (grokInstalled) readyClis.push('Grok'); - if (opencodeInstalled) readyClis.push('OpenCode'); - - // Determine overall status - if (readyClis.length > 0) { - // At least one CLI is ready - return { - readiness: 'ready', - geminiCli: geminiInstalled, - geminiAuthenticated: geminiAuthed, - grokCli: grokInstalled, - opencodeCli: opencodeInstalled, - message: `Ready (${readyClis.join(' + ')})`, - }; - } - - if (needsAuthClis.length > 0) { - // CLIs installed but need auth - return { - readiness: 'needs_auth', - geminiCli: geminiInstalled, - geminiAuthenticated: false, - grokCli: grokInstalled, - opencodeCli: opencodeInstalled, - message: `Gemini: run 'gemini' to login`, - }; - } - - return { - readiness: 'unavailable', - geminiCli: false, - geminiAuthenticated: false, - grokCli: false, - opencodeCli: 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 'needs_auth': - // CLI installed but not authenticated - show warning - console.error(warn(`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; - } + clearGeminiCliCache(); + clearGrokCliCache(); + clearOpenCodeCliCache(); } // ========== 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; } diff --git a/src/utils/websearch/gemini-cli.ts b/src/utils/websearch/gemini-cli.ts new file mode 100644 index 00000000..9a392c52 --- /dev/null +++ b/src/utils/websearch/gemini-cli.ts @@ -0,0 +1,114 @@ +/** + * Gemini CLI Detection + * + * Detects and manages Gemini CLI installation status. + * + * @module utils/websearch/gemini-cli + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; +import type { GeminiCliStatus } from './types'; + +// 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; +} + +/** + * Check if Gemini CLI is authenticated + * + * Gemini CLI stores OAuth credentials in ~/.gemini/oauth_creds.json + * Authentication is done by running `gemini` which opens browser for OAuth. + * Note: There is NO `gemini auth login` command - just run `gemini` to authenticate. + * + * @returns true if oauth_creds.json exists with access_token + */ +export function isGeminiAuthenticated(): boolean { + const oauthPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + + if (!fs.existsSync(oauthPath)) { + return false; + } + + try { + const content = fs.readFileSync(oauthPath, 'utf8'); + const creds = JSON.parse(content); + // Check if access_token exists (doesn't validate expiry - Gemini CLI handles refresh) + return !!creds.access_token; + } catch { + return false; + } +} + +/** + * Clear Gemini CLI cache (for testing or after installation) + */ +export function clearGeminiCliCache(): void { + geminiCliCache = null; +} diff --git a/src/utils/websearch/grok-cli.ts b/src/utils/websearch/grok-cli.ts new file mode 100644 index 00000000..bacb12aa --- /dev/null +++ b/src/utils/websearch/grok-cli.ts @@ -0,0 +1,85 @@ +/** + * Grok CLI Detection + * + * Detects and manages Grok CLI installation status. + * + * @module utils/websearch/grok-cli + */ + +import { execSync } from 'child_process'; +import type { GrokCliStatus } from './types'; + +// 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; +} diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts new file mode 100644 index 00000000..5a806783 --- /dev/null +++ b/src/utils/websearch/hook-config.ts @@ -0,0 +1,180 @@ +/** + * WebSearch Hook Configuration + * + * Manages hook configuration in Claude settings.json. + * + * @module utils/websearch/hook-config + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { info, warn } from '../ui'; +import { getWebSearchConfig } from '../../config/unified-config-loader'; + +// Path to Claude settings.json +const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); + +// CCS hooks directory +const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks'); + +// Hook file name +const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; + +// Buffer time added to max provider timeout for hook timeout (seconds) +const HOOK_TIMEOUT_BUFFER = 30; + +// Minimum hook timeout in seconds (fallback if no providers configured) +const MIN_HOOK_TIMEOUT = 60; + +/** + * Get path to WebSearch hook + */ +export function getHookPath(): string { + return path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK); +} + +/** + * Get WebSearch hook configuration for settings.json + * Timeout is computed from max provider timeout in config.yaml + buffer + */ +export function getWebSearchHookConfig(): Record { + const hookPath = getHookPath(); + const wsConfig = getWebSearchConfig(); + + // Compute max timeout from enabled providers + const timeouts: number[] = []; + if (wsConfig.providers?.gemini?.enabled && wsConfig.providers.gemini.timeout) { + timeouts.push(wsConfig.providers.gemini.timeout); + } + if (wsConfig.providers?.opencode?.enabled && wsConfig.providers.opencode.timeout) { + timeouts.push(wsConfig.providers.opencode.timeout); + } + if (wsConfig.providers?.grok?.enabled && wsConfig.providers.grok.timeout) { + timeouts.push(wsConfig.providers.grok.timeout); + } + + // Hook timeout = max provider timeout + buffer (or minimum if none configured) + const maxProviderTimeout = timeouts.length > 0 ? Math.max(...timeouts) : MIN_HOOK_TIMEOUT; + const hookTimeout = maxProviderTimeout + HOOK_TIMEOUT_BUFFER; + + return { + PreToolUse: [ + { + matcher: 'WebSearch', + hooks: [ + { + type: 'command', + command: `node "${hookPath}"`, + timeout: hookTimeout, + }, + ], + }, + ], + }; +} + +/** + * Ensure WebSearch hook is configured in ~/.claude/settings.json + */ +export function ensureHookConfig(): boolean { + try { + const wsConfig = getWebSearchConfig(); + + if (!wsConfig.enabled) { + return false; + } + + // Read existing settings or start fresh + let settings: Record = {}; + 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 | undefined; + const expectedHookPath = getHookPath(); + const expectedCommand = `node "${expectedHookPath}"`; + + if (hooks?.PreToolUse) { + const webSearchHookIndex = hooks.PreToolUse.findIndex((h: unknown) => { + const hook = h as Record; + return hook.matcher === 'WebSearch'; + }); + + if (webSearchHookIndex !== -1) { + // Hook exists - check if it needs updating + const existingHook = hooks.PreToolUse[webSearchHookIndex] as Record; + const existingHooks = existingHook.hooks as Array>; + const currentHookConfig = getWebSearchHookConfig(); + const expectedHooks = (currentHookConfig.PreToolUse as Array>)[0] + .hooks as Array>; + const expectedTimeout = expectedHooks[0].timeout as number; + + let needsUpdate = false; + + if (existingHooks?.[0]?.command !== expectedCommand) { + existingHooks[0].command = expectedCommand; + needsUpdate = true; + } + + if (existingHooks?.[0]?.timeout !== expectedTimeout) { + existingHooks[0].timeout = expectedTimeout; + needsUpdate = true; + } + + if (needsUpdate) { + fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8'); + if (process.env.CCS_DEBUG) { + console.error(info('Updated WebSearch hook config 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; + 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; + } +} diff --git a/src/utils/websearch/hook-env.ts b/src/utils/websearch/hook-env.ts new file mode 100644 index 00000000..ee3cf2b9 --- /dev/null +++ b/src/utils/websearch/hook-env.ts @@ -0,0 +1,66 @@ +/** + * WebSearch Hook Environment Variables + * + * Provides environment variables for WebSearch hook configuration. + * + * @module utils/websearch/hook-env + */ + +import { getWebSearchConfig } from '../../config/unified-config-loader'; + +/** + * 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 { + const wsConfig = getWebSearchConfig(); + const env: Record = {}; + + // Skip hook entirely if disabled + if (!wsConfig.enabled) { + env.CCS_WEBSEARCH_SKIP = '1'; + return env; + } + + // Pass master switch + env.CCS_WEBSEARCH_ENABLED = '1'; + + // 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'; + if (wsConfig.providers.gemini.model) { + env.CCS_WEBSEARCH_GEMINI_MODEL = wsConfig.providers.gemini.model; + } + 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 || 90); + } + } + + 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; +} diff --git a/src/utils/websearch/hook-installer.ts b/src/utils/websearch/hook-installer.ts new file mode 100644 index 00000000..4fc4d66b --- /dev/null +++ b/src/utils/websearch/hook-installer.ts @@ -0,0 +1,127 @@ +/** + * WebSearch Hook Installer + * + * Manages installation and uninstallation of the WebSearch hook. + * + * @module utils/websearch/hook-installer + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { info, warn } from '../ui'; +import { getWebSearchConfig } from '../../config/unified-config-loader'; +import { getHookPath, ensureHookConfig } from './hook-config'; + +// Re-export from hook-config for backward compatibility +export { getHookPath, getWebSearchHookConfig } from './hook-config'; + +// CCS hooks directory +const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks'); + +// Hook file name +const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; + +/** + * Check if WebSearch hook is installed + */ +export function hasWebSearchHook(): boolean { + return fs.existsSync(getHookPath()); +} + +/** + * 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 = getHookPath(); + + // 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), + 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; + } +} + +/** + * Uninstall WebSearch hook from ~/.ccs/hooks/ + * + * @returns true if hook uninstalled successfully + */ +export function uninstallWebSearchHook(): boolean { + try { + const hookPath = getHookPath(); + + if (fs.existsSync(hookPath)) { + fs.unlinkSync(hookPath); + if (process.env.CCS_DEBUG) { + console.error(info(`Uninstalled WebSearch hook: ${hookPath}`)); + } + } + + // TODO: Optionally remove from settings.json + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to uninstall WebSearch hook: ${(error as Error).message}`)); + } + return false; + } +} diff --git a/src/utils/websearch/index.ts b/src/utils/websearch/index.ts new file mode 100644 index 00000000..8c8826f1 --- /dev/null +++ b/src/utils/websearch/index.ts @@ -0,0 +1,54 @@ +/** + * WebSearch Module Barrel Export + * + * Re-exports all WebSearch functionality from submodules. + * + * @module utils/websearch + */ + +// Types +export type { + GeminiCliStatus, + GrokCliStatus, + OpenCodeCliStatus, + WebSearchReadiness, + WebSearchStatus, + WebSearchCliInfo, + WebSearchProviderConfig, + WebSearchConfig, +} from './types'; + +// Gemini CLI +export { + getGeminiCliStatus, + hasGeminiCli, + isGeminiAuthenticated, + clearGeminiCliCache, +} from './gemini-cli'; + +// Grok CLI +export { getGrokCliStatus, hasGrokCli, clearGrokCliCache } from './grok-cli'; + +// OpenCode CLI +export { getOpenCodeCliStatus, hasOpenCodeCli, clearOpenCodeCliCache } from './opencode-cli'; + +// Hook Installation +export { + getHookPath, + hasWebSearchHook, + getWebSearchHookConfig, + installWebSearchHook, + uninstallWebSearchHook, +} from './hook-installer'; + +// Hook Environment +export { getWebSearchHookEnv } from './hook-env'; + +// Status and Readiness +export { + getWebSearchCliProviders, + hasAnyWebSearchCli, + getCliInstallHints, + getWebSearchReadiness, + displayWebSearchStatus, +} from './status'; diff --git a/src/utils/websearch/opencode-cli.ts b/src/utils/websearch/opencode-cli.ts new file mode 100644 index 00000000..dff295cd --- /dev/null +++ b/src/utils/websearch/opencode-cli.ts @@ -0,0 +1,85 @@ +/** + * OpenCode CLI Detection + * + * Detects and manages OpenCode CLI installation status. + * + * @module utils/websearch/opencode-cli + */ + +import { execSync } from 'child_process'; +import type { OpenCodeCliStatus } from './types'; + +// Cache for OpenCode CLI status (per process) +let opencodeCliCache: OpenCodeCliStatus | null = null; + +/** + * Check if OpenCode CLI is installed globally + * + * OpenCode provides built-in web search via opencode/grok-code model. + * Install: curl -fsSL https://opencode.ai/install | bash + * + * @returns OpenCode CLI status with path and version + */ +export function getOpenCodeCliStatus(): OpenCodeCliStatus { + // Return cached result if available + if (opencodeCliCache) { + return opencodeCliCache; + } + + const result: OpenCodeCliStatus = { + installed: false, + path: null, + version: null, + }; + + try { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where opencode' : 'which opencode'; + + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const opencodePath = pathResult.trim().split('\n')[0]; // First result on Windows + + if (opencodePath) { + result.installed = true; + result.path = opencodePath; + + // Try to get version + try { + const versionResult = execSync('opencode --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 - OpenCode CLI not installed + } + + // Cache result + opencodeCliCache = result; + return result; +} + +/** + * Check if OpenCode CLI is available (quick boolean check) + */ +export function hasOpenCodeCli(): boolean { + return getOpenCodeCliStatus().installed; +} + +/** + * Clear OpenCode CLI cache (for testing or after installation) + */ +export function clearOpenCodeCliCache(): void { + opencodeCliCache = null; +} diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts new file mode 100644 index 00000000..7c6bdc46 --- /dev/null +++ b/src/utils/websearch/status.ts @@ -0,0 +1,191 @@ +/** + * WebSearch Status and Readiness + * + * Provides status checking and display functions for WebSearch. + * + * @module utils/websearch/status + */ + +import { ok, warn, fail, info } from '../ui'; +import { getWebSearchConfig } from '../../config/unified-config-loader'; +import { getGeminiCliStatus, hasGeminiCli, isGeminiAuthenticated } from './gemini-cli'; +import { getGrokCliStatus, hasGrokCli } from './grok-cli'; +import { getOpenCodeCliStatus, hasOpenCodeCli } from './opencode-cli'; +import type { WebSearchCliInfo, WebSearchStatus } from './types'; + +/** + * Get all WebSearch CLI providers with their status + */ +export function getWebSearchCliProviders(): WebSearchCliInfo[] { + const geminiStatus = getGeminiCliStatus(); + const grokStatus = getGrokCliStatus(); + const opencodeStatus = getOpenCodeCliStatus(); + + 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: 'opencode', + name: 'OpenCode', + command: 'opencode', + installed: opencodeStatus.installed, + version: opencodeStatus.version, + installCommand: 'curl -fsSL https://opencode.ai/install | bash', + docsUrl: 'https://github.com/sst/opencode', + requiresApiKey: false, + description: 'OpenCode with built-in web search (FREE via Zen)', + freeTier: true, + }, + { + id: 'grok', + name: 'Grok CLI', + command: 'grok', + installed: grokStatus.installed, + version: grokStatus.version, + installCommand: 'npm install -g @vibe-kit/grok-cli', + docsUrl: 'https://github.com/superagent-ai/grok-cli', + requiresApiKey: true, + apiKeyEnvVar: 'GROK_API_KEY', + description: 'xAI Grok CLI with AI coding agent capabilities', + freeTier: false, + }, + ]; +} + +/** + * Check if any WebSearch CLI is available + */ +export function hasAnyWebSearchCli(): boolean { + return hasGeminiCli() || hasGrokCli() || hasOpenCodeCli(); +} + +/** + * 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', + ' OpenCode (FREE): curl -fsSL https://opencode.ai/install | bash', + ' Grok CLI (paid): npm i -g @vibe-kit/grok-cli', + ]; +} + +/** + * Get WebSearch readiness status for display + * + * Called on third-party profile startup to inform user. + * Checks both installation AND authentication status for Gemini CLI. + */ +export function getWebSearchReadiness(): WebSearchStatus { + const wsConfig = getWebSearchConfig(); + + // Check if WebSearch is disabled entirely + if (!wsConfig.enabled) { + return { + readiness: 'unavailable', + geminiCli: false, + geminiAuthenticated: false, + grokCli: false, + opencodeCli: false, + message: 'Disabled in config', + }; + } + + // Check all CLIs + const geminiInstalled = hasGeminiCli(); + const geminiAuthed = geminiInstalled && isGeminiAuthenticated(); + const grokInstalled = hasGrokCli(); + const opencodeInstalled = hasOpenCodeCli(); + + // Build message based on installed + authenticated CLIs + const readyClis: string[] = []; + const needsAuthClis: string[] = []; + + // Gemini requires auth check + if (geminiInstalled) { + if (geminiAuthed) { + readyClis.push('Gemini'); + } else { + needsAuthClis.push('Gemini'); + } + } + + // Other CLIs don't require auth check (for now) + if (grokInstalled) readyClis.push('Grok'); + if (opencodeInstalled) readyClis.push('OpenCode'); + + // Determine overall status + if (readyClis.length > 0) { + return { + readiness: 'ready', + geminiCli: geminiInstalled, + geminiAuthenticated: geminiAuthed, + grokCli: grokInstalled, + opencodeCli: opencodeInstalled, + message: `Ready (${readyClis.join(' + ')})`, + }; + } + + if (needsAuthClis.length > 0) { + return { + readiness: 'needs_auth', + geminiCli: geminiInstalled, + geminiAuthenticated: false, + grokCli: grokInstalled, + opencodeCli: opencodeInstalled, + message: `Gemini: run 'gemini' to login`, + }; + } + + return { + readiness: 'unavailable', + geminiCli: false, + geminiAuthenticated: false, + grokCli: false, + opencodeCli: 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 'needs_auth': + console.error(warn(`WebSearch: ${status.message}`)); + break; + case 'unavailable': + console.error(fail(`WebSearch: ${status.message}`)); + const hints = getCliInstallHints(); + if (hints.length > 0) { + for (const hint of hints) { + console.error(info(hint)); + } + } + break; + } +} diff --git a/src/utils/websearch/types.ts b/src/utils/websearch/types.ts new file mode 100644 index 00000000..fc240cd1 --- /dev/null +++ b/src/utils/websearch/types.ts @@ -0,0 +1,100 @@ +/** + * WebSearch Type Definitions + * + * Contains all type definitions for WebSearch CLI providers and status. + * + * @module utils/websearch/types + */ + +/** + * Gemini CLI installation status + */ +export interface GeminiCliStatus { + installed: boolean; + path: string | null; + version: string | null; +} + +/** + * Grok CLI installation status + */ +export interface GrokCliStatus { + installed: boolean; + path: string | null; + version: string | null; +} + +/** + * OpenCode CLI installation status + */ +export interface OpenCodeCliStatus { + installed: boolean; + path: string | null; + version: string | null; +} + +/** + * WebSearch availability status for third-party profiles + */ +export type WebSearchReadiness = 'ready' | 'needs_auth' | 'unavailable'; + +/** + * WebSearch status for display + */ +export interface WebSearchStatus { + readiness: WebSearchReadiness; + geminiCli: boolean; + geminiAuthenticated: boolean; + grokCli: boolean; + opencodeCli: boolean; + message: string; +} + +/** + * WebSearch CLI provider information for health checks and UI + */ +export interface WebSearchCliInfo { + /** Provider ID */ + id: 'gemini' | 'grok' | 'opencode'; + /** 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; +} + +/** + * WebSearch provider configuration from config.yaml + */ +export interface WebSearchProviderConfig { + enabled?: boolean; + model?: string; + timeout?: number; +} + +/** + * WebSearch configuration from config.yaml + */ +export interface WebSearchConfig { + enabled: boolean; + providers?: { + gemini?: WebSearchProviderConfig; + opencode?: WebSearchProviderConfig; + grok?: WebSearchProviderConfig; + }; +}