From 9dd9bf297890c1802fdc491ec6b909e1e5136a84 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:01:25 -0400 Subject: [PATCH] feat(websearch): add agy provider and deprecate gemini cli fallback (#1607) Google retired the gemini CLI on 2026-06-18, so the gemini websearch fallback no longer works. Add agy (Antigravity) as the primary CLI websearch provider end to end: runtime spawn in websearch-transformer.cjs (agy --dangerously-skip-permissions --print-timeout Ns -p), detection (agy.ts), config schema and defaults, hook env, status, dashboard persistence, and docs. The legacy gemini provider stays present but is marked deprecated for enterprise users who retain access. Image analysis already supported the agy provider (no change). The CLIProxy gemini quota fetcher (Gemini API OAuth, not the cli binary) is untouched. Validation: typecheck, lint, and the websearch + config + web-server unit suites pass. --- docs/websearch.md | 11 ++- lib/hooks/websearch-transformer.cjs | 85 +++++++++++++++++- src/config/loader/config-getters.ts | 9 ++ src/config/loader/defaults-merger.ts | 5 ++ src/config/loader/yaml-serializer.ts | 7 +- src/config/schemas/index.ts | 1 + src/config/schemas/unified-config.ts | 5 ++ src/config/schemas/websearch.ts | 23 ++++- src/utils/websearch-manager.ts | 11 ++- src/utils/websearch/agy.ts | 87 +++++++++++++++++++ src/utils/websearch/hook-config.ts | 3 + src/utils/websearch/hook-env.ts | 15 +++- src/utils/websearch/index.ts | 6 +- src/utils/websearch/status.ts | 31 +++++-- src/utils/websearch/types.ts | 8 ++ src/web-server/routes/websearch-routes.ts | 9 ++ .../unit/hooks/websearch-transformer.test.ts | 37 +++----- tests/unit/utils/websearch/status.test.ts | 85 +++++++++++++++++- 18 files changed, 395 insertions(+), 43 deletions(-) create mode 100644 src/utils/websearch/agy.ts 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;