diff --git a/docs/websearch.md b/docs/websearch.md index 29dcf740..f83ede4c 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -70,9 +70,10 @@ That shared launch helper applies to normal third-party settings profiles, CLIPr | Brave Search | HTTP API | `BRAVE_API_KEY` | No | Cleaner snippets and metadata | | SearXNG | JSON API | `providers.searxng.url` | No | Self-hosted/public SearXNG backend via `/search?format=json` | | DuckDuckGo | HTML fetch | None | Yes | Built-in zero-setup fallback | -| Gemini CLI | Legacy CLI | `npm i -g @google/gemini-cli` | No | Optional compatibility fallback | -| OpenCode | Legacy CLI | `curl -fsSL https://opencode.ai/install \| bash` | No | Optional compatibility fallback | -| Grok CLI | Legacy CLI | `npm i -g @vibe-kit/grok-cli` + `GROK_API_KEY` | No | Optional compatibility fallback | +| Antigravity (agy) | LLM CLI | `curl -fsSL https://antigravity.google/cli/install.sh \| bash` | No | Recommended LLM CLI fallback (Gemini CLI successor) | +| Gemini CLI | LLM CLI | Deprecated, use Antigravity (agy) | No | Deprecated. Google retired the gemini CLI on 2026-06-18 | +| OpenCode | LLM CLI | `curl -fsSL https://opencode.ai/install \| bash` | No | Optional compatibility fallback | +| Grok CLI | LLM CLI | `npm i -g @vibe-kit/grok-cli` + `GROK_API_KEY` | No | Optional compatibility fallback | ## Configuration @@ -111,6 +112,10 @@ websearch: duckduckgo: enabled: true max_results: 5 + agy: + enabled: false + model: gemini-2.5-flash + timeout: 90 gemini: enabled: false model: gemini-2.5-flash diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index eb198cba..39996352 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -9,8 +9,9 @@ * - SearXNG JSON API * - DuckDuckGo HTML search * - * Legacy compatibility fallback: - * - Gemini CLI + * Optional LLM CLI fallback: + * - Antigravity CLI (agy) - recommended (Gemini CLI successor) + * - Gemini CLI - deprecated (Google retired the gemini CLI on 2026-06-18) * - OpenCode * - Grok CLI */ @@ -49,6 +50,11 @@ const SHARED_INSTRUCTIONS = `Instructions: 7. Format output clearly with sections if the topic is complex`; const PROVIDER_CONFIG = { + agy: { + model: 'gemini-2.5-flash', + toolInstruction: 'Use your web search tool to find current information.', + quirks: null, + }, gemini: { model: 'gemini-2.5-flash', toolInstruction: 'Use the google_web_search tool to find current information.', @@ -124,7 +130,10 @@ function readProviderState() { const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')); const cooldowns = - parsed && typeof parsed === 'object' && parsed.cooldowns && typeof parsed.cooldowns === 'object' + parsed && + typeof parsed === 'object' && + parsed.cooldowns && + typeof parsed.cooldowns === 'object' ? parsed.cooldowns : {}; return { cooldowns }; @@ -856,6 +865,66 @@ async function tryDuckDuckGoSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { } } +function runAgyCommand(args, timeoutMs) { + const result = spawnSync('agy', args, { + encoding: 'utf8', + timeout: timeoutMs, + maxBuffer: 1024 * 1024 * 2, + stdio: ['pipe', 'pipe', 'pipe'], + // Never route query-derived prompts through a shell. Node concatenates + // arguments for shell-backed Windows spawns, which lets shell metacharacters + // in WebSearch queries escape the intended CLI invocation. + shell: false, + }); + + if (result.error) { + if (result.error.code === 'ENOENT') + return { success: false, error: 'Antigravity CLI (agy) not installed' }; + throw result.error; + } + if (result.status !== 0) { + return { + success: false, + error: (result.stderr || '').trim() || `Antigravity CLI exited with code ${result.status}`, + }; + } + + const output = (result.stdout || '').trim(); + if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) { + return { success: false, error: 'Empty or too short response from Antigravity CLI' }; + } + return { success: true, content: output }; +} + +function tryAgySearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + try { + const timeoutMs = timeoutSec * 1000; + const model = process.env.CCS_WEBSEARCH_AGY_MODEL || PROVIDER_CONFIG.agy.model; + const prompt = buildPrompt('agy', query); + // gemini flag mapping: `--yolo` -> `--dangerously-skip-permissions`, `-m` -> `--model`, + // shell `timeout N` -> native `--print-timeout Ns`. The prompt is passed to `-p` (print mode). + const args = [ + '--model', + model, + '--dangerously-skip-permissions', + '--print-timeout', + `${timeoutSec}s`, + '-p', + prompt, + ]; + + debug(`Executing Antigravity (agy) fallback with model ${model}`); + return runAgyCommand(args, timeoutMs); + } catch (error) { + return { + success: false, + error: error.killed + ? 'Antigravity CLI timed out' + : error.message || 'Unknown Antigravity error', + }; + } +} + function shouldRetryGeminiWithLegacyPrompt(errorMessage) { const lower = (errorMessage || '').toLowerCase(); return ( @@ -1042,6 +1111,12 @@ function getConfiguredProviders() { available: () => isProviderEnabled('duckduckgo'), fn: tryDuckDuckGoSearch, }, + { + name: 'Antigravity CLI', + id: 'agy', + available: () => isProviderEnabled('agy') && isCliAvailable('agy'), + fn: tryAgySearch, + }, { name: 'Gemini CLI', id: 'gemini', @@ -1230,7 +1305,9 @@ async function runProviderWithPolicy(provider, query, timeoutSec, fingerprint) { } function getActiveProviders() { - return getConfiguredProviders().filter((provider) => !getProviderCooldown(provider.id) && provider.available()); + return getConfiguredProviders().filter( + (provider) => !getProviderCooldown(provider.id) && provider.available() + ); } function getActiveProviderIds() { diff --git a/src/config/loader/config-getters.ts b/src/config/loader/config-getters.ts index f8e77492..1081188b 100644 --- a/src/config/loader/config-getters.ts +++ b/src/config/loader/config-getters.ts @@ -90,6 +90,7 @@ export function getWebSearchConfig(): { brave?: { enabled?: boolean; max_results?: number }; searxng?: { enabled?: boolean; url?: string; max_results?: number }; duckduckgo?: { enabled?: boolean; max_results?: number }; + agy?: { enabled?: boolean; model?: string; timeout?: number }; gemini?: GeminiWebSearchInfo; opencode?: { enabled?: boolean; model?: string; timeout?: number }; grok?: { enabled?: boolean; timeout?: number }; @@ -126,6 +127,12 @@ export function getWebSearchConfig(): { max_results: config.websearch?.providers?.searxng?.max_results ?? 5, }; + const agyConfig = { + enabled: config.websearch?.providers?.agy?.enabled ?? false, + model: config.websearch?.providers?.agy?.model ?? 'gemini-2.5-flash', + timeout: config.websearch?.providers?.agy?.timeout ?? 90, + }; + const geminiConfig: GeminiWebSearchInfo = { enabled: config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? false, @@ -152,6 +159,7 @@ export function getWebSearchConfig(): { braveConfig.enabled || searxngConfig.enabled || duckDuckGoConfig.enabled || + agyConfig.enabled || geminiConfig.enabled || opencodeConfig.enabled || grokConfig.enabled; @@ -165,6 +173,7 @@ export function getWebSearchConfig(): { brave: braveConfig, searxng: searxngConfig, duckduckgo: duckDuckGoConfig, + agy: agyConfig, gemini: geminiConfig, opencode: opencodeConfig, grok: grokConfig, diff --git a/src/config/loader/defaults-merger.ts b/src/config/loader/defaults-merger.ts index 7b45f4a1..b6cd7c9a 100644 --- a/src/config/loader/defaults-merger.ts +++ b/src/config/loader/defaults-merger.ts @@ -160,6 +160,11 @@ export function mergeWithDefaults(partial: Partial): UnifiedConfi enabled: partial.websearch?.providers?.duckduckgo?.enabled ?? true, max_results: partial.websearch?.providers?.duckduckgo?.max_results ?? 5, }, + agy: { + enabled: partial.websearch?.providers?.agy?.enabled ?? false, + model: partial.websearch?.providers?.agy?.model ?? 'gemini-2.5-flash', + timeout: partial.websearch?.providers?.agy?.timeout ?? 90, + }, gemini: { enabled: partial.websearch?.providers?.gemini?.enabled ?? diff --git a/src/config/loader/yaml-serializer.ts b/src/config/loader/yaml-serializer.ts index 7fc878e9..bd511b53 100644 --- a/src/config/loader/yaml-serializer.ts +++ b/src/config/loader/yaml-serializer.ts @@ -149,8 +149,11 @@ export function generateYamlWithComments(config: UnifiedConfig): string { lines.push('# Brave requires BRAVE_API_KEY in your environment.'); lines.push('# DuckDuckGo works with zero extra setup and is enabled by default.'); lines.push('#'); - lines.push('# Legacy LLM fallbacks remain optional if you still want them:'); - lines.push('# gemini: npm i -g @google/gemini-cli'); + lines.push('# Optional LLM CLI fallbacks:'); + lines.push( + '# agy (recommended): curl -fsSL https://antigravity.google/cli/install.sh | bash' + ); + lines.push('# gemini (deprecated, retired upstream 2026-06-18): use agy instead'); lines.push('# opencode: curl -fsSL https://opencode.ai/install | bash'); lines.push('# grok: npm i -g @vibe-kit/grok-cli'); lines.push('# ----------------------------------------------------------------------------'); diff --git a/src/config/schemas/index.ts b/src/config/schemas/index.ts index a25aa156..43a437b5 100644 --- a/src/config/schemas/index.ts +++ b/src/config/schemas/index.ts @@ -66,6 +66,7 @@ export type { ExaWebSearchConfig, TavilyWebSearchConfig, SearxngWebSearchConfig, + AgyWebSearchConfig, GeminiWebSearchConfig, GrokWebSearchConfig, OpenCodeWebSearchConfig, diff --git a/src/config/schemas/unified-config.ts b/src/config/schemas/unified-config.ts index a38db23a..4b4ce886 100644 --- a/src/config/schemas/unified-config.ts +++ b/src/config/schemas/unified-config.ts @@ -154,6 +154,11 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { enabled: true, max_results: 5, }, + agy: { + enabled: false, + model: 'gemini-2.5-flash', + timeout: 90, + }, gemini: { enabled: false, model: 'gemini-2.5-flash', diff --git a/src/config/schemas/websearch.ts b/src/config/schemas/websearch.ts index d2714c41..42012a67 100644 --- a/src/config/schemas/websearch.ts +++ b/src/config/schemas/websearch.ts @@ -5,7 +5,7 @@ * - API-backed: Exa, Tavily, Brave * - Self-hosted: SearXNG * - Zero-setup: DuckDuckGo - * - Legacy CLI fallbacks: Gemini, Grok, OpenCode + * - LLM CLI fallbacks: Antigravity (agy, recommended), Gemini (deprecated), Grok, OpenCode */ /** @@ -60,8 +60,25 @@ export interface SearxngWebSearchConfig { max_results?: number; } +/** + * Antigravity CLI (agy) WebSearch configuration. + * + * Recommended LLM CLI fallback. `agy` is Google's successor to the retired + * `gemini` CLI. Install: `curl -fsSL https://antigravity.google/cli/install.sh | bash`. + */ +export interface AgyWebSearchConfig { + /** Enable Antigravity CLI fallback (default: false) */ + enabled?: boolean; + /** Model to use (default: gemini-2.5-flash; accepts legacy gemini ids) */ + model?: string; + /** Timeout in seconds (default: 90) */ + timeout?: number; +} + /** * Gemini CLI WebSearch configuration. + * + * @deprecated Google retired the gemini CLI on 2026-06-18. Use Antigravity (agy) instead. */ export interface GeminiWebSearchConfig { /** Enable Gemini CLI legacy fallback (default: false) */ @@ -109,7 +126,9 @@ export interface WebSearchProvidersConfig { searxng?: SearxngWebSearchConfig; /** DuckDuckGo HTML search - zero setup default backend */ duckduckgo?: DuckDuckGoWebSearchConfig; - /** Gemini CLI - optional legacy LLM fallback */ + /** Antigravity CLI (agy) - recommended LLM CLI fallback (Gemini CLI successor) */ + agy?: AgyWebSearchConfig; + /** Gemini CLI - deprecated legacy LLM fallback (retired upstream) */ gemini?: GeminiWebSearchConfig; /** Grok CLI - optional legacy LLM fallback */ grok?: GrokWebSearchConfig; diff --git a/src/utils/websearch-manager.ts b/src/utils/websearch-manager.ts index 77450d34..8b021ca2 100644 --- a/src/utils/websearch-manager.ts +++ b/src/utils/websearch-manager.ts @@ -16,6 +16,7 @@ // Re-export types export type { + AgyCliStatus, GeminiCliStatus, GrokCliStatus, OpenCodeCliStatus, @@ -25,6 +26,8 @@ export type { } from './websearch/types'; // Re-export CLI detection functions +export { getAgyCliStatus, hasAgyCli, clearAgyCliCache } from './websearch/agy'; + export { getGeminiCliStatus, hasGeminiCli, @@ -92,12 +95,18 @@ export { export { ensureProfileHooks, ensureProfileHooksOrThrow } from './websearch/profile-hook-injector'; // Import for local use -import { clearGeminiCliCache, clearGrokCliCache, clearOpenCodeCliCache } from './websearch'; +import { + clearAgyCliCache, + clearGeminiCliCache, + clearGrokCliCache, + clearOpenCodeCliCache, +} from './websearch'; /** * Clear all CLI caches */ export function clearAllCliCaches(): void { + clearAgyCliCache(); clearGeminiCliCache(); clearGrokCliCache(); clearOpenCodeCliCache(); diff --git a/src/utils/websearch/agy.ts b/src/utils/websearch/agy.ts new file mode 100644 index 00000000..f5528fdf --- /dev/null +++ b/src/utils/websearch/agy.ts @@ -0,0 +1,87 @@ +/** + * Antigravity CLI (agy) Detection + * + * Detects and manages Antigravity CLI installation status. `agy` is Google's + * successor to the retired `gemini` CLI (Google retired the gemini CLI on + * 2026-06-18) and is the recommended LLM CLI WebSearch fallback. + * + * @module utils/websearch/agy + */ + +import { execSync } from 'child_process'; +import type { AgyCliStatus } from './types'; + +// Cache for Antigravity CLI status (per process) +let agyCliCache: AgyCliStatus | null = null; + +/** + * Check if Antigravity CLI (agy) is installed globally. + * + * Install: `curl -fsSL https://antigravity.google/cli/install.sh | bash` + * (Unix installs to ~/.local/bin/agy). Must be in PATH. + * + * @returns Antigravity CLI status with path and version + */ +export function getAgyCliStatus(): AgyCliStatus { + // Return cached result if available + if (agyCliCache) { + return agyCliCache; + } + + const result: AgyCliStatus = { + installed: false, + path: undefined, + version: undefined, + }; + + try { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where agy' : 'which agy'; + + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const agyPath = pathResult.trim().split('\n')[0]; // First result on Windows + + if (agyPath) { + result.installed = true; + result.path = agyPath; + + // Try to get version + try { + const versionResult = execSync('agy --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 - Antigravity CLI not installed + } + + // Cache result + agyCliCache = result; + return result; +} + +/** + * Check if Antigravity CLI is available (quick boolean check) + */ +export function hasAgyCli(): boolean { + return getAgyCliStatus().installed; +} + +/** + * Clear Antigravity CLI cache (for testing or after installation) + */ +export function clearAgyCliCache(): void { + agyCliCache = null; +} diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index 83b605ab..c1342df4 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -41,6 +41,9 @@ export function getWebSearchHookConfig(): Record { // Compute max timeout from enabled providers const timeouts: number[] = []; + if (wsConfig.providers?.agy?.enabled && wsConfig.providers.agy.timeout) { + timeouts.push(wsConfig.providers.agy.timeout); + } if (wsConfig.providers?.gemini?.enabled && wsConfig.providers.gemini.timeout) { timeouts.push(wsConfig.providers.gemini.timeout); } diff --git a/src/utils/websearch/hook-env.ts b/src/utils/websearch/hook-env.ts index f3b8887f..8a95bcbe 100644 --- a/src/utils/websearch/hook-env.ts +++ b/src/utils/websearch/hook-env.ts @@ -27,6 +27,7 @@ export function getWebSearchHookEnv(): Record { CCS_WEBSEARCH_BRAVE: '0', CCS_WEBSEARCH_SEARXNG: '0', CCS_WEBSEARCH_DUCKDUCKGO: '0', + CCS_WEBSEARCH_AGY: '0', CCS_WEBSEARCH_GEMINI: '0', CCS_WEBSEARCH_OPENCODE: '0', CCS_WEBSEARCH_GROK: '0', @@ -80,12 +81,24 @@ export function getWebSearchHookEnv(): Record { env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS = String(wsConfig.providers.searxng.max_results || 5); } + if (wsConfig.providers?.agy?.enabled) { + env.CCS_WEBSEARCH_AGY = '1'; + if (wsConfig.providers.agy.model) { + env.CCS_WEBSEARCH_AGY_MODEL = wsConfig.providers.agy.model; + } + // Antigravity is the primary CLI fallback, so its timeout wins. + env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.agy.timeout || 90); + } + 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); + // Only set if Antigravity (primary) has not already chosen the timeout. + if (!env.CCS_WEBSEARCH_TIMEOUT) { + env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.gemini.timeout || 55); + } } if (wsConfig.providers?.opencode?.enabled) { diff --git a/src/utils/websearch/index.ts b/src/utils/websearch/index.ts index 4111cad1..8d746801 100644 --- a/src/utils/websearch/index.ts +++ b/src/utils/websearch/index.ts @@ -8,6 +8,7 @@ // Types export type { + AgyCliStatus, GeminiCliStatus, GrokCliStatus, OpenCodeCliStatus, @@ -20,7 +21,10 @@ export type { export type { WebSearchApiKeyState } from './provider-secrets'; -// Gemini CLI +// Antigravity CLI (agy) - recommended Gemini CLI successor +export { getAgyCliStatus, hasAgyCli, clearAgyCliCache } from './agy'; + +// Gemini CLI (deprecated) export { getGeminiCliStatus, hasGeminiCli, diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index 1cf9e57b..af16138c 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -11,6 +11,7 @@ import { join } from 'path'; import { ok, warn, fail, info } from '../ui'; import { getCcsDir } from '../config-manager'; +import { getAgyCliStatus } from './agy'; import { getGeminiCliStatus, isGeminiAuthenticated } from './gemini-cli'; import { getGrokCliStatus } from './grok-cli'; import { getOpenCodeCliStatus } from './opencode-cli'; @@ -113,12 +114,31 @@ function applyCooldownStatus( function getLegacyProviderStatuses(): WebSearchCliInfo[] { const wsConfig = getWebSearchConfig(); + const agyStatus = getAgyCliStatus(); const geminiStatus = getGeminiCliStatus(); const grokStatus = getGrokCliStatus(); const opencodeStatus = getOpenCodeCliStatus(); const geminiAuthed = geminiStatus.installed && isGeminiAuthenticated(); return [ + { + id: 'agy', + kind: 'legacy-cli', + name: 'Antigravity CLI', + command: 'agy', + enabled: wsConfig.providers?.agy?.enabled ?? false, + available: agyStatus.installed, + version: agyStatus.version ?? null, + installCommand: 'curl -fsSL https://antigravity.google/cli/install.sh | bash', + docsUrl: 'https://antigravity.google/cli', + requiresApiKey: false, + description: 'Recommended LLM CLI fallback with Google web search (Gemini CLI successor).', + detail: agyStatus.installed + ? agyStatus.version + ? `Installed (${agyStatus.version})` + : 'Installed' + : 'Not installed', + }, { id: 'gemini', kind: 'legacy-cli', @@ -127,15 +147,16 @@ function getLegacyProviderStatuses(): WebSearchCliInfo[] { enabled: wsConfig.providers?.gemini?.enabled ?? false, available: geminiAuthed, version: geminiStatus.version ?? null, - installCommand: 'npm install -g @google/gemini-cli', - docsUrl: 'https://github.com/google-gemini/gemini-cli', + installCommand: 'curl -fsSL https://antigravity.google/cli/install.sh | bash', + docsUrl: 'https://antigravity.google/cli', requiresApiKey: false, - description: 'Optional legacy LLM fallback with Google web search.', + description: + 'Deprecated legacy fallback (Google retired the gemini CLI). Prefer Antigravity.', detail: geminiStatus.installed ? geminiAuthed ? 'Authenticated' : "Run 'gemini' to login" - : 'Not installed', + : 'Not installed (retired - use Antigravity)', }, { id: 'opencode', @@ -287,7 +308,7 @@ export function getCliInstallHints(): string[] { ' Enable DuckDuckGo in Settings > WebSearch for zero-setup search', ' Or enable SearXNG and set a valid base URL (must support /search?format=json)', ' Or export EXA_API_KEY, TAVILY_API_KEY, or BRAVE_API_KEY for API-backed search', - ' Optional legacy fallback: npm i -g @google/gemini-cli', + ' Optional LLM CLI fallback: curl -fsSL https://antigravity.google/cli/install.sh | bash', ]; } diff --git a/src/utils/websearch/types.ts b/src/utils/websearch/types.ts index 38f94499..57dd08a9 100644 --- a/src/utils/websearch/types.ts +++ b/src/utils/websearch/types.ts @@ -8,6 +8,12 @@ import type { ComponentStatus } from '../../types/utils'; +/** + * Antigravity CLI (agy) installation status + * @deprecated Use ComponentStatus directly + */ +export type AgyCliStatus = ComponentStatus; + /** * Gemini CLI installation status * @deprecated Use ComponentStatus directly @@ -40,6 +46,7 @@ export type WebSearchProviderId = | 'brave' | 'searxng' | 'duckduckgo' + | 'agy' | 'gemini' | 'grok' | 'opencode'; @@ -112,6 +119,7 @@ export interface WebSearchConfig { brave?: WebSearchProviderConfig; searxng?: WebSearchProviderConfig; duckduckgo?: WebSearchProviderConfig; + agy?: WebSearchProviderConfig; gemini?: WebSearchProviderConfig; opencode?: WebSearchProviderConfig; grok?: WebSearchProviderConfig; diff --git a/src/web-server/routes/websearch-routes.ts b/src/web-server/routes/websearch-routes.ts index 4b2220ca..a136a58b 100644 --- a/src/web-server/routes/websearch-routes.ts +++ b/src/web-server/routes/websearch-routes.ts @@ -197,6 +197,15 @@ router.put('/', (req: Request, res: Response): void => { config.websearch?.providers?.searxng?.max_results ?? DEFAULT_WEBSEARCH_MAX_RESULTS ), }, + agy: { + enabled: + providers.agy?.enabled ?? config.websearch?.providers?.agy?.enabled ?? false, + model: + providers.agy?.model ?? + config.websearch?.providers?.agy?.model ?? + 'gemini-2.5-flash', + timeout: providers.agy?.timeout ?? config.websearch?.providers?.agy?.timeout ?? 90, + }, gemini: { enabled: providers.gemini?.enabled ?? diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index f05f9765..822cd4a2 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -1,12 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; @@ -32,11 +25,7 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as { query: string, errors: Array<{ provider: string; error: string }> ) => HookOutput; - buildSuccessHookOutput: ( - query: string, - providerName: string, - content: string - ) => HookOutput; + buildSuccessHookOutput: (query: string, providerName: string, content: string) => HookOutput; classifyDuckDuckGoHtml: ( html: string, count: number @@ -45,7 +34,10 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as { kind: 'results' | 'no_results' | 'non_result_html'; results: Array<{ title: string; url: string; description: string }>; }; - extractDuckDuckGoResults: (html: string, count: number) => Array<{ + extractDuckDuckGoResults: ( + html: string, + count: number + ) => Array<{ title: string; url: string; description: string; @@ -62,7 +54,10 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as { results: Array<{ title: string; url: string; description: string }> ) => string; parseRetryAfterSeconds: (rawValue: string) => number | null; - trySearxngSearch: (query: string, timeoutSec?: number) => Promise<{ + trySearxngSearch: ( + query: string, + timeoutSec?: number + ) => Promise<{ content?: string; error?: string; statusCode?: number; @@ -136,7 +131,7 @@ describe('websearch-transformer legacy CLI safety', () => { const source = readFileSync(hookPath, 'utf8'); expect(source).not.toContain('shell: isWindows'); - expect(source.match(/shell: false/g) || []).toHaveLength(3); + expect(source.match(/shell: false/g) || []).toHaveLength(4); }); }); @@ -411,9 +406,7 @@ describe('websearch-transformer hook helpers', () => { const output = JSON.parse(result.stdout.trim()) as HookOutput; expect(output.hookSpecificOutput.hookEventName).toBe('PreToolUse'); expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); - expect(output.hookSpecificOutput.additionalContext).toContain( - 'CCS local WebSearch evidence' - ); + expect(output.hookSpecificOutput.additionalContext).toContain('CCS local WebSearch evidence'); expect(output.hookSpecificOutput.additionalContext).toContain('Provider: DuckDuckGo'); expect(output.hookSpecificOutput.additionalContext).toContain( 'URL: https://example.com/article' @@ -782,8 +775,7 @@ global.fetch = async (url) => { expect( traceEvents.some( (event) => - event.event === 'websearch_provider_success' && - event.providerId === 'duckduckgo' + event.event === 'websearch_provider_success' && event.providerId === 'duckduckgo' ) ).toBe(true); } finally { @@ -994,8 +986,7 @@ global.fetch = async (url) => { ).toBe(true); expect( traceEvents.some( - (event) => - event.event === 'websearch_provider_success' && event.providerId === 'exa' + (event) => event.event === 'websearch_provider_success' && event.providerId === 'exa' ) ).toBe(true); } finally { diff --git a/tests/unit/utils/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts index a24c76a6..f625c2a2 100644 --- a/tests/unit/utils/websearch/status.test.ts +++ b/tests/unit/utils/websearch/status.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, spyOn } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import * as agyCli from '../../../../src/utils/websearch/agy'; import * as geminiCli from '../../../../src/utils/websearch/gemini-cli'; import * as grokCli from '../../../../src/utils/websearch/grok-cli'; import * as opencodeCli from '../../../../src/utils/websearch/opencode-cli'; @@ -13,7 +14,9 @@ import { } from '../../../../src/utils/websearch/status'; import type { WebSearchCliInfo } from '../../../../src/utils/websearch/types'; -function provider(overrides: Partial & Pick): WebSearchCliInfo { +function provider( + overrides: Partial & Pick +): WebSearchCliInfo { return { id: overrides.id, kind: overrides.kind ?? 'backend', @@ -151,6 +154,10 @@ describe('websearch readiness', () => { installed: false, version: null, } as any); + const agyStatusSpy = spyOn(agyCli, 'getAgyCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); try { const providers = getWebSearchCliProviders(); @@ -166,6 +173,7 @@ describe('websearch readiness', () => { geminiAuthSpy.mockRestore(); grokStatusSpy.mockRestore(); opencodeStatusSpy.mockRestore(); + agyStatusSpy.mockRestore(); } }); @@ -205,6 +213,10 @@ describe('websearch readiness', () => { installed: false, version: null, } as any); + const agyStatusSpy = spyOn(agyCli, 'getAgyCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); try { const providers = getWebSearchCliProviders(); @@ -220,6 +232,72 @@ describe('websearch readiness', () => { geminiAuthSpy.mockRestore(); grokStatusSpy.mockRestore(); opencodeStatusSpy.mockRestore(); + agyStatusSpy.mockRestore(); + } + }); + + it('exposes Antigravity (agy) as a recommended CLI provider when enabled and installed', () => { + const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({ + enabled: true, + providers: { + exa: { enabled: false, max_results: 5 }, + tavily: { enabled: false, max_results: 5 }, + brave: { enabled: false, max_results: 5 }, + searxng: { enabled: false, url: '', max_results: 5 }, + duckduckgo: { enabled: false, max_results: 5 }, + agy: { enabled: true, model: 'gemini-2.5-flash', timeout: 90 }, + gemini: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, + } as any); + const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({ + exa: { envVar: 'EXA_API_KEY', configured: false, available: false, source: 'none' }, + tavily: { envVar: 'TAVILY_API_KEY', configured: false, available: false, source: 'none' }, + brave: { envVar: 'BRAVE_API_KEY', configured: false, available: false, source: 'none' }, + }); + const agyStatusSpy = spyOn(agyCli, 'getAgyCliStatus').mockReturnValue({ + installed: true, + version: '1.0.13', + } as any); + const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false); + const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + + try { + const providers = getWebSearchCliProviders(); + const agy = providers.find((entry) => entry.id === 'agy'); + + expect(agy?.name).toBe('Antigravity CLI'); + expect(agy?.kind).toBe('legacy-cli'); + expect(agy?.command).toBe('agy'); + expect(agy?.enabled).toBe(true); + expect(agy?.available).toBe(true); + expect(agy?.requiresApiKey).toBe(false); + expect(agy?.installCommand).toContain('antigravity.google'); + expect(agy?.detail).toContain('1.0.13'); + + const readiness = buildWebSearchReadiness(true, providers); + expect(readiness.readiness).toBe('ready'); + expect(readiness.message).toContain('Antigravity CLI'); + } finally { + getConfigSpy.mockRestore(); + apiKeySpy.mockRestore(); + agyStatusSpy.mockRestore(); + geminiStatusSpy.mockRestore(); + geminiAuthSpy.mockRestore(); + grokStatusSpy.mockRestore(); + opencodeStatusSpy.mockRestore(); } }); @@ -293,6 +371,10 @@ describe('websearch readiness', () => { installed: false, version: null, } as any); + const agyStatusSpy = spyOn(agyCli, 'getAgyCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); try { const providers = getWebSearchCliProviders(); @@ -313,6 +395,7 @@ describe('websearch readiness', () => { geminiAuthSpy.mockRestore(); grokStatusSpy.mockRestore(); opencodeStatusSpy.mockRestore(); + agyStatusSpy.mockRestore(); if (originalCcsHome === undefined) { delete process.env.CCS_HOME;