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);
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 = {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
@@ -143,12 +145,18 @@ function tryGeminiSearch(query) {
const prompt = buildGeminiPrompt(query);
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
// Note: gemini CLI uses OAuth auth, no API key needed
const spawnResult = spawnSync('gemini', ['-p', prompt], {
// Execute gemini CLI with required flags:
// --model gemini-2.5-flash: Use the flash model for fast responses
// --yolo: Skip confirmation prompts for tool use
// -p: Provide prompt
const spawnResult = spawnSync('gemini', [
'--model', 'gemini-2.5-flash',
'--yolo',
'-p', prompt
], {
encoding: 'utf8',
timeout: timeoutMs,
maxBuffer: 1024 * 1024 * 2, // 2MB buffer for large responses
@@ -209,7 +217,7 @@ function tryGeminiSearch(query) {
if (err.code === 'ENOENT') {
return {
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) {
return [
'[WebSearch Results via Gemini]',
'=== WEBSEARCH COMPLETED SUCCESSFULLY ===',
'(via Gemini CLI - this is NOT an error)',
'',
`Query: "${query}"`,
'',
'---',
'',
content,
'',
'---',
'',
'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.',
'=========================================',
'Use this information to answer the user. Search again if needed.',
].join('\n');
}
@@ -285,6 +290,10 @@ function getMcpFallbackMessage(query, 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.',
'Please use one of the following MCP tools instead:',
'',
+54 -5
View File
@@ -4,13 +4,20 @@
#
# Options:
# --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
SKIP_VALIDATE=false
FORCE_NPM=false
FORCE_BUN=false
for arg in "$@"; do
case $arg in
--skip-validate) SKIP_VALIDATE=true ;;
--npm) FORCE_NPM=true ;;
--bun) FORCE_BUN=true ;;
esac
done
@@ -19,6 +26,42 @@ echo "[i] CCS Dev Install - Starting..."
# Get to the right directory
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
echo "[i] Building TypeScript..."
bun run build
@@ -27,10 +70,10 @@ bun run build
echo "[i] Creating package..."
if [ "$SKIP_VALIDATE" = true ]; then
# Skip validation, just pack
bun pm pack --ignore-scripts
npm pack --ignore-scripts 2>/dev/null || bun pm pack --ignore-scripts
else
# Full pack with validation (runs prepublishOnly)
bun pm pack
npm pack 2>/dev/null || bun pm pack
fi
# Find the tarball
@@ -43,9 +86,15 @@ fi
echo "[i] Found tarball: $TARBALL"
# Install globally using npm (handles bin linking correctly)
echo "[i] Installing globally with npm..."
npm install -g "$TARBALL"
# Install globally using detected package manager
echo "[i] Installing globally with $PKG_MANAGER..."
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
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 { ErrorManager } from './utils/error-manager';
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 { handleVersionCommand } from './commands/version-command';
@@ -384,6 +388,9 @@ async function main(): Promise<void> {
ensureMcpWebSearch();
installWebSearchHook();
// Display WebSearch status (single line, equilibrium UX)
displayWebSearchStatus();
// Check if this is GLMT profile (requires proxy)
if (profileInfo.name === 'glmt') {
// GLMT FLOW: Settings-based with embedded proxy for thinking support
+8 -1
View File
@@ -39,7 +39,11 @@ import {
} from './account-manager';
import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands';
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 */
const DEFAULT_CONFIG: ExecutorConfig = {
@@ -122,6 +126,9 @@ export async function execClaudeWithCLIProxy(
// Hook intercepts WebSearch, tries Gemini CLI first, falls back to MCP
installWebSearchHook();
// Display WebSearch status (single line, equilibrium UX)
displayWebSearchStatus();
// Validate provider
const providerConfig = getProviderConfig(provider);
log(`Provider: ${providerConfig.displayName}`);
+232 -1
View File
@@ -14,7 +14,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { info, warn } from './ui';
import { ok, info, warn, fail } from './ui';
import { getWebSearchConfig } from '../config/unified-config-loader';
// MCP configuration file path
@@ -30,6 +30,7 @@ interface McpServerConfig {
command?: string;
args?: 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',
url: 'https://api.z.ai/api/mcp/web_search_prime/mcp',
headers: {},
_managedBy: 'ccs',
};
/**
@@ -61,6 +63,7 @@ const BRAVE_SEARCH_CONFIG: McpServerConfig = {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-brave-search'],
env: {},
_managedBy: 'ccs',
};
/**
@@ -74,6 +77,7 @@ const TAVILY_CONFIG: McpServerConfig = {
command: 'npx',
args: ['-y', '@tavily/mcp-server'],
env: {},
_managedBy: 'ccs',
};
/**
@@ -571,3 +575,230 @@ function ensureWebSearchHookConfigInternal(): boolean {
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 { isUnifiedConfig } from '../config/unified-config-types';
import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys';
import {
getWebSearchReadiness,
getMcpWebSearchStatus,
getGeminiCliStatus,
} from '../utils/mcp-manager';
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 });
}
});
/**
* 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;
}
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() {
const [config, setConfig] = useState<WebSearchConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
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(() => {
fetchConfig();
fetchStatus();
}, []);
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>) => {
if (!config) return;
@@ -131,6 +165,93 @@ export function SettingsPage() {
</AlertDescription>
</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">
{/* Enable/Disable */}
<div className="flex items-center justify-between">