mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
feat(websearch): add OpenCode CLI as third WebSearch provider
- Add OpenCodeWebSearchConfig type with enabled, model, timeout options - Implement getOpenCodeCliStatus() for CLI detection with caching - Add hasOpenCodeCli() and clearOpenCodeCliCache() helper functions - Update getWebSearchCliProviders() to include OpenCode provider info - Update hasAnyWebSearchCli() to check all three providers - Update getCliInstallHints() with OpenCode install command - Update WebSearchStatus interface to include opencodeCli - Update getWebSearchReadiness() to report OpenCode status - Update routes.ts PUT /api/websearch to handle opencode config - Update routes.ts GET /api/websearch/status to return opencodeCli - Add OpenCode provider card in settings.tsx UI - Add purple-themed installation hints for OpenCode - Order providers: Gemini (1st) -> OpenCode (2nd) -> Grok (3rd/last) OpenCode uses `opencode run` with `opencode/gpt-5-nano` model for web search. FREE tier available via OpenCode Zen, no API key required.
This commit is contained in:
@@ -121,20 +121,34 @@ export interface GrokWebSearchConfig {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenCode CLI WebSearch configuration.
|
||||
*/
|
||||
export interface OpenCodeWebSearchConfig {
|
||||
/** Enable OpenCode CLI for WebSearch (default: false) */
|
||||
enabled?: boolean;
|
||||
/** Model to use (default: opencode/gpt-5-nano) */
|
||||
model?: string;
|
||||
/** Timeout in seconds (default: 60) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSearch providers configuration.
|
||||
* Currently supports Gemini CLI and Grok CLI.
|
||||
* Supports Gemini CLI, Grok CLI, and OpenCode.
|
||||
*/
|
||||
export interface WebSearchProvidersConfig {
|
||||
/** Gemini CLI - uses google_web_search tool (FREE tier: 1000 req/day) */
|
||||
gemini?: GeminiWebSearchConfig;
|
||||
/** Grok CLI - xAI web search (requires GROK_API_KEY) */
|
||||
grok?: GrokWebSearchConfig;
|
||||
/** OpenCode - built-in web search (FREE via OpenCode Zen) */
|
||||
opencode?: OpenCodeWebSearchConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSearch configuration.
|
||||
* Uses CLI tools (Gemini CLI, Grok CLI) to provide WebSearch for third-party profiles.
|
||||
* Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles.
|
||||
* Third-party providers don't have server-side WebSearch access.
|
||||
*/
|
||||
export interface WebSearchConfig {
|
||||
|
||||
+119
-25
@@ -204,12 +204,99 @@ 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/gpt-5-nano 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all CLI caches
|
||||
*/
|
||||
export function clearAllCliCaches(): void {
|
||||
geminiCliCache = null;
|
||||
grokCliCache = null;
|
||||
opencodeCliCache = null;
|
||||
}
|
||||
|
||||
// ========== CLI Provider Info ==========
|
||||
@@ -219,7 +306,7 @@ export function clearAllCliCaches(): void {
|
||||
*/
|
||||
export interface WebSearchCliInfo {
|
||||
/** Provider ID */
|
||||
id: 'gemini' | 'grok';
|
||||
id: 'gemini' | 'grok' | 'opencode';
|
||||
/** Display name */
|
||||
name: string;
|
||||
/** CLI command name */
|
||||
@@ -248,6 +335,7 @@ export interface WebSearchCliInfo {
|
||||
export function getWebSearchCliProviders(): WebSearchCliInfo[] {
|
||||
const geminiStatus = getGeminiCliStatus();
|
||||
const grokStatus = getGrokCliStatus();
|
||||
const opencodeStatus = getOpenCodeCliStatus();
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -262,6 +350,18 @@ export function getWebSearchCliProviders(): WebSearchCliInfo[] {
|
||||
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',
|
||||
@@ -282,7 +382,7 @@ export function getWebSearchCliProviders(): WebSearchCliInfo[] {
|
||||
* Check if any WebSearch CLI is available
|
||||
*/
|
||||
export function hasAnyWebSearchCli(): boolean {
|
||||
return hasGeminiCli() || hasGrokCli();
|
||||
return hasGeminiCli() || hasGrokCli() || hasOpenCodeCli();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -296,6 +396,7 @@ export function getCliInstallHints(): string[] {
|
||||
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',
|
||||
];
|
||||
}
|
||||
@@ -520,6 +621,7 @@ export interface WebSearchStatus {
|
||||
readiness: WebSearchReadiness;
|
||||
geminiCli: boolean;
|
||||
grokCli: boolean;
|
||||
opencodeCli: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -537,38 +639,29 @@ export function getWebSearchReadiness(): WebSearchStatus {
|
||||
readiness: 'unavailable',
|
||||
geminiCli: false,
|
||||
grokCli: false,
|
||||
opencodeCli: false,
|
||||
message: 'Disabled in config',
|
||||
};
|
||||
}
|
||||
|
||||
// Check both CLIs
|
||||
// Check all CLIs
|
||||
const geminiInstalled = hasGeminiCli();
|
||||
const grokInstalled = hasGrokCli();
|
||||
const opencodeInstalled = hasOpenCodeCli();
|
||||
|
||||
if (geminiInstalled && grokInstalled) {
|
||||
// Build message based on installed CLIs
|
||||
const installedClis: string[] = [];
|
||||
if (geminiInstalled) installedClis.push('Gemini');
|
||||
if (grokInstalled) installedClis.push('Grok');
|
||||
if (opencodeInstalled) installedClis.push('OpenCode');
|
||||
|
||||
if (installedClis.length > 0) {
|
||||
return {
|
||||
readiness: 'ready',
|
||||
geminiCli: true,
|
||||
grokCli: true,
|
||||
message: 'Ready (Gemini + Grok CLI)',
|
||||
};
|
||||
}
|
||||
|
||||
if (geminiInstalled) {
|
||||
return {
|
||||
readiness: 'ready',
|
||||
geminiCli: true,
|
||||
grokCli: false,
|
||||
message: 'Ready (Gemini CLI)',
|
||||
};
|
||||
}
|
||||
|
||||
if (grokInstalled) {
|
||||
return {
|
||||
readiness: 'ready',
|
||||
geminiCli: false,
|
||||
grokCli: true,
|
||||
message: 'Ready (Grok CLI)',
|
||||
geminiCli: geminiInstalled,
|
||||
grokCli: grokInstalled,
|
||||
opencodeCli: opencodeInstalled,
|
||||
message: `Ready (${installedClis.join(' + ')})`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -576,6 +669,7 @@ export function getWebSearchReadiness(): WebSearchStatus {
|
||||
readiness: 'unavailable',
|
||||
geminiCli: false,
|
||||
grokCli: false,
|
||||
opencodeCli: false,
|
||||
message: 'Install: npm i -g @google/gemini-cli',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
getWebSearchReadiness,
|
||||
getGeminiCliStatus,
|
||||
getGrokCliStatus,
|
||||
getOpenCodeCliStatus,
|
||||
} from '../utils/websearch-manager';
|
||||
|
||||
export const apiRoutes = Router();
|
||||
@@ -1489,6 +1490,20 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
|
||||
timeout:
|
||||
providers.grok?.timeout ?? existingConfig.websearch?.providers?.grok?.timeout ?? 55,
|
||||
},
|
||||
opencode: {
|
||||
enabled:
|
||||
providers.opencode?.enabled ??
|
||||
existingConfig.websearch?.providers?.opencode?.enabled ??
|
||||
false,
|
||||
model:
|
||||
providers.opencode?.model ??
|
||||
existingConfig.websearch?.providers?.opencode?.model ??
|
||||
'opencode/gpt-5-nano',
|
||||
timeout:
|
||||
providers.opencode?.timeout ??
|
||||
existingConfig.websearch?.providers?.opencode?.timeout ??
|
||||
60,
|
||||
},
|
||||
}
|
||||
: existingConfig.websearch?.providers,
|
||||
};
|
||||
@@ -1506,12 +1521,13 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
|
||||
|
||||
/**
|
||||
* GET /api/websearch/status - Get WebSearch status
|
||||
* Returns: { geminiCli, grokCli, readiness }
|
||||
* Returns: { geminiCli, grokCli, opencodeCli, readiness }
|
||||
*/
|
||||
apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const geminiCli = getGeminiCliStatus();
|
||||
const grokCli = getGrokCliStatus();
|
||||
const opencodeCli = getOpenCodeCliStatus();
|
||||
const readiness = getWebSearchReadiness();
|
||||
|
||||
res.json({
|
||||
@@ -1525,6 +1541,11 @@ apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => {
|
||||
path: grokCli.path,
|
||||
version: grokCli.version,
|
||||
},
|
||||
opencodeCli: {
|
||||
installed: opencodeCli.installed,
|
||||
path: opencodeCli.path,
|
||||
version: opencodeCli.version,
|
||||
},
|
||||
readiness: {
|
||||
status: readiness.readiness,
|
||||
message: readiness.message,
|
||||
|
||||
@@ -28,9 +28,14 @@ interface ProviderConfig {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface OpenCodeProviderConfig extends ProviderConfig {
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface WebSearchProvidersConfig {
|
||||
gemini?: ProviderConfig;
|
||||
grok?: ProviderConfig;
|
||||
opencode?: OpenCodeProviderConfig;
|
||||
}
|
||||
|
||||
interface WebSearchConfig {
|
||||
@@ -47,6 +52,7 @@ interface CliStatus {
|
||||
interface WebSearchStatus {
|
||||
geminiCli: CliStatus;
|
||||
grokCli: CliStatus;
|
||||
opencodeCli: CliStatus;
|
||||
readiness: {
|
||||
status: 'ready' | 'unavailable';
|
||||
message: string;
|
||||
@@ -136,9 +142,10 @@ export function SettingsPage() {
|
||||
const providers = config?.providers || {};
|
||||
const currentState = providers.gemini?.enabled ?? false;
|
||||
const grokState = providers.grok?.enabled ?? false;
|
||||
const opencodeState = providers.opencode?.enabled ?? false;
|
||||
|
||||
saveConfig({
|
||||
enabled: !currentState || grokState, // Enable WebSearch if any provider is enabled
|
||||
enabled: !currentState || grokState || opencodeState, // Enable WebSearch if any provider is enabled
|
||||
providers: {
|
||||
...providers,
|
||||
gemini: {
|
||||
@@ -192,6 +199,7 @@ export function SettingsPage() {
|
||||
|
||||
const isGeminiEnabled = config?.providers?.gemini?.enabled ?? false;
|
||||
const isGrokEnabled = config?.providers?.grok?.enabled ?? false;
|
||||
const isOpenCodeEnabled = config?.providers?.opencode?.enabled ?? false;
|
||||
|
||||
// Toggle Grok provider
|
||||
const toggleGrok = () => {
|
||||
@@ -199,7 +207,7 @@ export function SettingsPage() {
|
||||
const currentState = providers.grok?.enabled ?? false;
|
||||
|
||||
saveConfig({
|
||||
enabled: isGeminiEnabled || !currentState, // Enable WebSearch if any provider is enabled
|
||||
enabled: isGeminiEnabled || !currentState || isOpenCodeEnabled, // Enable WebSearch if any provider is enabled
|
||||
providers: {
|
||||
...providers,
|
||||
grok: {
|
||||
@@ -210,6 +218,23 @@ export function SettingsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle OpenCode provider
|
||||
const toggleOpenCode = () => {
|
||||
const providers = config?.providers || {};
|
||||
const currentState = providers.opencode?.enabled ?? false;
|
||||
|
||||
saveConfig({
|
||||
enabled: isGeminiEnabled || isGrokEnabled || !currentState, // Enable WebSearch if any provider is enabled
|
||||
providers: {
|
||||
...providers,
|
||||
opencode: {
|
||||
...providers.opencode,
|
||||
enabled: !currentState,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-[calc(100vh-100px)] flex items-center justify-center">
|
||||
@@ -352,6 +377,72 @@ export function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OpenCode CLI Provider */}
|
||||
<div
|
||||
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
|
||||
isOpenCodeEnabled
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-border bg-background'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal
|
||||
className={`w-5 h-5 ${isOpenCodeEnabled ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-mono font-medium">opencode</p>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
|
||||
FREE
|
||||
</span>
|
||||
{status?.opencodeCli?.installed ? (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
|
||||
installed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
|
||||
not installed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
OpenCode (web search via Zen)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isOpenCodeEnabled}
|
||||
onCheckedChange={toggleOpenCode}
|
||||
disabled={saving || !status?.opencodeCli?.installed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* OpenCode Installation hint when not installed */}
|
||||
{!status?.opencodeCli?.installed && !statusLoading && (
|
||||
<div className="p-4 rounded-lg border border-purple-200 bg-purple-50 dark:border-purple-900/50 dark:bg-purple-900/20">
|
||||
<p className="text-sm font-medium text-purple-800 dark:text-purple-200 mb-2">
|
||||
OpenCode not installed
|
||||
</p>
|
||||
<p className="text-sm text-purple-700 dark:text-purple-300 mb-3">
|
||||
Install globally (FREE tier available):
|
||||
</p>
|
||||
<code className="text-sm bg-purple-100 dark:bg-purple-900/40 px-2 py-1 rounded font-mono">
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
</code>
|
||||
<div className="mt-3">
|
||||
<a
|
||||
href="https://github.com/sst/opencode"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-purple-700 dark:text-purple-300 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
View documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grok CLI Provider */}
|
||||
<div
|
||||
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
|
||||
|
||||
Reference in New Issue
Block a user