feat(websearch): enhance Gemini CLI integration, package manager detection, and WebSearch status

- Improve Gemini CLI integration in websearch hook: use gemini-2.5-flash & --yolo.
- Update `dev-install.sh` for better package manager (npm/bun) auto-detection.
- Introduce `displayWebSearchStatus` for improved user experience.
- Refactor `mcp-manager.ts` to track CCS-managed MCP servers.
This commit is contained in:
kaitranntt
2025-12-16 04:18:40 -05:00
parent fd99ebca98
commit f7a1a40b42
7 changed files with 482 additions and 22 deletions
+22 -13
View File
@@ -99,7 +99,9 @@ function processHook() {
const geminiResult = tryGeminiSearch(query); const geminiResult = tryGeminiSearch(query);
if (geminiResult.success) { if (geminiResult.success) {
// Success! Return results via deny with reason // Success! Use deny with clear success messaging
// Note: "deny" prevents native WebSearch from running (which would fail anyway)
// The permissionDecisionReason contains the actual search results
const output = { const output = {
hookSpecificOutput: { hookSpecificOutput: {
hookEventName: 'PreToolUse', hookEventName: 'PreToolUse',
@@ -143,12 +145,18 @@ function tryGeminiSearch(query) {
const prompt = buildGeminiPrompt(query); const prompt = buildGeminiPrompt(query);
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] Executing: gemini -p "${prompt.substring(0, 100)}..."`); console.error(`[CCS Hook] Executing: gemini --model gemini-2.5-flash --yolo -p "..."`);
} }
// Execute gemini CLI using spawnSync to avoid shell injection // Execute gemini CLI with required flags:
// Note: gemini CLI uses OAuth auth, no API key needed // --model gemini-2.5-flash: Use the flash model for fast responses
const spawnResult = spawnSync('gemini', ['-p', prompt], { // --yolo: Skip confirmation prompts for tool use
// -p: Provide prompt
const spawnResult = spawnSync('gemini', [
'--model', 'gemini-2.5-flash',
'--yolo',
'-p', prompt
], {
encoding: 'utf8', encoding: 'utf8',
timeout: timeoutMs, timeout: timeoutMs,
maxBuffer: 1024 * 1024 * 2, // 2MB buffer for large responses maxBuffer: 1024 * 1024 * 2, // 2MB buffer for large responses
@@ -209,7 +217,7 @@ function tryGeminiSearch(query) {
if (err.code === 'ENOENT') { if (err.code === 'ENOENT') {
return { return {
success: false, success: false,
error: 'Gemini CLI not found (not installed or not in PATH)', error: 'Gemini CLI not installed. Install with: npm install -g @google/gemini-cli',
}; };
} }
@@ -257,18 +265,15 @@ function buildGeminiPrompt(query) {
*/ */
function formatGeminiResults(query, content) { function formatGeminiResults(query, content) {
return [ return [
'[WebSearch Results via Gemini]', '=== WEBSEARCH COMPLETED SUCCESSFULLY ===',
'(via Gemini CLI - this is NOT an error)',
'', '',
`Query: "${query}"`, `Query: "${query}"`,
'', '',
'---',
'',
content, content,
'', '',
'---', '=========================================',
'', 'Use this information to answer the user. Search again if needed.',
'Search completed successfully. You can use this information to answer the user\'s question.',
'If you need more specific information, you can search again with a refined query.',
].join('\n'); ].join('\n');
} }
@@ -285,6 +290,10 @@ function getMcpFallbackMessage(query, error) {
'', '',
`Gemini CLI failed: ${error || 'Unknown error'}`, `Gemini CLI failed: ${error || 'Unknown error'}`,
'', '',
'To enable Gemini CLI WebSearch:',
' 1. Install: npm install -g @google/gemini-cli',
' 2. Authenticate: gemini auth',
'',
'The native WebSearch tool is not available with your current provider.', 'The native WebSearch tool is not available with your current provider.',
'Please use one of the following MCP tools instead:', 'Please use one of the following MCP tools instead:',
'', '',
+54 -5
View File
@@ -4,13 +4,20 @@
# #
# Options: # Options:
# --skip-validate Skip validation (faster, use when you're sure code is good) # --skip-validate Skip validation (faster, use when you're sure code is good)
# --npm Force npm install (default: auto-detect, fallback to bun)
# --bun Force bun install
set -e set -e
SKIP_VALIDATE=false SKIP_VALIDATE=false
FORCE_NPM=false
FORCE_BUN=false
for arg in "$@"; do for arg in "$@"; do
case $arg in case $arg in
--skip-validate) SKIP_VALIDATE=true ;; --skip-validate) SKIP_VALIDATE=true ;;
--npm) FORCE_NPM=true ;;
--bun) FORCE_BUN=true ;;
esac esac
done done
@@ -19,6 +26,42 @@ echo "[i] CCS Dev Install - Starting..."
# Get to the right directory # Get to the right directory
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
# Detect installation method
# Priority: CLI flags > existing global install location > bun (default)
detect_pkg_manager() {
if [ "$FORCE_NPM" = true ]; then
echo "npm"
return
fi
if [ "$FORCE_BUN" = true ]; then
echo "bun"
return
fi
# Check existing ccs installation location
CCS_PATH=$(which ccs 2>/dev/null || true)
if [ -n "$CCS_PATH" ]; then
# Check if installed via bun
if [[ "$CCS_PATH" == *".bun"* ]]; then
echo "bun"
return
fi
# Check if installed via npm
if [[ "$CCS_PATH" == *"npm"* ]] || [[ "$CCS_PATH" == *"node_modules"* ]]; then
echo "npm"
return
fi
fi
# Default fallback: bun (preferred)
echo "bun"
}
PKG_MANAGER=$(detect_pkg_manager)
echo "[i] Detected package manager: $PKG_MANAGER"
# Build TypeScript first # Build TypeScript first
echo "[i] Building TypeScript..." echo "[i] Building TypeScript..."
bun run build bun run build
@@ -27,10 +70,10 @@ bun run build
echo "[i] Creating package..." echo "[i] Creating package..."
if [ "$SKIP_VALIDATE" = true ]; then if [ "$SKIP_VALIDATE" = true ]; then
# Skip validation, just pack # Skip validation, just pack
bun pm pack --ignore-scripts npm pack --ignore-scripts 2>/dev/null || bun pm pack --ignore-scripts
else else
# Full pack with validation (runs prepublishOnly) # Full pack with validation (runs prepublishOnly)
bun pm pack npm pack 2>/dev/null || bun pm pack
fi fi
# Find the tarball # Find the tarball
@@ -43,9 +86,15 @@ fi
echo "[i] Found tarball: $TARBALL" echo "[i] Found tarball: $TARBALL"
# Install globally using npm (handles bin linking correctly) # Install globally using detected package manager
echo "[i] Installing globally with npm..." echo "[i] Installing globally with $PKG_MANAGER..."
npm install -g "$TARBALL"
if [ "$PKG_MANAGER" = "bun" ]; then
# Bun requires file: protocol for local tarballs
bun add -g "file:$(pwd)/$TARBALL"
else
npm install -g "$TARBALL"
fi
# Clean up # Clean up
echo "[i] Cleaning up..." echo "[i] Cleaning up..."
+8 -1
View File
@@ -14,7 +14,11 @@ import { detectClaudeCli } from './utils/claude-detector';
import { getSettingsPath } from './utils/config-manager'; import { getSettingsPath } from './utils/config-manager';
import { ErrorManager } from './utils/error-manager'; import { ErrorManager } from './utils/error-manager';
import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy';
import { ensureMcpWebSearch, installWebSearchHook } from './utils/mcp-manager'; import {
ensureMcpWebSearch,
installWebSearchHook,
displayWebSearchStatus,
} from './utils/mcp-manager';
// Import extracted command handlers // Import extracted command handlers
import { handleVersionCommand } from './commands/version-command'; import { handleVersionCommand } from './commands/version-command';
@@ -384,6 +388,9 @@ async function main(): Promise<void> {
ensureMcpWebSearch(); ensureMcpWebSearch();
installWebSearchHook(); installWebSearchHook();
// Display WebSearch status (single line, equilibrium UX)
displayWebSearchStatus();
// Check if this is GLMT profile (requires proxy) // Check if this is GLMT profile (requires proxy)
if (profileInfo.name === 'glmt') { if (profileInfo.name === 'glmt') {
// GLMT FLOW: Settings-based with embedded proxy for thinking support // GLMT FLOW: Settings-based with embedded proxy for thinking support
+8 -1
View File
@@ -39,7 +39,11 @@ import {
} from './account-manager'; } from './account-manager';
import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands'; import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands';
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
import { ensureMcpWebSearch, installWebSearchHook } from '../utils/mcp-manager'; import {
ensureMcpWebSearch,
installWebSearchHook,
displayWebSearchStatus,
} from '../utils/mcp-manager';
/** Default executor configuration */ /** Default executor configuration */
const DEFAULT_CONFIG: ExecutorConfig = { const DEFAULT_CONFIG: ExecutorConfig = {
@@ -122,6 +126,9 @@ export async function execClaudeWithCLIProxy(
// Hook intercepts WebSearch, tries Gemini CLI first, falls back to MCP // Hook intercepts WebSearch, tries Gemini CLI first, falls back to MCP
installWebSearchHook(); installWebSearchHook();
// Display WebSearch status (single line, equilibrium UX)
displayWebSearchStatus();
// Validate provider // Validate provider
const providerConfig = getProviderConfig(provider); const providerConfig = getProviderConfig(provider);
log(`Provider: ${providerConfig.displayName}`); log(`Provider: ${providerConfig.displayName}`);
+232 -1
View File
@@ -14,7 +14,7 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import { info, warn } from './ui'; import { ok, info, warn, fail } from './ui';
import { getWebSearchConfig } from '../config/unified-config-loader'; import { getWebSearchConfig } from '../config/unified-config-loader';
// MCP configuration file path // MCP configuration file path
@@ -30,6 +30,7 @@ interface McpServerConfig {
command?: string; command?: string;
args?: string[]; args?: string[];
env?: Record<string, string>; env?: Record<string, string>;
_managedBy?: 'ccs'; // CCS-managed marker (Option C hybrid)
} }
/** /**
@@ -48,6 +49,7 @@ const WEB_SEARCH_PRIME_CONFIG: McpServerConfig = {
type: 'http', type: 'http',
url: 'https://api.z.ai/api/mcp/web_search_prime/mcp', url: 'https://api.z.ai/api/mcp/web_search_prime/mcp',
headers: {}, headers: {},
_managedBy: 'ccs',
}; };
/** /**
@@ -61,6 +63,7 @@ const BRAVE_SEARCH_CONFIG: McpServerConfig = {
command: 'npx', command: 'npx',
args: ['-y', '@modelcontextprotocol/server-brave-search'], args: ['-y', '@modelcontextprotocol/server-brave-search'],
env: {}, env: {},
_managedBy: 'ccs',
}; };
/** /**
@@ -74,6 +77,7 @@ const TAVILY_CONFIG: McpServerConfig = {
command: 'npx', command: 'npx',
args: ['-y', '@tavily/mcp-server'], args: ['-y', '@tavily/mcp-server'],
env: {}, env: {},
_managedBy: 'ccs',
}; };
/** /**
@@ -571,3 +575,230 @@ function ensureWebSearchHookConfigInternal(): boolean {
return false; return false;
} }
} }
// ========== Phase 1: MCP Status Functions ==========
/**
* MCP web search status with ownership tracking
*/
export interface McpWebSearchStatus {
configured: boolean;
ccsManaged: string[]; // CCS-managed server names
userAdded: string[]; // User-added server names
}
/**
* Get comprehensive MCP web search status
*
* Distinguishes CCS-managed vs user-added MCP servers
* based on _managedBy marker.
*/
export function getMcpWebSearchStatus(): McpWebSearchStatus {
const result: McpWebSearchStatus = {
configured: false,
ccsManaged: [],
userAdded: [],
};
try {
if (!fs.existsSync(MCP_CONFIG_PATH)) return result;
const content = fs.readFileSync(MCP_CONFIG_PATH, 'utf8');
const config: McpConfig = JSON.parse(content);
if (!config.mcpServers) return result;
for (const [name, server] of Object.entries(config.mcpServers)) {
// Check if it's a web search server
const lowerName = name.toLowerCase();
const isWebSearch =
lowerName.includes('web-search') ||
lowerName.includes('websearch') ||
lowerName.includes('tavily') ||
lowerName.includes('brave');
if (!isWebSearch) continue;
if (server._managedBy === 'ccs') {
result.ccsManaged.push(name);
} else {
result.userAdded.push(name);
}
}
result.configured = result.ccsManaged.length > 0 || result.userAdded.length > 0;
return result;
} catch {
return result;
}
}
/**
* Check if CCS-managed web search MCP is configured
*/
export function hasCcsManagedWebSearch(): boolean {
const status = getMcpWebSearchStatus();
return status.ccsManaged.length > 0;
}
// ========== Phase 2: Gemini CLI Detection ==========
import { execSync } from 'child_process';
/**
* Gemini CLI installation status
*/
export interface GeminiCliStatus {
installed: boolean;
path: string | null;
version: string | null;
}
// Cache for Gemini CLI status (per process)
let geminiCliCache: GeminiCliStatus | null = null;
/**
* Check if Gemini CLI is installed globally
*
* Requires global install: `npm install -g @google/gemini-cli`
* No npx fallback - must be in PATH
*
* @returns Gemini CLI status with path and version
*/
export function getGeminiCliStatus(): GeminiCliStatus {
// Return cached result if available
if (geminiCliCache) {
return geminiCliCache;
}
const result: GeminiCliStatus = {
installed: false,
path: null,
version: null,
};
try {
const isWindows = process.platform === 'win32';
const whichCmd = isWindows ? 'where gemini' : 'which gemini';
const pathResult = execSync(whichCmd, {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
});
const geminiPath = pathResult.trim().split('\n')[0]; // First result on Windows
if (geminiPath) {
result.installed = true;
result.path = geminiPath;
// Try to get version
try {
const versionResult = execSync('gemini --version', {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
});
result.version = versionResult.trim();
} catch {
// Version check failed, but CLI is installed
result.version = 'unknown';
}
}
} catch {
// Command not found - Gemini CLI not installed
}
// Cache result
geminiCliCache = result;
return result;
}
/**
* Check if Gemini CLI is available (quick boolean check)
*/
export function hasGeminiCli(): boolean {
return getGeminiCliStatus().installed;
}
/**
* Clear Gemini CLI cache (for testing or after installation)
*/
export function clearGeminiCliCache(): void {
geminiCliCache = null;
}
// ========== Phase 3: WebSearch Readiness Status ==========
/**
* WebSearch availability status for third-party profiles
*/
export type WebSearchReadiness = 'ready' | 'mcp-only' | 'unavailable';
/**
* WebSearch status for display
*/
export interface WebSearchStatus {
readiness: WebSearchReadiness;
geminiCli: boolean;
mcpConfigured: boolean;
message: string;
}
/**
* Get WebSearch readiness status for display
*
* Called on third-party profile startup to inform user.
*/
export function getWebSearchReadiness(): WebSearchStatus {
const geminiCli = hasGeminiCli();
const mcpStatus = getMcpWebSearchStatus();
const mcpConfigured = mcpStatus.configured;
if (geminiCli) {
return {
readiness: 'ready',
geminiCli: true,
mcpConfigured,
message: 'Ready (Gemini CLI)',
};
}
if (mcpConfigured) {
return {
readiness: 'mcp-only',
geminiCli: false,
mcpConfigured: true,
message: 'MCP fallback only',
};
}
return {
readiness: 'unavailable',
geminiCli: false,
mcpConfigured: false,
message: 'Unavailable (run: ccs config)',
};
}
/**
* Display WebSearch status (single line, equilibrium UX)
*
* Only call for third-party profiles.
*/
export function displayWebSearchStatus(): void {
const status = getWebSearchReadiness();
switch (status.readiness) {
case 'ready':
console.error(ok(`WebSearch: ${status.message}`));
break;
case 'mcp-only':
console.error(info(`WebSearch: ${status.message}`));
break;
case 'unavailable':
console.error(fail(`WebSearch: ${status.message}`));
break;
}
}
+36
View File
@@ -57,6 +57,11 @@ import { getWebSearchConfig } from '../config/unified-config-loader';
import type { WebSearchConfig } from '../config/unified-config-types'; import type { WebSearchConfig } from '../config/unified-config-types';
import { isUnifiedConfig } from '../config/unified-config-types'; import { isUnifiedConfig } from '../config/unified-config-types';
import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys'; import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys';
import {
getWebSearchReadiness,
getMcpWebSearchStatus,
getGeminiCliStatus,
} from '../utils/mcp-manager';
export const apiRoutes = Router(); export const apiRoutes = Router();
@@ -1475,3 +1480,34 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
res.status(500).json({ error: (error as Error).message }); res.status(500).json({ error: (error as Error).message });
} }
}); });
/**
* GET /api/websearch/status - Get comprehensive WebSearch status
* Returns: { geminiCli, mcpServers, readiness }
*/
apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => {
try {
const geminiCli = getGeminiCliStatus();
const mcpStatus = getMcpWebSearchStatus();
const readiness = getWebSearchReadiness();
res.json({
geminiCli: {
installed: geminiCli.installed,
path: geminiCli.path,
version: geminiCli.version,
},
mcpServers: {
configured: mcpStatus.configured,
ccsManaged: mcpStatus.ccsManaged,
userAdded: mcpStatus.userAdded,
},
readiness: {
status: readiness.readiness,
message: readiness.message,
},
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
+122 -1
View File
@@ -23,16 +23,36 @@ interface WebSearchConfig {
fallback: boolean; fallback: boolean;
} }
interface WebSearchStatus {
geminiCli: {
installed: boolean;
path: string | null;
version: string | null;
};
mcpServers: {
configured: boolean;
ccsManaged: string[];
userAdded: string[];
};
readiness: {
status: 'ready' | 'mcp-only' | 'unavailable';
message: string;
};
}
export function SettingsPage() { export function SettingsPage() {
const [config, setConfig] = useState<WebSearchConfig | null>(null); const [config, setConfig] = useState<WebSearchConfig | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [status, setStatus] = useState<WebSearchStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
// Load config on mount // Load config and status on mount
useEffect(() => { useEffect(() => {
fetchConfig(); fetchConfig();
fetchStatus();
}, []); }, []);
const fetchConfig = async () => { const fetchConfig = async () => {
@@ -50,6 +70,20 @@ export function SettingsPage() {
} }
}; };
const fetchStatus = async () => {
try {
setStatusLoading(true);
const res = await fetch('/api/websearch/status');
if (!res.ok) throw new Error('Failed to load status');
const data = await res.json();
setStatus(data);
} catch (err) {
console.error('Failed to fetch WebSearch status:', err);
} finally {
setStatusLoading(false);
}
};
const saveConfig = async (updates: Partial<WebSearchConfig>) => { const saveConfig = async (updates: Partial<WebSearchConfig>) => {
if (!config) return; if (!config) return;
@@ -131,6 +165,93 @@ export function SettingsPage() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{/* WebSearch Status Panel */}
<div className="space-y-4 pb-4 border-b">
<h4 className="font-medium flex items-center gap-2">
Status
<Button variant="ghost" size="sm" onClick={fetchStatus} disabled={statusLoading}>
<RefreshCw className={`w-3 h-3 ${statusLoading ? 'animate-spin' : ''}`} />
</Button>
</h4>
{statusLoading ? (
<div className="flex items-center gap-2 text-muted-foreground">
<RefreshCw className="w-4 h-4 animate-spin" />
Checking status...
</div>
) : status ? (
<div className="space-y-3">
{/* Overall Readiness */}
<div className="flex items-center gap-2">
{status.readiness.status === 'ready' && (
<CheckCircle2 className="w-4 h-4 text-green-600" />
)}
{status.readiness.status === 'mcp-only' && (
<Info className="w-4 h-4 text-blue-600" />
)}
{status.readiness.status === 'unavailable' && (
<AlertCircle className="w-4 h-4 text-red-600" />
)}
<span className="font-medium">{status.readiness.message}</span>
</div>
{/* Gemini CLI Status */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<div
className={`w-2 h-2 rounded-full mt-2 ${
status.geminiCli.installed ? 'bg-green-500' : 'bg-gray-400'
}`}
/>
<div className="flex-1">
<p className="font-medium">Gemini CLI</p>
{status.geminiCli.installed ? (
<p className="text-sm text-muted-foreground">
Installed {status.geminiCli.version && `(${status.geminiCli.version})`}
</p>
) : (
<div className="text-sm text-muted-foreground">
<p>Not installed</p>
<code className="text-xs bg-muted px-1 py-0.5 rounded">
npm install -g @google/gemini-cli
</code>
</div>
)}
</div>
</div>
{/* MCP Servers */}
<div className="space-y-2">
<p className="text-sm font-medium">MCP Servers</p>
{status.mcpServers.ccsManaged.length === 0 &&
status.mcpServers.userAdded.length === 0 ? (
<p className="text-sm text-muted-foreground">No web search MCP configured</p>
) : (
<div className="space-y-1">
{status.mcpServers.ccsManaged.map((name) => (
<div key={name} className="flex items-center gap-2 text-sm">
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
Managed by CCS
</span>
<span>{name}</span>
</div>
))}
{status.mcpServers.userAdded.map((name) => (
<div key={name} className="flex items-center gap-2 text-sm">
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
User-added
</span>
<span>{name}</span>
</div>
))}
</div>
)}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">Status unavailable</p>
)}
</div>
<div className="space-y-4"> <div className="space-y-4">
{/* Enable/Disable */} {/* Enable/Disable */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">