mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 02:19:39 +00:00
feat(websearch): add Grok CLI support and improve install guidance
- Add Grok CLI detection (grok-4-cli by lalomorales22) alongside Gemini CLI - Add WebSearch health check group to health-service.ts - Update settings UI to show both Gemini and Grok CLI providers - Add detailed install hints when no WebSearch CLI is installed - Update API routes to return grokCli status - Both CLI and dashboard users now see clear installation guidance Providers: - Gemini CLI: FREE tier (1000 req/day), no API key needed - Grok CLI: Requires xAI API key (XAI_API_KEY), web + X search
This commit is contained in:
@@ -1,333 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* CCS WebSearch Gemini Transformer Hook
|
|
||||||
*
|
|
||||||
* Intercepts Claude's WebSearch tool and executes search via Gemini CLI.
|
|
||||||
* This is the ultimate solution for third-party providers that lack
|
|
||||||
* native WebSearch (gemini, agy, codex, qwen profiles).
|
|
||||||
*
|
|
||||||
* Strategy: Execute-and-Deny Pattern
|
|
||||||
* 1. Intercept WebSearch PreToolUse event
|
|
||||||
* 2. Execute `gemini -p` with google_web_search tool
|
|
||||||
* 3. Return deny with search results in permissionDecisionReason
|
|
||||||
* 4. Claude receives results as feedback, continues conversation
|
|
||||||
*
|
|
||||||
* If Gemini CLI fails, falls back to MCP redirect.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* Configured in ~/.claude/settings.json:
|
|
||||||
* {
|
|
||||||
* "hooks": {
|
|
||||||
* "PreToolUse": [{
|
|
||||||
* "matcher": "WebSearch",
|
|
||||||
* "hooks": [{
|
|
||||||
* "type": "command",
|
|
||||||
* "command": "node ~/.ccs/hooks/websearch-gemini-transformer.cjs",
|
|
||||||
* "timeout": 60
|
|
||||||
* }]
|
|
||||||
* }]
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* Environment variables:
|
|
||||||
* CCS_DEBUG=1 - Enable debug output
|
|
||||||
* CCS_WEBSEARCH_SKIP=1 - Skip this hook entirely (allow WebSearch)
|
|
||||||
* CCS_GEMINI_SKIP=1 - Skip Gemini CLI, use MCP fallback only
|
|
||||||
* CCS_GEMINI_TIMEOUT=55 - Gemini CLI timeout in seconds (default: 55)
|
|
||||||
*
|
|
||||||
* Exit codes:
|
|
||||||
* 0 - Allow tool (pass-through) or deny with results
|
|
||||||
* 2 - Block tool (deny with message)
|
|
||||||
*
|
|
||||||
* @module hooks/websearch-gemini-transformer
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { spawnSync } = require('child_process');
|
|
||||||
|
|
||||||
// Minimum response length to consider valid (prevent false positives from error messages)
|
|
||||||
const MIN_VALID_RESPONSE_LENGTH = 20;
|
|
||||||
|
|
||||||
// Read input from stdin
|
|
||||||
let input = '';
|
|
||||||
process.stdin.setEncoding('utf8');
|
|
||||||
process.stdin.on('data', (chunk) => {
|
|
||||||
input += chunk;
|
|
||||||
});
|
|
||||||
process.stdin.on('end', () => {
|
|
||||||
processHook();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle stdin not being available
|
|
||||||
process.stdin.on('error', () => {
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main hook processing logic
|
|
||||||
*/
|
|
||||||
function processHook() {
|
|
||||||
try {
|
|
||||||
// Skip if disabled
|
|
||||||
if (process.env.CCS_WEBSEARCH_SKIP === '1') {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(input);
|
|
||||||
|
|
||||||
// Only handle WebSearch tool
|
|
||||||
if (data.tool_name !== 'WebSearch') {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = data.tool_input?.query || '';
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
// No query provided - allow native handling (will fail anyway)
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if Gemini CLI is specifically disabled - skip straight to MCP
|
|
||||||
if (process.env.CCS_GEMINI_SKIP === '1') {
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error('[CCS Hook] Gemini CLI disabled, using MCP fallback');
|
|
||||||
}
|
|
||||||
outputMcpFallback(query, 'Gemini CLI disabled by configuration');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try Gemini CLI first
|
|
||||||
const geminiResult = tryGeminiSearch(query);
|
|
||||||
|
|
||||||
if (geminiResult.success) {
|
|
||||||
// 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',
|
|
||||||
permissionDecision: 'deny',
|
|
||||||
permissionDecisionReason: formatGeminiResults(query, geminiResult.content),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(JSON.stringify(output));
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini failed - fall back to MCP redirect
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(`[CCS Hook] Gemini failed: ${geminiResult.error}, falling back to MCP`);
|
|
||||||
}
|
|
||||||
|
|
||||||
outputMcpFallback(query, geminiResult.error);
|
|
||||||
} catch (err) {
|
|
||||||
// Don't block on parse errors - allow tool to proceed
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error('[CCS Hook] Parse error:', err.message);
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to execute search via Gemini CLI
|
|
||||||
*
|
|
||||||
* @param {string} query - Search query
|
|
||||||
* @returns {{ success: boolean, content?: string, error?: string }}
|
|
||||||
*/
|
|
||||||
function tryGeminiSearch(query) {
|
|
||||||
try {
|
|
||||||
// Get timeout from env or default to 55 seconds (under 60s hook timeout)
|
|
||||||
const timeoutSec = parseInt(process.env.CCS_GEMINI_TIMEOUT || '55', 10);
|
|
||||||
const timeoutMs = timeoutSec * 1000;
|
|
||||||
|
|
||||||
// Build prompt for Gemini with explicit instruction to use web search
|
|
||||||
const prompt = buildGeminiPrompt(query);
|
|
||||||
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(`[CCS Hook] Executing: gemini --model gemini-2.5-flash --yolo -p "..."`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle spawn errors
|
|
||||||
if (spawnResult.error) {
|
|
||||||
throw spawnResult.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for non-zero exit code
|
|
||||||
if (spawnResult.status !== 0) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `Gemini CLI exited with code ${spawnResult.status}: ${(spawnResult.stderr || '').substring(0, 200)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = spawnResult.stdout || '';
|
|
||||||
const trimmedResult = result.trim();
|
|
||||||
|
|
||||||
// Check if result looks like an error or empty
|
|
||||||
if (!trimmedResult || trimmedResult.length < MIN_VALID_RESPONSE_LENGTH) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Empty or too short response from Gemini',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for common error patterns
|
|
||||||
const lowerResult = trimmedResult.toLowerCase();
|
|
||||||
if (
|
|
||||||
lowerResult.includes('error:') ||
|
|
||||||
lowerResult.includes('failed to') ||
|
|
||||||
lowerResult.includes('unable to') ||
|
|
||||||
lowerResult.includes('authentication required')
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `Gemini returned error: ${trimmedResult.substring(0, 100)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
content: trimmedResult,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
// Handle different error types
|
|
||||||
if (err.killed) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Gemini CLI timed out',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err.code === 'ENOENT') {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Gemini CLI not installed. Install with: npm install -g @google/gemini-cli',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check stderr for more details
|
|
||||||
if (err.stderr) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `Gemini CLI error: ${err.stderr.substring(0, 200)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: err.message || 'Unknown error executing Gemini CLI',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the prompt for Gemini CLI
|
|
||||||
* Instructs Gemini to use google_web_search tool for the query
|
|
||||||
*
|
|
||||||
* @param {string} query - Original search query
|
|
||||||
* @returns {string} Formatted prompt
|
|
||||||
*/
|
|
||||||
function buildGeminiPrompt(query) {
|
|
||||||
return [
|
|
||||||
`Search the web for: ${query}`,
|
|
||||||
'',
|
|
||||||
'Instructions:',
|
|
||||||
'1. Use the google_web_search tool to find current information',
|
|
||||||
'2. Provide a comprehensive summary of the search results',
|
|
||||||
'3. Include relevant URLs/sources when available',
|
|
||||||
'4. Be concise but thorough',
|
|
||||||
'5. Focus on factual, up-to-date information',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format Gemini search results for Claude
|
|
||||||
*
|
|
||||||
* @param {string} query - Original query
|
|
||||||
* @param {string} content - Gemini response content
|
|
||||||
* @returns {string} Formatted message for Claude
|
|
||||||
*/
|
|
||||||
function formatGeminiResults(query, content) {
|
|
||||||
return [
|
|
||||||
'=== WEBSEARCH COMPLETED SUCCESSFULLY ===',
|
|
||||||
'(via Gemini CLI - this is NOT an error)',
|
|
||||||
'',
|
|
||||||
`Query: "${query}"`,
|
|
||||||
'',
|
|
||||||
content,
|
|
||||||
'',
|
|
||||||
'=========================================',
|
|
||||||
'Use this information to answer the user. Search again if needed.',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get MCP fallback message when Gemini fails
|
|
||||||
*
|
|
||||||
* @param {string} query - Original query
|
|
||||||
* @param {string} error - Error message from Gemini attempt
|
|
||||||
* @returns {string} Message directing Claude to use MCP
|
|
||||||
*/
|
|
||||||
function getMcpFallbackMessage(query, error) {
|
|
||||||
return [
|
|
||||||
'[WebSearch Unavailable - Use MCP Fallback]',
|
|
||||||
'',
|
|
||||||
`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:',
|
|
||||||
'',
|
|
||||||
'1. mcp__web-search-prime__webSearchPrime (requires z.ai subscription)',
|
|
||||||
'2. mcp__brave-search__brave_web_search (if configured)',
|
|
||||||
'3. mcp__tavily__search (if configured)',
|
|
||||||
'',
|
|
||||||
`Use the same query: "${query}"`,
|
|
||||||
'',
|
|
||||||
'Example:',
|
|
||||||
'```',
|
|
||||||
'mcp__web-search-prime__webSearchPrime',
|
|
||||||
`search_query: "${query}"`,
|
|
||||||
'```',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Output MCP fallback response and exit
|
|
||||||
*
|
|
||||||
* @param {string} query - Original query
|
|
||||||
* @param {string} error - Error message
|
|
||||||
*/
|
|
||||||
function outputMcpFallback(query, error) {
|
|
||||||
const output = {
|
|
||||||
decision: 'block',
|
|
||||||
reason: 'WebSearch unavailable - use MCP fallback',
|
|
||||||
hookSpecificOutput: {
|
|
||||||
hookEventName: 'PreToolUse',
|
|
||||||
permissionDecision: 'deny',
|
|
||||||
permissionDecisionReason: getMcpFallbackMessage(query, error),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(JSON.stringify(output));
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* CCS WebSearch Hook - CLI Tool Executor
|
||||||
|
*
|
||||||
|
* Intercepts Claude's WebSearch tool and executes search via CLI tools.
|
||||||
|
* Currently supports Gemini CLI, designed for easy addition of future tools.
|
||||||
|
*
|
||||||
|
* Environment Variables (set by CCS):
|
||||||
|
* CCS_WEBSEARCH_SKIP=1 - Skip this hook entirely (for official Claude)
|
||||||
|
* CCS_WEBSEARCH_ENABLED=1 - Enable WebSearch (default: 1)
|
||||||
|
* CCS_WEBSEARCH_TIMEOUT=55 - Timeout in seconds (default: 55)
|
||||||
|
* CCS_DEBUG=1 - Enable debug output
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 - Allow tool (pass-through to native WebSearch)
|
||||||
|
* 2 - Block tool (deny with results/message)
|
||||||
|
*
|
||||||
|
* @module hooks/websearch-gemini-transformer
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
// Minimum response length to consider valid
|
||||||
|
const MIN_VALID_RESPONSE_LENGTH = 20;
|
||||||
|
|
||||||
|
// Default timeout in seconds
|
||||||
|
const DEFAULT_TIMEOUT_SEC = 55;
|
||||||
|
|
||||||
|
// Read input from stdin
|
||||||
|
let input = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', (chunk) => {
|
||||||
|
input += chunk;
|
||||||
|
});
|
||||||
|
process.stdin.on('end', () => {
|
||||||
|
processHook();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle stdin not being available
|
||||||
|
process.stdin.on('error', () => {
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a CLI tool is available
|
||||||
|
*/
|
||||||
|
function isCliAvailable(cmd) {
|
||||||
|
try {
|
||||||
|
const result = spawnSync('which', [cmd], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 2000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
return result.status === 0 && result.stdout.trim().length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main hook processing logic
|
||||||
|
*/
|
||||||
|
async function processHook() {
|
||||||
|
try {
|
||||||
|
// Skip if disabled (for official Claude subscriptions)
|
||||||
|
if (process.env.CCS_WEBSEARCH_SKIP === '1') {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if enabled (default: enabled)
|
||||||
|
if (process.env.CCS_WEBSEARCH_ENABLED === '0') {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(input);
|
||||||
|
|
||||||
|
// Only handle WebSearch tool
|
||||||
|
if (data.tool_name !== 'WebSearch') {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = data.tool_input?.query || '';
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = parseInt(process.env.CCS_WEBSEARCH_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
||||||
|
|
||||||
|
// Try Gemini CLI (currently the only supported CLI tool)
|
||||||
|
if (isCliAvailable('gemini')) {
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error('[CCS Hook] Executing Gemini CLI...');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = tryGeminiSearch(query, timeout);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
outputSuccess(query, result.content, 'Gemini CLI');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(`[CCS Hook] Gemini failed: ${result.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini failed - show error message
|
||||||
|
outputError(query, result.error, 'Gemini CLI');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No CLI tools available
|
||||||
|
outputNoToolsMessage(query);
|
||||||
|
} catch (err) {
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error('[CCS Hook] Parse error:', err.message);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute search via Gemini CLI
|
||||||
|
*/
|
||||||
|
function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||||
|
try {
|
||||||
|
const timeoutMs = timeoutSec * 1000;
|
||||||
|
|
||||||
|
const prompt = [
|
||||||
|
`Search the web for: ${query}`,
|
||||||
|
'',
|
||||||
|
'Instructions:',
|
||||||
|
'1. Use the google_web_search tool to find current information',
|
||||||
|
'2. Provide a comprehensive summary of the search results',
|
||||||
|
'3. Include relevant URLs/sources when available',
|
||||||
|
'4. Be concise but thorough',
|
||||||
|
'5. Focus on factual, up-to-date information',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error('[CCS Hook] Executing: gemini --model gemini-2.5-flash --yolo -p "..."');
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnResult = spawnSync(
|
||||||
|
'gemini',
|
||||||
|
['--model', 'gemini-2.5-flash', '--yolo', '-p', prompt],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: timeoutMs,
|
||||||
|
maxBuffer: 1024 * 1024 * 2,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (spawnResult.error) {
|
||||||
|
if (spawnResult.error.code === 'ENOENT') {
|
||||||
|
return { success: false, error: 'Gemini CLI not installed' };
|
||||||
|
}
|
||||||
|
throw spawnResult.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spawnResult.status !== 0) {
|
||||||
|
const stderr = (spawnResult.stderr || '').trim();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: stderr || `Gemini CLI exited with code ${spawnResult.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (spawnResult.stdout || '').trim();
|
||||||
|
|
||||||
|
if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) {
|
||||||
|
return { success: false, error: 'Empty or too short response from Gemini' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerResult = result.toLowerCase();
|
||||||
|
if (
|
||||||
|
lowerResult.includes('error:') ||
|
||||||
|
lowerResult.includes('failed to') ||
|
||||||
|
lowerResult.includes('authentication required')
|
||||||
|
) {
|
||||||
|
return { success: false, error: `Gemini returned error: ${result.substring(0, 100)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, content: result };
|
||||||
|
} catch (err) {
|
||||||
|
if (err.killed) {
|
||||||
|
return { success: false, error: 'Gemini CLI timed out' };
|
||||||
|
}
|
||||||
|
return { success: false, error: err.message || 'Unknown Gemini error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format search results for Claude
|
||||||
|
*/
|
||||||
|
function formatSearchResults(query, content, providerName) {
|
||||||
|
return [
|
||||||
|
'=== WEBSEARCH COMPLETED SUCCESSFULLY ===',
|
||||||
|
`(via ${providerName} - this is NOT an error)`,
|
||||||
|
'',
|
||||||
|
`Query: "${query}"`,
|
||||||
|
'',
|
||||||
|
content,
|
||||||
|
'',
|
||||||
|
'=========================================',
|
||||||
|
'Use this information to answer the user. Search again if needed.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output success response and exit
|
||||||
|
*/
|
||||||
|
function outputSuccess(query, content, providerName) {
|
||||||
|
const output = {
|
||||||
|
decision: 'block',
|
||||||
|
reason: `WebSearch completed via ${providerName}`,
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: 'PreToolUse',
|
||||||
|
permissionDecision: 'deny',
|
||||||
|
permissionDecisionReason: formatSearchResults(query, content, providerName),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(output));
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output error message
|
||||||
|
*/
|
||||||
|
function outputError(query, error, providerName) {
|
||||||
|
const message = [
|
||||||
|
`[WebSearch - ${providerName} Error]`,
|
||||||
|
'',
|
||||||
|
`Error: ${error}`,
|
||||||
|
'',
|
||||||
|
`Query: "${query}"`,
|
||||||
|
'',
|
||||||
|
'Troubleshooting:',
|
||||||
|
' - Check if Gemini CLI is authenticated: gemini auth status',
|
||||||
|
' - Re-authenticate if needed: gemini auth login',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
decision: 'block',
|
||||||
|
reason: `WebSearch failed: ${error}`,
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: 'PreToolUse',
|
||||||
|
permissionDecision: 'deny',
|
||||||
|
permissionDecisionReason: message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(output));
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output no tools message
|
||||||
|
*/
|
||||||
|
function outputNoToolsMessage(query) {
|
||||||
|
const message = [
|
||||||
|
'[WebSearch - No CLI Tools Available]',
|
||||||
|
'',
|
||||||
|
'WebSearch requires a CLI tool to be installed.',
|
||||||
|
'',
|
||||||
|
'Install Gemini CLI (free, uses Google OAuth):',
|
||||||
|
' npm install -g @google/gemini-cli',
|
||||||
|
' gemini auth login',
|
||||||
|
'',
|
||||||
|
`Query: "${query}"`,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
decision: 'block',
|
||||||
|
reason: 'WebSearch unavailable - no CLI tools installed',
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: 'PreToolUse',
|
||||||
|
permissionDecision: 'deny',
|
||||||
|
permissionDecisionReason: message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(output));
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
+4
-2
@@ -18,7 +18,8 @@ import {
|
|||||||
ensureMcpWebSearch,
|
ensureMcpWebSearch,
|
||||||
installWebSearchHook,
|
installWebSearchHook,
|
||||||
displayWebSearchStatus,
|
displayWebSearchStatus,
|
||||||
} from './utils/mcp-manager';
|
getWebSearchHookEnv,
|
||||||
|
} from './utils/websearch-manager';
|
||||||
|
|
||||||
// Import extracted command handlers
|
// Import extracted command handlers
|
||||||
import { handleVersionCommand } from './commands/version-command';
|
import { handleVersionCommand } from './commands/version-command';
|
||||||
@@ -152,7 +153,8 @@ async function execClaudeWithProxy(
|
|||||||
|
|
||||||
const isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||||
const env = { ...process.env, ...envVars };
|
const webSearchEnv = getWebSearchHookEnv();
|
||||||
|
const env = { ...process.env, ...envVars, ...webSearchEnv };
|
||||||
|
|
||||||
let claude: ChildProcess;
|
let claude: ChildProcess;
|
||||||
if (needsShell) {
|
if (needsShell) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
import { isAuthenticated } from './auth-handler';
|
import { isAuthenticated } from './auth-handler';
|
||||||
import { CLIProxyProvider, ExecutorConfig } from './types';
|
import { CLIProxyProvider, ExecutorConfig } from './types';
|
||||||
import { configureProviderModel, getCurrentModel } from './model-config';
|
import { configureProviderModel, getCurrentModel } from './model-config';
|
||||||
|
import { getWebSearchHookEnv } from '../utils/websearch-manager';
|
||||||
import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog';
|
import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog';
|
||||||
import {
|
import {
|
||||||
findAccountByQuery,
|
findAccountByQuery,
|
||||||
@@ -43,7 +44,7 @@ import {
|
|||||||
ensureMcpWebSearch,
|
ensureMcpWebSearch,
|
||||||
installWebSearchHook,
|
installWebSearchHook,
|
||||||
displayWebSearchStatus,
|
displayWebSearchStatus,
|
||||||
} from '../utils/mcp-manager';
|
} from '../utils/websearch-manager';
|
||||||
|
|
||||||
/** Default executor configuration */
|
/** Default executor configuration */
|
||||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||||
@@ -386,10 +387,14 @@ export async function execClaudeWithCLIProxy(
|
|||||||
// 7. Execute Claude CLI with proxied environment
|
// 7. Execute Claude CLI with proxied environment
|
||||||
// Uses custom settings path (for variants), user settings, or bundled defaults
|
// Uses custom settings path (for variants), user settings, or bundled defaults
|
||||||
const envVars = getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath);
|
const envVars = getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath);
|
||||||
const env = { ...process.env, ...envVars };
|
const webSearchEnv = getWebSearchHookEnv();
|
||||||
|
const env = { ...process.env, ...envVars, ...webSearchEnv };
|
||||||
|
|
||||||
log(`Claude env: ANTHROPIC_BASE_URL=${envVars.ANTHROPIC_BASE_URL}`);
|
log(`Claude env: ANTHROPIC_BASE_URL=${envVars.ANTHROPIC_BASE_URL}`);
|
||||||
log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`);
|
log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`);
|
||||||
|
if (Object.keys(webSearchEnv).length > 0) {
|
||||||
|
log(`Claude env: WebSearch config=${JSON.stringify(webSearchEnv)}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Filter out CCS-specific flags before passing to Claude CLI
|
// Filter out CCS-specific flags before passing to Claude CLI
|
||||||
const ccsFlags = [
|
const ccsFlags = [
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export function getConfigFormat(): 'yaml' | 'json' | 'none' {
|
|||||||
/**
|
/**
|
||||||
* Load unified config from YAML file.
|
* Load unified config from YAML file.
|
||||||
* Returns null if file doesn't exist or format check fails.
|
* Returns null if file doesn't exist or format check fails.
|
||||||
|
* Auto-upgrades config if version is outdated (regenerates comments).
|
||||||
*/
|
*/
|
||||||
export function loadUnifiedConfig(): UnifiedConfig | null {
|
export function loadUnifiedConfig(): UnifiedConfig | null {
|
||||||
const yamlPath = getConfigYamlPath();
|
const yamlPath = getConfigYamlPath();
|
||||||
@@ -82,6 +83,19 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-upgrade if version is outdated (regenerates YAML with new comments)
|
||||||
|
if ((parsed.version ?? 1) < UNIFIED_CONFIG_VERSION) {
|
||||||
|
parsed.version = UNIFIED_CONFIG_VERSION;
|
||||||
|
try {
|
||||||
|
saveUnifiedConfig(parsed);
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(`[i] Config upgraded to v${UNIFIED_CONFIG_VERSION}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore save errors during upgrade - config still works
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err.message : 'Unknown error';
|
const error = err instanceof Error ? err.message : 'Unknown error';
|
||||||
@@ -117,18 +131,20 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
|||||||
},
|
},
|
||||||
websearch: {
|
websearch: {
|
||||||
enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true,
|
enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true,
|
||||||
provider: partial.websearch?.provider ?? defaults.websearch?.provider ?? 'auto',
|
providers: {
|
||||||
fallback: partial.websearch?.fallback ?? defaults.websearch?.fallback ?? true,
|
gemini: {
|
||||||
webSearchPrimeUrl:
|
enabled:
|
||||||
partial.websearch?.webSearchPrimeUrl ?? defaults.websearch?.webSearchPrimeUrl,
|
partial.websearch?.providers?.gemini?.enabled ??
|
||||||
gemini: {
|
partial.websearch?.gemini?.enabled ?? // Legacy fallback
|
||||||
enabled: partial.websearch?.gemini?.enabled ?? defaults.websearch?.gemini?.enabled ?? true,
|
true,
|
||||||
timeout: partial.websearch?.gemini?.timeout ?? defaults.websearch?.gemini?.timeout ?? 55,
|
timeout:
|
||||||
|
partial.websearch?.providers?.gemini?.timeout ??
|
||||||
|
partial.websearch?.gemini?.timeout ?? // Legacy fallback
|
||||||
|
55,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mode: partial.websearch?.mode ?? defaults.websearch?.mode ?? 'sequential',
|
// Legacy fields (keep for backwards compatibility during read)
|
||||||
selectedProviders:
|
gemini: partial.websearch?.gemini,
|
||||||
partial.websearch?.selectedProviders ?? defaults.websearch?.selectedProviders ?? [],
|
|
||||||
customMcp: partial.websearch?.customMcp ?? defaults.websearch?.customMcp ?? [],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -245,11 +261,19 @@ function generateYamlWithComments(config: UnifiedConfig): string {
|
|||||||
// WebSearch section
|
// WebSearch section
|
||||||
if (config.websearch) {
|
if (config.websearch) {
|
||||||
lines.push('# ----------------------------------------------------------------------------');
|
lines.push('# ----------------------------------------------------------------------------');
|
||||||
lines.push('# WebSearch: MCP auto-configuration for third-party profiles');
|
lines.push('# WebSearch: Provider configuration for third-party profiles');
|
||||||
lines.push('# enabled: true/false - Enable/disable MCP web-search auto-config');
|
lines.push('# Dashboard (`ccs config`) is the source of truth for provider selection.');
|
||||||
lines.push('# provider: auto | web-search-prime | brave | tavily');
|
lines.push('#');
|
||||||
lines.push('# fallback: true/false - Enable fallback chain when provider fails');
|
lines.push('# enabled: Master switch - auto-disables when no providers enabled');
|
||||||
lines.push('# API keys: Set BRAVE_API_KEY or TAVILY_API_KEY env vars');
|
lines.push('# mode: sequential (try in order) | parallel (merge all results)');
|
||||||
|
lines.push('#');
|
||||||
|
lines.push('# providers:');
|
||||||
|
lines.push('# gemini: Uses Gemini CLI with google_web_search tool');
|
||||||
|
lines.push('# web-search-prime: HTTP MCP endpoint (z.ai subscription)');
|
||||||
|
lines.push('# brave: Brave Search API (requires BRAVE_API_KEY)');
|
||||||
|
lines.push('# tavily: Tavily Search API (requires TAVILY_API_KEY)');
|
||||||
|
lines.push('#');
|
||||||
|
lines.push('# customMcp: User-defined MCP servers (BYOM - Bring Your Own MCP)');
|
||||||
lines.push('# ----------------------------------------------------------------------------');
|
lines.push('# ----------------------------------------------------------------------------');
|
||||||
lines.push(
|
lines.push(
|
||||||
yaml
|
yaml
|
||||||
@@ -324,43 +348,46 @@ export function setDefaultProfile(name: string): void {
|
|||||||
updateUnifiedConfig({ default: name });
|
updateUnifiedConfig({ default: name });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemini CLI WebSearch configuration
|
||||||
|
*/
|
||||||
|
export interface GeminiWebSearchInfo {
|
||||||
|
enabled: boolean;
|
||||||
|
timeout: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get websearch configuration.
|
* Get websearch configuration.
|
||||||
* Returns defaults if not configured.
|
* Returns defaults if not configured.
|
||||||
|
* Simplified: Gemini CLI only (future CLI tools can be added).
|
||||||
*/
|
*/
|
||||||
export function getWebSearchConfig(): {
|
export function getWebSearchConfig(): {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
provider: 'auto' | 'web-search-prime' | 'brave' | 'tavily';
|
providers?: {
|
||||||
fallback: boolean;
|
gemini?: GeminiWebSearchInfo;
|
||||||
webSearchPrimeUrl?: string;
|
|
||||||
gemini: {
|
|
||||||
enabled: boolean;
|
|
||||||
timeout: number;
|
|
||||||
};
|
};
|
||||||
mode: 'sequential' | 'parallel';
|
// Legacy fields (deprecated)
|
||||||
selectedProviders: string[];
|
gemini?: { enabled?: boolean; timeout?: number };
|
||||||
customMcp: Array<{
|
|
||||||
name: string;
|
|
||||||
type: 'http' | 'stdio';
|
|
||||||
url?: string;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
command?: string;
|
|
||||||
args?: string[];
|
|
||||||
env?: Record<string, string>;
|
|
||||||
}>;
|
|
||||||
} {
|
} {
|
||||||
const config = loadOrCreateUnifiedConfig();
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
|
||||||
|
// Build provider config from new format or legacy fallbacks
|
||||||
|
const geminiConfig: GeminiWebSearchInfo = {
|
||||||
|
enabled:
|
||||||
|
config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? true,
|
||||||
|
timeout:
|
||||||
|
config.websearch?.providers?.gemini?.timeout ?? config.websearch?.gemini?.timeout ?? 55,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-disable master switch if Gemini is not enabled
|
||||||
|
const enabled = geminiConfig.enabled && (config.websearch?.enabled ?? true);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: config.websearch?.enabled ?? true,
|
enabled,
|
||||||
provider: config.websearch?.provider ?? 'auto',
|
providers: {
|
||||||
fallback: config.websearch?.fallback ?? true,
|
gemini: geminiConfig,
|
||||||
webSearchPrimeUrl: config.websearch?.webSearchPrimeUrl,
|
|
||||||
gemini: {
|
|
||||||
enabled: config.websearch?.gemini?.enabled ?? true,
|
|
||||||
timeout: config.websearch?.gemini?.timeout ?? 55,
|
|
||||||
},
|
},
|
||||||
mode: config.websearch?.mode ?? 'sequential',
|
// Legacy field for backwards compatibility
|
||||||
selectedProviders: config.websearch?.selectedProviders ?? [],
|
gemini: config.websearch?.gemini,
|
||||||
customMcp: config.websearch?.customMcp ?? [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
/**
|
/**
|
||||||
* Unified config version.
|
* Unified config version.
|
||||||
* Version 2 = YAML unified format
|
* Version 2 = YAML unified format
|
||||||
|
* Version 3 = WebSearch config comments update
|
||||||
*/
|
*/
|
||||||
export const UNIFIED_CONFIG_VERSION = 2;
|
export const UNIFIED_CONFIG_VERSION = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Account configuration (formerly in profiles.json).
|
* Account configuration (formerly in profiles.json).
|
||||||
@@ -101,55 +102,56 @@ export interface PreferencesConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom MCP server configuration for BYOM (Bring Your Own MCP)
|
* Gemini CLI WebSearch configuration.
|
||||||
*/
|
*/
|
||||||
export interface CustomMcpConfig {
|
export interface GeminiWebSearchConfig {
|
||||||
/** Unique name for this MCP server */
|
/** Enable Gemini CLI for WebSearch (default: true) */
|
||||||
name: string;
|
enabled?: boolean;
|
||||||
/** Server type: HTTP endpoint or stdio command */
|
/** Timeout in seconds (default: 55) */
|
||||||
type: 'http' | 'stdio';
|
timeout?: number;
|
||||||
/** URL for HTTP-based MCP servers */
|
}
|
||||||
url?: string;
|
|
||||||
/** Headers for HTTP requests */
|
/**
|
||||||
headers?: Record<string, string>;
|
* WebSearch providers configuration.
|
||||||
/** Command for stdio-based MCP servers */
|
* Currently supports Gemini CLI only.
|
||||||
command?: string;
|
* Future: opencode, grok-cli, etc.
|
||||||
/** Arguments for stdio command */
|
*/
|
||||||
args?: string[];
|
export interface WebSearchProvidersConfig {
|
||||||
/** Environment variables for the server */
|
/** Gemini CLI - uses google_web_search tool */
|
||||||
env?: Record<string, string>;
|
gemini?: GeminiWebSearchConfig;
|
||||||
|
// Future CLI tools can be added here:
|
||||||
|
// opencode?: { enabled?: boolean; model?: string; };
|
||||||
|
// grok?: { enabled?: boolean; };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSearch configuration.
|
* WebSearch configuration.
|
||||||
* Controls MCP web-search auto-configuration for third-party profiles.
|
* Uses CLI tools (Gemini CLI) to provide WebSearch for third-party profiles.
|
||||||
|
* Third-party providers don't have server-side WebSearch access.
|
||||||
*/
|
*/
|
||||||
export interface WebSearchConfig {
|
export interface WebSearchConfig {
|
||||||
/** Enable auto-configuration of MCP web-search (default: true) */
|
/** Master switch - enable/disable WebSearch (default: true) */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Preferred provider: auto uses fallback chain, or specify one */
|
/** Individual provider configurations */
|
||||||
provider?: 'auto' | 'web-search-prime' | 'brave' | 'tavily';
|
providers?: WebSearchProvidersConfig;
|
||||||
/** Enable fallback chain when preferred provider fails (default: true) */
|
// Legacy fields (deprecated, kept for backwards compatibility)
|
||||||
fallback?: boolean;
|
/** @deprecated Use providers.gemini instead */
|
||||||
/** Custom URL for web-search-prime provider (optional, overrides default) */
|
|
||||||
webSearchPrimeUrl?: string;
|
|
||||||
/**
|
|
||||||
* Gemini CLI configuration for WebSearch transformation.
|
|
||||||
* Uses `gemini -p` with google_web_search tool as primary method.
|
|
||||||
* No API key needed - uses OAuth authentication.
|
|
||||||
*/
|
|
||||||
gemini?: {
|
gemini?: {
|
||||||
/** Enable Gemini CLI for WebSearch (default: true) */
|
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Timeout in seconds for Gemini CLI (default: 55) */
|
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
};
|
};
|
||||||
/** Search mode: sequential (default) or parallel */
|
/** @deprecated Unused */
|
||||||
mode?: 'sequential' | 'parallel';
|
mode?: 'sequential' | 'parallel';
|
||||||
/** Selected providers for parallel mode */
|
/** @deprecated Unused */
|
||||||
|
provider?: 'auto' | 'web-search-prime' | 'brave' | 'tavily';
|
||||||
|
/** @deprecated Unused */
|
||||||
|
fallback?: boolean;
|
||||||
|
/** @deprecated Unused */
|
||||||
|
webSearchPrimeUrl?: string;
|
||||||
|
/** @deprecated Unused */
|
||||||
selectedProviders?: string[];
|
selectedProviders?: string[];
|
||||||
/** Custom MCP servers (BYOM - Bring Your Own MCP) */
|
/** @deprecated Unused */
|
||||||
customMcp?: CustomMcpConfig[];
|
customMcp?: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,11 +212,11 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
|||||||
},
|
},
|
||||||
websearch: {
|
websearch: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
provider: 'auto',
|
providers: {
|
||||||
fallback: true,
|
gemini: {
|
||||||
gemini: {
|
enabled: true,
|
||||||
enabled: true,
|
timeout: 55,
|
||||||
timeout: 55,
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,834 +0,0 @@
|
|||||||
/**
|
|
||||||
* MCP Manager - Manages MCP server configuration for CCS
|
|
||||||
*
|
|
||||||
* Ensures web-search-prime MCP is available for third-party profiles
|
|
||||||
* that cannot use Claude's native WebSearch tool.
|
|
||||||
*
|
|
||||||
* WebSearch is a server-side tool executed by Anthropic's API.
|
|
||||||
* Third-party providers (gemini, agy, codex, qwen) don't have access.
|
|
||||||
* This manager auto-configures MCP web-search as a fallback.
|
|
||||||
*
|
|
||||||
* @module utils/mcp-manager
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as os from 'os';
|
|
||||||
import { ok, info, warn, fail } from './ui';
|
|
||||||
import { getWebSearchConfig } from '../config/unified-config-loader';
|
|
||||||
|
|
||||||
// MCP configuration file path
|
|
||||||
const MCP_CONFIG_PATH = path.join(os.homedir(), '.claude', '.mcp.json');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MCP server configuration interface
|
|
||||||
*/
|
|
||||||
interface McpServerConfig {
|
|
||||||
type: 'http' | 'stdio';
|
|
||||||
url?: string;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
command?: string;
|
|
||||||
args?: string[];
|
|
||||||
env?: Record<string, string>;
|
|
||||||
_managedBy?: 'ccs'; // CCS-managed marker (Option C hybrid)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MCP configuration file structure
|
|
||||||
*/
|
|
||||||
interface McpConfig {
|
|
||||||
mcpServers?: Record<string, McpServerConfig>;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default web-search-prime MCP configuration (primary)
|
|
||||||
* HTTP-based, requires z.ai coding plan subscription
|
|
||||||
*/
|
|
||||||
const WEB_SEARCH_PRIME_CONFIG: McpServerConfig = {
|
|
||||||
type: 'http',
|
|
||||||
url: 'https://api.z.ai/api/mcp/web_search_prime/mcp',
|
|
||||||
headers: {},
|
|
||||||
_managedBy: 'ccs',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Brave Search MCP configuration (secondary fallback)
|
|
||||||
* Requires BRAVE_API_KEY env var
|
|
||||||
* Free tier: 15k queries/month, 1 query/sec
|
|
||||||
* Package: @modelcontextprotocol/server-brave-search
|
|
||||||
*/
|
|
||||||
const BRAVE_SEARCH_CONFIG: McpServerConfig = {
|
|
||||||
type: 'stdio',
|
|
||||||
command: 'npx',
|
|
||||||
args: ['-y', '@modelcontextprotocol/server-brave-search'],
|
|
||||||
env: {},
|
|
||||||
_managedBy: 'ccs',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tavily MCP configuration (tertiary fallback)
|
|
||||||
* Requires TAVILY_API_KEY env var
|
|
||||||
* AI-optimized search, paid service
|
|
||||||
* Package: @tavily/mcp-server (official)
|
|
||||||
*/
|
|
||||||
const TAVILY_CONFIG: McpServerConfig = {
|
|
||||||
type: 'stdio',
|
|
||||||
command: 'npx',
|
|
||||||
args: ['-y', '@tavily/mcp-server'],
|
|
||||||
env: {},
|
|
||||||
_managedBy: 'ccs',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure MCP web-search is configured for third-party profiles
|
|
||||||
*
|
|
||||||
* Called before spawning Claude CLI for CLIProxy profiles.
|
|
||||||
* Respects user configuration from ~/.ccs/config.yaml:
|
|
||||||
* - enabled: false → skip auto-config
|
|
||||||
* - provider: specific → add only that provider
|
|
||||||
* - fallback: false → don't add fallback providers
|
|
||||||
*
|
|
||||||
* Multi-tier MCP fallback chain:
|
|
||||||
* 1. web-search-prime (primary, requires z.ai subscription)
|
|
||||||
* 2. brave-search (if BRAVE_API_KEY set)
|
|
||||||
* 3. tavily (if TAVILY_API_KEY set)
|
|
||||||
*
|
|
||||||
* Only adds MCPs if no web search MCP is already configured.
|
|
||||||
*
|
|
||||||
* @returns true if web search MCP is available, false on error
|
|
||||||
*/
|
|
||||||
export function ensureMcpWebSearch(): boolean {
|
|
||||||
try {
|
|
||||||
// Check user configuration
|
|
||||||
const wsConfig = getWebSearchConfig();
|
|
||||||
|
|
||||||
// If disabled by user, skip auto-configuration
|
|
||||||
if (!wsConfig.enabled) {
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(info('WebSearch auto-config disabled by user'));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let config: McpConfig = { mcpServers: {} };
|
|
||||||
|
|
||||||
// Read existing config if present
|
|
||||||
if (fs.existsSync(MCP_CONFIG_PATH)) {
|
|
||||||
const content = fs.readFileSync(MCP_CONFIG_PATH, 'utf8');
|
|
||||||
try {
|
|
||||||
config = JSON.parse(content);
|
|
||||||
} catch {
|
|
||||||
// Malformed JSON - start fresh but preserve file as backup
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(warn('Malformed .mcp.json - starting fresh'));
|
|
||||||
}
|
|
||||||
config = { mcpServers: {} };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize mcpServers if missing
|
|
||||||
if (!config.mcpServers) {
|
|
||||||
config.mcpServers = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any web search MCP already configured (case-insensitive)
|
|
||||||
const hasWebSearch = Object.keys(config.mcpServers).some((key) => {
|
|
||||||
const lowerKey = key.toLowerCase();
|
|
||||||
return (
|
|
||||||
lowerKey.includes('web-search') ||
|
|
||||||
lowerKey.includes('websearch') ||
|
|
||||||
lowerKey.includes('tavily') ||
|
|
||||||
lowerKey.includes('brave')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasWebSearch) {
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(info('MCP web-search already configured'));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track what we add for logging
|
|
||||||
const addedMcps: string[] = [];
|
|
||||||
|
|
||||||
// Helper to add a specific provider
|
|
||||||
const addProvider = (provider: 'web-search-prime' | 'brave' | 'tavily'): boolean => {
|
|
||||||
// mcpServers is guaranteed to exist here (initialized above)
|
|
||||||
const servers = config.mcpServers as Record<string, McpServerConfig>;
|
|
||||||
|
|
||||||
if (provider === 'web-search-prime') {
|
|
||||||
const webSearchPrimeConfig = { ...WEB_SEARCH_PRIME_CONFIG };
|
|
||||||
// Use configurable URL if provided
|
|
||||||
if (wsConfig.webSearchPrimeUrl) {
|
|
||||||
webSearchPrimeConfig.url = wsConfig.webSearchPrimeUrl;
|
|
||||||
}
|
|
||||||
servers['web-search-prime'] = webSearchPrimeConfig;
|
|
||||||
addedMcps.push('web-search-prime');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === 'brave') {
|
|
||||||
const braveApiKey = process.env.BRAVE_API_KEY;
|
|
||||||
if (braveApiKey) {
|
|
||||||
const braveConfig = { ...BRAVE_SEARCH_CONFIG };
|
|
||||||
braveConfig.env = { BRAVE_API_KEY: braveApiKey };
|
|
||||||
servers['brave-search'] = braveConfig;
|
|
||||||
addedMcps.push('brave-search');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === 'tavily') {
|
|
||||||
const tavilyApiKey = process.env.TAVILY_API_KEY;
|
|
||||||
if (tavilyApiKey) {
|
|
||||||
const tavilyConfig = { ...TAVILY_CONFIG };
|
|
||||||
tavilyConfig.env = { TAVILY_API_KEY: tavilyApiKey };
|
|
||||||
servers['tavily'] = tavilyConfig;
|
|
||||||
addedMcps.push('tavily');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply user's provider preference
|
|
||||||
if (wsConfig.provider === 'auto') {
|
|
||||||
// Auto mode: add all available providers
|
|
||||||
addProvider('web-search-prime');
|
|
||||||
if (wsConfig.fallback) {
|
|
||||||
addProvider('brave');
|
|
||||||
addProvider('tavily');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Specific provider requested
|
|
||||||
const added = addProvider(wsConfig.provider);
|
|
||||||
if (!added && wsConfig.fallback) {
|
|
||||||
// Fallback if preferred provider not available
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(
|
|
||||||
warn(`Preferred provider ${wsConfig.provider} not available, using fallback`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
addProvider('web-search-prime');
|
|
||||||
addProvider('brave');
|
|
||||||
addProvider('tavily');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add custom MCPs from config (BYOM)
|
|
||||||
if (wsConfig.customMcp && wsConfig.customMcp.length > 0) {
|
|
||||||
const servers = config.mcpServers as Record<string, McpServerConfig>;
|
|
||||||
for (const custom of wsConfig.customMcp) {
|
|
||||||
const serverConfig: McpServerConfig = {
|
|
||||||
type: custom.type,
|
|
||||||
_managedBy: 'ccs',
|
|
||||||
};
|
|
||||||
if (custom.type === 'http') {
|
|
||||||
serverConfig.url = custom.url;
|
|
||||||
serverConfig.headers = custom.headers || {};
|
|
||||||
} else {
|
|
||||||
serverConfig.command = custom.command;
|
|
||||||
serverConfig.args = custom.args || [];
|
|
||||||
serverConfig.env = custom.env || {};
|
|
||||||
}
|
|
||||||
servers[custom.name] = serverConfig;
|
|
||||||
addedMcps.push(custom.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If nothing was added, return false
|
|
||||||
if (addedMcps.length === 0) {
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(warn('No web search MCP could be configured'));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure ~/.claude directory exists
|
|
||||||
const claudeDir = path.dirname(MCP_CONFIG_PATH);
|
|
||||||
if (!fs.existsSync(claudeDir)) {
|
|
||||||
fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write config with proper formatting
|
|
||||||
fs.writeFileSync(MCP_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
|
||||||
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(info(`Added MCP servers for web search: ${addedMcps.join(', ')}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(warn(`Failed to configure MCP: ${(error as Error).message}`));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if MCP web-search is configured
|
|
||||||
*
|
|
||||||
* @returns true if any web search MCP is present
|
|
||||||
*/
|
|
||||||
export function hasMcpWebSearch(): boolean {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(MCP_CONFIG_PATH)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = fs.readFileSync(MCP_CONFIG_PATH, 'utf8');
|
|
||||||
const config: McpConfig = JSON.parse(content);
|
|
||||||
|
|
||||||
if (!config.mcpServers) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case-insensitive check for web search MCP
|
|
||||||
return Object.keys(config.mcpServers).some((key) => {
|
|
||||||
const lowerKey = key.toLowerCase();
|
|
||||||
return (
|
|
||||||
lowerKey.includes('web-search') ||
|
|
||||||
lowerKey.includes('websearch') ||
|
|
||||||
lowerKey.includes('tavily') ||
|
|
||||||
lowerKey.includes('brave')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get path to MCP config file
|
|
||||||
*
|
|
||||||
* @returns absolute path to .mcp.json
|
|
||||||
*/
|
|
||||||
export function getMcpConfigPath(): string {
|
|
||||||
return MCP_CONFIG_PATH;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CCS hooks directory
|
|
||||||
const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks');
|
|
||||||
// Legacy hook for simple MCP redirect (deprecated)
|
|
||||||
const BLOCK_WEBSEARCH_HOOK = 'block-websearch.cjs';
|
|
||||||
// New hybrid hook: Gemini CLI first, MCP fallback
|
|
||||||
const GEMINI_TRANSFORMER_HOOK = 'websearch-gemini-transformer.cjs';
|
|
||||||
// Default hook timeout in seconds (Gemini CLI needs time)
|
|
||||||
const HOOK_TIMEOUT_SECONDS = 60;
|
|
||||||
// Path to Claude settings.json
|
|
||||||
const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install WebSearch transformer hook to ~/.ccs/hooks/
|
|
||||||
*
|
|
||||||
* This hook intercepts WebSearch and:
|
|
||||||
* 1. Tries Gemini CLI with google_web_search (no API key needed)
|
|
||||||
* 2. Falls back to MCP redirect if Gemini fails
|
|
||||||
*
|
|
||||||
* This is the ultimate solution for third-party profiles.
|
|
||||||
*
|
|
||||||
* @param options - Installation options
|
|
||||||
* @param options.legacy - Install legacy block-websearch.cjs instead (default: false)
|
|
||||||
* @returns true if hook installed successfully
|
|
||||||
*/
|
|
||||||
export function installWebSearchHook(options?: { legacy?: boolean }): boolean {
|
|
||||||
try {
|
|
||||||
// Ensure hooks directory exists
|
|
||||||
if (!fs.existsSync(CCS_HOOKS_DIR)) {
|
|
||||||
fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which hook to install
|
|
||||||
const hookFileName = options?.legacy ? BLOCK_WEBSEARCH_HOOK : GEMINI_TRANSFORMER_HOOK;
|
|
||||||
const hookPath = path.join(CCS_HOOKS_DIR, hookFileName);
|
|
||||||
|
|
||||||
// Find the bundled hook script
|
|
||||||
// In npm package: node_modules/ccs/lib/hooks/
|
|
||||||
// In development: lib/hooks/
|
|
||||||
const possiblePaths = [
|
|
||||||
path.join(__dirname, '..', '..', 'lib', 'hooks', hookFileName),
|
|
||||||
path.join(__dirname, '..', 'lib', 'hooks', hookFileName),
|
|
||||||
];
|
|
||||||
|
|
||||||
let sourcePath: string | null = null;
|
|
||||||
for (const p of possiblePaths) {
|
|
||||||
if (fs.existsSync(p)) {
|
|
||||||
sourcePath = p;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sourcePath) {
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(warn(`WebSearch hook source not found: ${hookFileName}`));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy hook to ~/.ccs/hooks/
|
|
||||||
fs.copyFileSync(sourcePath, hookPath);
|
|
||||||
fs.chmodSync(hookPath, 0o755);
|
|
||||||
|
|
||||||
// Also install the legacy hook for backward compatibility
|
|
||||||
if (!options?.legacy) {
|
|
||||||
const legacyHookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK);
|
|
||||||
const legacySourcePaths = [
|
|
||||||
path.join(__dirname, '..', '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK),
|
|
||||||
path.join(__dirname, '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const p of legacySourcePaths) {
|
|
||||||
if (fs.existsSync(p)) {
|
|
||||||
fs.copyFileSync(p, legacyHookPath);
|
|
||||||
fs.chmodSync(legacyHookPath, 0o755);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(info(`Installed WebSearch hook: ${hookPath}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also ensure the hook is configured in settings.json
|
|
||||||
// This is called after the hook file is installed
|
|
||||||
ensureWebSearchHookConfigInternal();
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(warn(`Failed to install WebSearch hook: ${(error as Error).message}`));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get WebSearch hook configuration for settings.json
|
|
||||||
*
|
|
||||||
* Returns config for the Gemini transformer hook (default) or legacy hook.
|
|
||||||
*
|
|
||||||
* @param options - Configuration options
|
|
||||||
* @param options.legacy - Use legacy block-websearch.cjs instead (default: false)
|
|
||||||
* @returns hook configuration object for settings.json
|
|
||||||
*/
|
|
||||||
export function getWebSearchHookConfig(options?: { legacy?: boolean }): Record<string, unknown> {
|
|
||||||
const hookFileName = options?.legacy ? BLOCK_WEBSEARCH_HOOK : GEMINI_TRANSFORMER_HOOK;
|
|
||||||
const hookPath = path.join(CCS_HOOKS_DIR, hookFileName);
|
|
||||||
// Legacy hook only needs 5s, Gemini hook needs 60s for CLI execution
|
|
||||||
const timeout = options?.legacy ? 5 : HOOK_TIMEOUT_SECONDS;
|
|
||||||
|
|
||||||
return {
|
|
||||||
PreToolUse: [
|
|
||||||
{
|
|
||||||
matcher: 'WebSearch',
|
|
||||||
hooks: [
|
|
||||||
{
|
|
||||||
type: 'command',
|
|
||||||
command: `node "${hookPath}"`,
|
|
||||||
timeout: timeout,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if WebSearch hook is installed
|
|
||||||
*
|
|
||||||
* Checks for both the new Gemini transformer and legacy block hook.
|
|
||||||
*
|
|
||||||
* @returns true if any WebSearch hook exists
|
|
||||||
*/
|
|
||||||
export function hasWebSearchHook(): boolean {
|
|
||||||
const geminiHookPath = path.join(CCS_HOOKS_DIR, GEMINI_TRANSFORMER_HOOK);
|
|
||||||
const legacyHookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK);
|
|
||||||
return fs.existsSync(geminiHookPath) || fs.existsSync(legacyHookPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get information about installed WebSearch hooks
|
|
||||||
*
|
|
||||||
* @returns Object with hook status details
|
|
||||||
*/
|
|
||||||
export function getWebSearchHookStatus(): {
|
|
||||||
installed: boolean;
|
|
||||||
geminiTransformer: boolean;
|
|
||||||
legacyBlock: boolean;
|
|
||||||
activePath: string | null;
|
|
||||||
} {
|
|
||||||
const geminiHookPath = path.join(CCS_HOOKS_DIR, GEMINI_TRANSFORMER_HOOK);
|
|
||||||
const legacyHookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK);
|
|
||||||
const hasGemini = fs.existsSync(geminiHookPath);
|
|
||||||
const hasLegacy = fs.existsSync(legacyHookPath);
|
|
||||||
|
|
||||||
return {
|
|
||||||
installed: hasGemini || hasLegacy,
|
|
||||||
geminiTransformer: hasGemini,
|
|
||||||
legacyBlock: hasLegacy,
|
|
||||||
// Gemini transformer takes priority
|
|
||||||
activePath: hasGemini ? geminiHookPath : hasLegacy ? legacyHookPath : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get environment variables for WebSearch hook configuration.
|
|
||||||
*
|
|
||||||
* These env vars are read by the websearch-gemini-transformer.cjs hook
|
|
||||||
* to control its behavior without requiring file I/O.
|
|
||||||
*
|
|
||||||
* @returns Record of environment variables to set before spawning Claude
|
|
||||||
*/
|
|
||||||
export function getWebSearchHookEnv(): Record<string, string> {
|
|
||||||
const wsConfig = getWebSearchConfig();
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
|
|
||||||
// Skip hook entirely if disabled
|
|
||||||
if (!wsConfig.enabled) {
|
|
||||||
env.CCS_WEBSEARCH_SKIP = '1';
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip Gemini CLI if specifically disabled
|
|
||||||
if (!wsConfig.gemini.enabled) {
|
|
||||||
env.CCS_GEMINI_SKIP = '1';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set Gemini timeout
|
|
||||||
if (wsConfig.gemini.timeout) {
|
|
||||||
env.CCS_GEMINI_TIMEOUT = String(wsConfig.gemini.timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set search mode (sequential or parallel)
|
|
||||||
if (wsConfig.mode === 'parallel') {
|
|
||||||
env.CCS_WEBSEARCH_MODE = 'parallel';
|
|
||||||
// Pass selected providers for parallel mode
|
|
||||||
if (wsConfig.selectedProviders && wsConfig.selectedProviders.length > 0) {
|
|
||||||
env.CCS_WEBSEARCH_PROVIDERS = wsConfig.selectedProviders.join(',');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure WebSearch hook is configured in ~/.claude/settings.json
|
|
||||||
*
|
|
||||||
* Merges the hook configuration into existing settings.json without
|
|
||||||
* overwriting other settings. Only adds if not already present.
|
|
||||||
*
|
|
||||||
* This is an internal function called by installWebSearchHook.
|
|
||||||
*
|
|
||||||
* @returns true if hook config is present (already existed or was added)
|
|
||||||
*/
|
|
||||||
function ensureWebSearchHookConfigInternal(): boolean {
|
|
||||||
try {
|
|
||||||
const wsConfig = getWebSearchConfig();
|
|
||||||
|
|
||||||
// Skip if disabled
|
|
||||||
if (!wsConfig.enabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read existing settings or start fresh
|
|
||||||
let settings: Record<string, unknown> = {};
|
|
||||||
if (fs.existsSync(CLAUDE_SETTINGS_PATH)) {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8');
|
|
||||||
settings = JSON.parse(content);
|
|
||||||
} catch {
|
|
||||||
// Malformed JSON - start fresh
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(warn('Malformed settings.json - will merge carefully'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if WebSearch hook already configured
|
|
||||||
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
|
|
||||||
if (hooks?.PreToolUse) {
|
|
||||||
const hasWebSearchHook = hooks.PreToolUse.some((h: unknown) => {
|
|
||||||
const hook = h as Record<string, unknown>;
|
|
||||||
return hook.matcher === 'WebSearch';
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasWebSearchHook) {
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(info('WebSearch hook already configured in settings.json'));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get hook config
|
|
||||||
const hookConfig = getWebSearchHookConfig();
|
|
||||||
|
|
||||||
// Merge hook config into settings
|
|
||||||
if (!settings.hooks) {
|
|
||||||
settings.hooks = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingsHooks = settings.hooks as Record<string, unknown[]>;
|
|
||||||
if (!settingsHooks.PreToolUse) {
|
|
||||||
settingsHooks.PreToolUse = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add our hook config
|
|
||||||
const preToolUseHooks = hookConfig.PreToolUse as unknown[];
|
|
||||||
settingsHooks.PreToolUse.push(...preToolUseHooks);
|
|
||||||
|
|
||||||
// Ensure ~/.claude directory exists
|
|
||||||
const claudeDir = path.dirname(CLAUDE_SETTINGS_PATH);
|
|
||||||
if (!fs.existsSync(claudeDir)) {
|
|
||||||
fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write updated settings
|
|
||||||
fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
|
|
||||||
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(info('Added WebSearch hook to settings.json'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.CCS_DEBUG) {
|
|
||||||
console.error(warn(`Failed to configure WebSearch hook: ${(error as Error).message}`));
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { spawn, ChildProcess } from 'child_process';
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
import { ErrorManager } from './error-manager';
|
import { ErrorManager } from './error-manager';
|
||||||
|
import { getWebSearchHookEnv } from './websearch-manager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escape arguments for shell execution (Windows compatibility)
|
* Escape arguments for shell execution (Windows compatibility)
|
||||||
@@ -25,8 +26,13 @@ export function execClaude(
|
|||||||
const isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||||
|
|
||||||
|
// Get WebSearch hook config env vars
|
||||||
|
const webSearchEnv = getWebSearchHookEnv();
|
||||||
|
|
||||||
// Prepare environment (merge with process.env if envVars provided)
|
// Prepare environment (merge with process.env if envVars provided)
|
||||||
const env = envVars ? { ...process.env, ...envVars } : process.env;
|
const env = envVars
|
||||||
|
? { ...process.env, ...envVars, ...webSearchEnv }
|
||||||
|
: { ...process.env, ...webSearchEnv };
|
||||||
|
|
||||||
let child: ChildProcess;
|
let child: ChildProcess;
|
||||||
if (needsShell) {
|
if (needsShell) {
|
||||||
|
|||||||
@@ -0,0 +1,632 @@
|
|||||||
|
/**
|
||||||
|
* WebSearch Manager - Manages WebSearch hook for CCS
|
||||||
|
*
|
||||||
|
* WebSearch is a server-side tool executed by Anthropic's API.
|
||||||
|
* Third-party providers (gemini, agy, codex, qwen) don't have access.
|
||||||
|
* This manager installs a hook that uses CLI tools (Gemini CLI) as fallback.
|
||||||
|
*
|
||||||
|
* Simplified Architecture:
|
||||||
|
* - No MCP complexity
|
||||||
|
* - Uses CLI tools (currently Gemini CLI)
|
||||||
|
* - Easy to extend for future CLI tools (opencode, etc.)
|
||||||
|
*
|
||||||
|
* @module utils/websearch-manager
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { ok, info, warn, fail } from './ui';
|
||||||
|
import { getWebSearchConfig } from '../config/unified-config-loader';
|
||||||
|
|
||||||
|
// CCS hooks directory
|
||||||
|
const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks');
|
||||||
|
|
||||||
|
// Hook file name
|
||||||
|
const WEBSEARCH_HOOK = 'websearch-transformer.cjs';
|
||||||
|
|
||||||
|
// Default hook timeout in seconds (Gemini CLI needs time)
|
||||||
|
const HOOK_TIMEOUT_SECONDS = 60;
|
||||||
|
|
||||||
|
// Path to Claude settings.json
|
||||||
|
const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
|
||||||
|
|
||||||
|
// ========== Gemini CLI Detection ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Grok CLI Detection ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grok CLI installation status
|
||||||
|
*/
|
||||||
|
export interface GrokCliStatus {
|
||||||
|
installed: boolean;
|
||||||
|
path: string | null;
|
||||||
|
version: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for Grok CLI status (per process)
|
||||||
|
let grokCliCache: GrokCliStatus | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Grok CLI is installed globally
|
||||||
|
*
|
||||||
|
* Grok CLI (grok-4-cli by lalomorales22) provides web search + X search.
|
||||||
|
* Requires: `npm install -g grok-cli` and XAI_API_KEY env var.
|
||||||
|
*
|
||||||
|
* @returns Grok CLI status with path and version
|
||||||
|
*/
|
||||||
|
export function getGrokCliStatus(): GrokCliStatus {
|
||||||
|
// Return cached result if available
|
||||||
|
if (grokCliCache) {
|
||||||
|
return grokCliCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: GrokCliStatus = {
|
||||||
|
installed: false,
|
||||||
|
path: null,
|
||||||
|
version: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
const whichCmd = isWindows ? 'where grok' : 'which grok';
|
||||||
|
|
||||||
|
const pathResult = execSync(whichCmd, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 5000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const grokPath = pathResult.trim().split('\n')[0]; // First result on Windows
|
||||||
|
|
||||||
|
if (grokPath) {
|
||||||
|
result.installed = true;
|
||||||
|
result.path = grokPath;
|
||||||
|
|
||||||
|
// Try to get version
|
||||||
|
try {
|
||||||
|
const versionResult = execSync('grok --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 - Grok CLI not installed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache result
|
||||||
|
grokCliCache = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Grok CLI is available (quick boolean check)
|
||||||
|
*/
|
||||||
|
export function hasGrokCli(): boolean {
|
||||||
|
return getGrokCliStatus().installed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear Grok CLI cache (for testing or after installation)
|
||||||
|
*/
|
||||||
|
export function clearGrokCliCache(): void {
|
||||||
|
grokCliCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all CLI caches
|
||||||
|
*/
|
||||||
|
export function clearAllCliCaches(): void {
|
||||||
|
geminiCliCache = null;
|
||||||
|
grokCliCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== CLI Provider Info ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSearch CLI provider information for health checks and UI
|
||||||
|
*/
|
||||||
|
export interface WebSearchCliInfo {
|
||||||
|
/** Provider ID */
|
||||||
|
id: 'gemini' | 'grok';
|
||||||
|
/** Display name */
|
||||||
|
name: string;
|
||||||
|
/** CLI command name */
|
||||||
|
command: string;
|
||||||
|
/** Whether CLI is installed */
|
||||||
|
installed: boolean;
|
||||||
|
/** CLI version if installed */
|
||||||
|
version: string | null;
|
||||||
|
/** Install command */
|
||||||
|
installCommand: string;
|
||||||
|
/** Docs URL */
|
||||||
|
docsUrl: string;
|
||||||
|
/** Whether this provider requires an API key */
|
||||||
|
requiresApiKey: boolean;
|
||||||
|
/** API key environment variable name */
|
||||||
|
apiKeyEnvVar?: string;
|
||||||
|
/** Brief description */
|
||||||
|
description: string;
|
||||||
|
/** Free tier available? */
|
||||||
|
freeTier: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all WebSearch CLI providers with their status
|
||||||
|
*/
|
||||||
|
export function getWebSearchCliProviders(): WebSearchCliInfo[] {
|
||||||
|
const geminiStatus = getGeminiCliStatus();
|
||||||
|
const grokStatus = getGrokCliStatus();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'gemini',
|
||||||
|
name: 'Gemini CLI',
|
||||||
|
command: 'gemini',
|
||||||
|
installed: geminiStatus.installed,
|
||||||
|
version: geminiStatus.version,
|
||||||
|
installCommand: 'npm install -g @google/gemini-cli',
|
||||||
|
docsUrl: 'https://github.com/google-gemini/gemini-cli',
|
||||||
|
requiresApiKey: false,
|
||||||
|
description: 'Google Gemini with web search (FREE tier: 1000 req/day)',
|
||||||
|
freeTier: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'grok',
|
||||||
|
name: 'Grok CLI',
|
||||||
|
command: 'grok',
|
||||||
|
installed: grokStatus.installed,
|
||||||
|
version: grokStatus.version,
|
||||||
|
installCommand: 'npm install -g grok-cli',
|
||||||
|
docsUrl: 'https://github.com/lalomorales22/grok-4-cli',
|
||||||
|
requiresApiKey: true,
|
||||||
|
apiKeyEnvVar: 'XAI_API_KEY',
|
||||||
|
description: 'xAI Grok with web + X search (requires API key)',
|
||||||
|
freeTier: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any WebSearch CLI is available
|
||||||
|
*/
|
||||||
|
export function hasAnyWebSearchCli(): boolean {
|
||||||
|
return hasGeminiCli() || hasGrokCli();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get install hints for CLI-only users when no WebSearch CLI is installed
|
||||||
|
*/
|
||||||
|
export function getCliInstallHints(): string[] {
|
||||||
|
if (hasAnyWebSearchCli()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'[i] WebSearch: No CLI tools installed',
|
||||||
|
' Gemini CLI (FREE): npm i -g @google/gemini-cli',
|
||||||
|
' Grok CLI (paid): npm i -g grok-cli',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Hook Management ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install WebSearch hook to ~/.ccs/hooks/
|
||||||
|
*
|
||||||
|
* This hook intercepts WebSearch and executes via Gemini CLI.
|
||||||
|
*
|
||||||
|
* @returns true if hook installed successfully
|
||||||
|
*/
|
||||||
|
export function installWebSearchHook(): boolean {
|
||||||
|
try {
|
||||||
|
const wsConfig = getWebSearchConfig();
|
||||||
|
|
||||||
|
// Skip if disabled
|
||||||
|
if (!wsConfig.enabled) {
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(info('WebSearch disabled - skipping hook install'));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure hooks directory exists
|
||||||
|
if (!fs.existsSync(CCS_HOOKS_DIR)) {
|
||||||
|
fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK);
|
||||||
|
|
||||||
|
// Find the bundled hook script
|
||||||
|
// In npm package: node_modules/ccs/lib/hooks/
|
||||||
|
// In development: lib/hooks/
|
||||||
|
const possiblePaths = [
|
||||||
|
path.join(__dirname, '..', '..', 'lib', 'hooks', WEBSEARCH_HOOK),
|
||||||
|
path.join(__dirname, '..', 'lib', 'hooks', WEBSEARCH_HOOK),
|
||||||
|
];
|
||||||
|
|
||||||
|
let sourcePath: string | null = null;
|
||||||
|
for (const p of possiblePaths) {
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
sourcePath = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourcePath) {
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(warn(`WebSearch hook source not found: ${WEBSEARCH_HOOK}`));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy hook to ~/.ccs/hooks/
|
||||||
|
fs.copyFileSync(sourcePath, hookPath);
|
||||||
|
fs.chmodSync(hookPath, 0o755);
|
||||||
|
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(info(`Installed WebSearch hook: ${hookPath}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure hook is configured in settings.json
|
||||||
|
ensureHookConfig();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(warn(`Failed to install WebSearch hook: ${(error as Error).message}`));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if WebSearch hook is installed
|
||||||
|
*/
|
||||||
|
export function hasWebSearchHook(): boolean {
|
||||||
|
const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK);
|
||||||
|
return fs.existsSync(hookPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get WebSearch hook configuration for settings.json
|
||||||
|
*/
|
||||||
|
export function getWebSearchHookConfig(): Record<string, unknown> {
|
||||||
|
const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK);
|
||||||
|
|
||||||
|
return {
|
||||||
|
PreToolUse: [
|
||||||
|
{
|
||||||
|
matcher: 'WebSearch',
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
type: 'command',
|
||||||
|
command: `node "${hookPath}"`,
|
||||||
|
timeout: HOOK_TIMEOUT_SECONDS,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure WebSearch hook is configured in ~/.claude/settings.json
|
||||||
|
*/
|
||||||
|
function ensureHookConfig(): boolean {
|
||||||
|
try {
|
||||||
|
const wsConfig = getWebSearchConfig();
|
||||||
|
|
||||||
|
if (!wsConfig.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read existing settings or start fresh
|
||||||
|
let settings: Record<string, unknown> = {};
|
||||||
|
if (fs.existsSync(CLAUDE_SETTINGS_PATH)) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8');
|
||||||
|
settings = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(warn('Malformed settings.json - will merge carefully'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if WebSearch hook already configured
|
||||||
|
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
|
||||||
|
if (hooks?.PreToolUse) {
|
||||||
|
const hasWebSearchHook = hooks.PreToolUse.some((h: unknown) => {
|
||||||
|
const hook = h as Record<string, unknown>;
|
||||||
|
return hook.matcher === 'WebSearch';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasWebSearchHook) {
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(info('WebSearch hook already configured in settings.json'));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get hook config
|
||||||
|
const hookConfig = getWebSearchHookConfig();
|
||||||
|
|
||||||
|
// Merge hook config into settings
|
||||||
|
if (!settings.hooks) {
|
||||||
|
settings.hooks = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsHooks = settings.hooks as Record<string, unknown[]>;
|
||||||
|
if (!settingsHooks.PreToolUse) {
|
||||||
|
settingsHooks.PreToolUse = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add our hook config
|
||||||
|
const preToolUseHooks = hookConfig.PreToolUse as unknown[];
|
||||||
|
settingsHooks.PreToolUse.push(...preToolUseHooks);
|
||||||
|
|
||||||
|
// Ensure ~/.claude directory exists
|
||||||
|
const claudeDir = path.dirname(CLAUDE_SETTINGS_PATH);
|
||||||
|
if (!fs.existsSync(claudeDir)) {
|
||||||
|
fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write updated settings
|
||||||
|
fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
|
||||||
|
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(info('Added WebSearch hook to settings.json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.CCS_DEBUG) {
|
||||||
|
console.error(warn(`Failed to configure WebSearch hook: ${(error as Error).message}`));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Environment Variables for Hook ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get environment variables for WebSearch hook configuration.
|
||||||
|
*
|
||||||
|
* Simple env vars - hook reads these to control behavior.
|
||||||
|
*
|
||||||
|
* @returns Record of environment variables to set before spawning Claude
|
||||||
|
*/
|
||||||
|
export function getWebSearchHookEnv(): Record<string, string> {
|
||||||
|
const wsConfig = getWebSearchConfig();
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Skip hook entirely if disabled
|
||||||
|
if (!wsConfig.enabled) {
|
||||||
|
env.CCS_WEBSEARCH_SKIP = '1';
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass simple config to hook
|
||||||
|
env.CCS_WEBSEARCH_ENABLED = '1';
|
||||||
|
env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers?.gemini?.timeout || 55);
|
||||||
|
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== WebSearch Readiness Status ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSearch availability status for third-party profiles
|
||||||
|
*/
|
||||||
|
export type WebSearchReadiness = 'ready' | 'unavailable';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSearch status for display
|
||||||
|
*/
|
||||||
|
export interface WebSearchStatus {
|
||||||
|
readiness: WebSearchReadiness;
|
||||||
|
geminiCli: boolean;
|
||||||
|
grokCli: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get WebSearch readiness status for display
|
||||||
|
*
|
||||||
|
* Called on third-party profile startup to inform user.
|
||||||
|
*/
|
||||||
|
export function getWebSearchReadiness(): WebSearchStatus {
|
||||||
|
const wsConfig = getWebSearchConfig();
|
||||||
|
|
||||||
|
// Check if WebSearch is disabled entirely
|
||||||
|
if (!wsConfig.enabled) {
|
||||||
|
return {
|
||||||
|
readiness: 'unavailable',
|
||||||
|
geminiCli: false,
|
||||||
|
grokCli: false,
|
||||||
|
message: 'Disabled in config',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check both CLIs
|
||||||
|
const geminiInstalled = hasGeminiCli();
|
||||||
|
const grokInstalled = hasGrokCli();
|
||||||
|
|
||||||
|
if (geminiInstalled && grokInstalled) {
|
||||||
|
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)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
readiness: 'unavailable',
|
||||||
|
geminiCli: false,
|
||||||
|
grokCli: false,
|
||||||
|
message: 'Install: npm i -g @google/gemini-cli',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display WebSearch status (single line, equilibrium UX)
|
||||||
|
*
|
||||||
|
* Only call for third-party profiles.
|
||||||
|
* Shows detailed install hints when no CLI is installed.
|
||||||
|
*/
|
||||||
|
export function displayWebSearchStatus(): void {
|
||||||
|
const status = getWebSearchReadiness();
|
||||||
|
|
||||||
|
switch (status.readiness) {
|
||||||
|
case 'ready':
|
||||||
|
console.error(ok(`WebSearch: ${status.message}`));
|
||||||
|
break;
|
||||||
|
case 'unavailable':
|
||||||
|
console.error(fail(`WebSearch: ${status.message}`));
|
||||||
|
// Show install hints for CLI-only users
|
||||||
|
const hints = getCliInstallHints();
|
||||||
|
if (hints.length > 0) {
|
||||||
|
for (const hint of hints) {
|
||||||
|
console.error(info(hint));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Backward Compatibility Exports ==========
|
||||||
|
// These are kept for imports that haven't been updated yet
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use installWebSearchHook instead - MCP is no longer used
|
||||||
|
*/
|
||||||
|
export function ensureMcpWebSearch(): boolean {
|
||||||
|
// No-op - MCP is no longer used
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated MCP is no longer used
|
||||||
|
*/
|
||||||
|
export function hasMcpWebSearch(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated MCP is no longer used
|
||||||
|
*/
|
||||||
|
export function getMcpConfigPath(): string {
|
||||||
|
return path.join(os.homedir(), '.claude', '.mcp.json');
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
|||||||
import packageJson from '../../package.json';
|
import packageJson from '../../package.json';
|
||||||
import { getEnvironmentDiagnostics } from '../management/environment-diagnostics';
|
import { getEnvironmentDiagnostics } from '../management/environment-diagnostics';
|
||||||
import { checkAuthCodePorts } from '../management/oauth-port-diagnostics';
|
import { checkAuthCodePorts } from '../management/oauth-port-diagnostics';
|
||||||
|
import { getWebSearchCliProviders, hasAnyWebSearchCli } from '../utils/websearch-manager';
|
||||||
|
|
||||||
export interface HealthCheck {
|
export interface HealthCheck {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -136,6 +137,16 @@ export async function runHealthChecks(): Promise<HealthReport> {
|
|||||||
checks: oauthReadinessChecks,
|
checks: oauthReadinessChecks,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Group 8: WebSearch CLI Providers
|
||||||
|
const websearchChecks: HealthCheck[] = [];
|
||||||
|
websearchChecks.push(...checkWebSearchClis());
|
||||||
|
groups.push({
|
||||||
|
id: 'websearch',
|
||||||
|
name: 'WebSearch',
|
||||||
|
icon: 'Search',
|
||||||
|
checks: websearchChecks,
|
||||||
|
});
|
||||||
|
|
||||||
// Flatten all checks for backward compatibility
|
// Flatten all checks for backward compatibility
|
||||||
const allChecks = groups.flatMap((g) => g.checks);
|
const allChecks = groups.flatMap((g) => g.checks);
|
||||||
|
|
||||||
@@ -816,6 +827,49 @@ async function checkOAuthPortsForDashboard(): Promise<HealthCheck[]> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check 18: WebSearch CLI Providers (Gemini CLI, Grok CLI)
|
||||||
|
function checkWebSearchClis(): HealthCheck[] {
|
||||||
|
const providers = getWebSearchCliProviders();
|
||||||
|
const checks: HealthCheck[] = [];
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
if (provider.installed) {
|
||||||
|
const freeTag = provider.freeTier ? ' (FREE)' : '';
|
||||||
|
checks.push({
|
||||||
|
id: `websearch-${provider.id}`,
|
||||||
|
name: provider.name,
|
||||||
|
status: 'ok',
|
||||||
|
message: `v${provider.version || 'unknown'}${freeTag}`,
|
||||||
|
details: provider.description,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const keyNote = provider.requiresApiKey ? ` (needs ${provider.apiKeyEnvVar})` : ' (FREE)';
|
||||||
|
checks.push({
|
||||||
|
id: `websearch-${provider.id}`,
|
||||||
|
name: provider.name,
|
||||||
|
status: 'info',
|
||||||
|
message: `Not installed${keyNote}`,
|
||||||
|
fix: provider.installCommand,
|
||||||
|
details: provider.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add summary check if no providers installed
|
||||||
|
if (!hasAnyWebSearchCli()) {
|
||||||
|
checks.push({
|
||||||
|
id: 'websearch-summary',
|
||||||
|
name: 'WebSearch Status',
|
||||||
|
status: 'warning',
|
||||||
|
message: 'No CLI tools installed',
|
||||||
|
fix: 'npm install -g @google/gemini-cli (FREE)',
|
||||||
|
details: 'Install a WebSearch CLI for real-time web access',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return checks;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fix a health issue by its check ID
|
* Fix a health issue by its check ID
|
||||||
*/
|
*/
|
||||||
|
|||||||
+30
-67
@@ -60,9 +60,9 @@ import { isUnifiedConfig } from '../config/unified-config-types';
|
|||||||
import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys';
|
import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys';
|
||||||
import {
|
import {
|
||||||
getWebSearchReadiness,
|
getWebSearchReadiness,
|
||||||
getMcpWebSearchStatus,
|
|
||||||
getGeminiCliStatus,
|
getGeminiCliStatus,
|
||||||
} from '../utils/mcp-manager';
|
getGrokCliStatus,
|
||||||
|
} from '../utils/websearch-manager';
|
||||||
|
|
||||||
export const apiRoutes = Router();
|
export const apiRoutes = Router();
|
||||||
|
|
||||||
@@ -1440,19 +1440,11 @@ apiRoutes.get('/websearch', (_req: Request, res: Response): void => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/websearch - Update WebSearch configuration
|
* PUT /api/websearch - Update WebSearch configuration
|
||||||
* Body: WebSearchConfig fields (enabled, provider, fallback, gemini, mode, selectedProviders, customMcp)
|
* Body: WebSearchConfig fields (enabled, providers)
|
||||||
|
* Dashboard is the source of truth for provider selection.
|
||||||
*/
|
*/
|
||||||
apiRoutes.put('/websearch', (req: Request, res: Response): void => {
|
apiRoutes.put('/websearch', (req: Request, res: Response): void => {
|
||||||
const {
|
const { enabled, providers } = req.body as Partial<WebSearchConfig>;
|
||||||
enabled,
|
|
||||||
provider,
|
|
||||||
fallback,
|
|
||||||
webSearchPrimeUrl,
|
|
||||||
gemini,
|
|
||||||
mode,
|
|
||||||
selectedProviders,
|
|
||||||
customMcp,
|
|
||||||
} = req.body as Partial<WebSearchConfig>;
|
|
||||||
|
|
||||||
// Validate enabled
|
// Validate enabled
|
||||||
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||||
@@ -1460,45 +1452,9 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate fallback
|
// Validate providers if specified
|
||||||
if (fallback !== undefined && typeof fallback !== 'boolean') {
|
if (providers !== undefined && typeof providers !== 'object') {
|
||||||
res.status(400).json({ error: 'Invalid value for fallback. Must be a boolean.' });
|
res.status(400).json({ error: 'Invalid value for providers. Must be an object.' });
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate provider if specified
|
|
||||||
const validProviders = ['auto', 'web-search-prime', 'brave', 'tavily'];
|
|
||||||
if (provider && !validProviders.includes(provider)) {
|
|
||||||
res.status(400).json({
|
|
||||||
error: `Invalid provider. Must be one of: ${validProviders.join(', ')}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate webSearchPrimeUrl if specified
|
|
||||||
if (webSearchPrimeUrl !== undefined && typeof webSearchPrimeUrl !== 'string') {
|
|
||||||
res.status(400).json({ error: 'Invalid value for webSearchPrimeUrl. Must be a string.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate mode if specified
|
|
||||||
const validModes = ['sequential', 'parallel'];
|
|
||||||
if (mode && !validModes.includes(mode)) {
|
|
||||||
res.status(400).json({
|
|
||||||
error: `Invalid mode. Must be one of: ${validModes.join(', ')}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate selectedProviders if specified
|
|
||||||
if (selectedProviders !== undefined && !Array.isArray(selectedProviders)) {
|
|
||||||
res.status(400).json({ error: 'Invalid value for selectedProviders. Must be an array.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate customMcp if specified
|
|
||||||
if (customMcp !== undefined && !Array.isArray(customMcp)) {
|
|
||||||
res.status(400).json({ error: 'Invalid value for customMcp. Must be an array.' });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1510,16 +1466,23 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge updates - preserve all existing fields
|
// Merge updates - simple structure (Gemini CLI only for now)
|
||||||
existingConfig.websearch = {
|
existingConfig.websearch = {
|
||||||
enabled: enabled ?? existingConfig.websearch?.enabled ?? true,
|
enabled: enabled ?? existingConfig.websearch?.enabled ?? true,
|
||||||
provider: provider ?? existingConfig.websearch?.provider ?? 'auto',
|
providers: providers
|
||||||
fallback: fallback ?? existingConfig.websearch?.fallback ?? true,
|
? {
|
||||||
webSearchPrimeUrl: webSearchPrimeUrl ?? existingConfig.websearch?.webSearchPrimeUrl,
|
gemini: {
|
||||||
gemini: gemini ?? existingConfig.websearch?.gemini ?? { enabled: true, timeout: 55 },
|
enabled:
|
||||||
mode: mode ?? existingConfig.websearch?.mode ?? 'sequential',
|
providers.gemini?.enabled ??
|
||||||
selectedProviders: selectedProviders ?? existingConfig.websearch?.selectedProviders ?? [],
|
existingConfig.websearch?.providers?.gemini?.enabled ??
|
||||||
customMcp: customMcp ?? existingConfig.websearch?.customMcp ?? [],
|
true,
|
||||||
|
timeout:
|
||||||
|
providers.gemini?.timeout ??
|
||||||
|
existingConfig.websearch?.providers?.gemini?.timeout ??
|
||||||
|
55,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: existingConfig.websearch?.providers,
|
||||||
};
|
};
|
||||||
|
|
||||||
saveUnifiedConfig(existingConfig);
|
saveUnifiedConfig(existingConfig);
|
||||||
@@ -1534,13 +1497,13 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/websearch/status - Get comprehensive WebSearch status
|
* GET /api/websearch/status - Get WebSearch status
|
||||||
* Returns: { geminiCli, mcpServers, readiness }
|
* Returns: { geminiCli, grokCli, readiness }
|
||||||
*/
|
*/
|
||||||
apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => {
|
apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => {
|
||||||
try {
|
try {
|
||||||
const geminiCli = getGeminiCliStatus();
|
const geminiCli = getGeminiCliStatus();
|
||||||
const mcpStatus = getMcpWebSearchStatus();
|
const grokCli = getGrokCliStatus();
|
||||||
const readiness = getWebSearchReadiness();
|
const readiness = getWebSearchReadiness();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -1549,10 +1512,10 @@ apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => {
|
|||||||
path: geminiCli.path,
|
path: geminiCli.path,
|
||||||
version: geminiCli.version,
|
version: geminiCli.version,
|
||||||
},
|
},
|
||||||
mcpServers: {
|
grokCli: {
|
||||||
configured: mcpStatus.configured,
|
installed: grokCli.installed,
|
||||||
ccsManaged: mcpStatus.ccsManaged,
|
path: grokCli.path,
|
||||||
userAdded: mcpStatus.userAdded,
|
version: grokCli.version,
|
||||||
},
|
},
|
||||||
readiness: {
|
readiness: {
|
||||||
status: readiness.readiness,
|
status: readiness.readiness,
|
||||||
|
|||||||
+175
-403
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Settings Page - WebSearch Configuration
|
* Settings Page - WebSearch Configuration
|
||||||
* Simplified toggle-based UI: enable providers you want, we handle the rest
|
* Supports Gemini CLI and Grok CLI providers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
@@ -9,7 +9,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
import {
|
||||||
Globe,
|
Globe,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -18,87 +17,42 @@ import {
|
|||||||
FileCode,
|
FileCode,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Zap,
|
|
||||||
Terminal,
|
Terminal,
|
||||||
Server,
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { CodeEditor } from '@/components/code-editor';
|
import { CodeEditor } from '@/components/code-editor';
|
||||||
|
|
||||||
interface CustomMcpConfig {
|
interface ProviderConfig {
|
||||||
name: string;
|
enabled?: boolean;
|
||||||
type: 'http' | 'stdio';
|
timeout?: number;
|
||||||
url?: string;
|
}
|
||||||
headers?: Record<string, string>;
|
|
||||||
command?: string;
|
interface WebSearchProvidersConfig {
|
||||||
args?: string[];
|
gemini?: ProviderConfig;
|
||||||
env?: Record<string, string>;
|
grok?: ProviderConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebSearchConfig {
|
interface WebSearchConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
provider: 'auto' | 'web-search-prime' | 'brave' | 'tavily';
|
providers?: WebSearchProvidersConfig;
|
||||||
fallback: boolean;
|
}
|
||||||
gemini?: {
|
|
||||||
enabled: boolean;
|
interface CliStatus {
|
||||||
timeout: number;
|
installed: boolean;
|
||||||
};
|
path: string | null;
|
||||||
mode?: 'sequential' | 'parallel';
|
version: string | null;
|
||||||
selectedProviders?: string[];
|
|
||||||
customMcp?: CustomMcpConfig[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebSearchStatus {
|
interface WebSearchStatus {
|
||||||
geminiCli: {
|
geminiCli: CliStatus;
|
||||||
installed: boolean;
|
grokCli: CliStatus;
|
||||||
path: string | null;
|
|
||||||
version: string | null;
|
|
||||||
};
|
|
||||||
mcpServers: {
|
|
||||||
configured: boolean;
|
|
||||||
ccsManaged: string[];
|
|
||||||
userAdded: string[];
|
|
||||||
};
|
|
||||||
readiness: {
|
readiness: {
|
||||||
status: 'ready' | 'mcp-only' | 'unavailable';
|
status: 'ready' | 'unavailable';
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Built-in providers with code names
|
|
||||||
const BUILTIN_PROVIDERS = [
|
|
||||||
{
|
|
||||||
id: 'gemini',
|
|
||||||
name: 'gemini',
|
|
||||||
desc: 'Google Gemini CLI (OAuth)',
|
|
||||||
icon: Terminal,
|
|
||||||
isMcp: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'web-search-prime',
|
|
||||||
name: 'web-search-prime',
|
|
||||||
desc: 'z.ai MCP',
|
|
||||||
icon: Zap,
|
|
||||||
isMcp: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'brave-search',
|
|
||||||
name: 'brave-search',
|
|
||||||
desc: 'Brave Search MCP',
|
|
||||||
icon: Globe,
|
|
||||||
isMcp: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tavily',
|
|
||||||
name: 'tavily',
|
|
||||||
desc: 'Tavily MCP',
|
|
||||||
icon: Globe,
|
|
||||||
isMcp: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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);
|
||||||
@@ -111,10 +65,6 @@ export function SettingsPage() {
|
|||||||
const [rawConfig, setRawConfig] = useState<string | null>(null);
|
const [rawConfig, setRawConfig] = useState<string | null>(null);
|
||||||
const [rawConfigLoading, setRawConfigLoading] = useState(false);
|
const [rawConfigLoading, setRawConfigLoading] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
// BYOM MCP form state - JSON paste
|
|
||||||
const [mcpJsonInput, setMcpJsonInput] = useState('');
|
|
||||||
const [mcpJsonError, setMcpJsonError] = useState<string | null>(null);
|
|
||||||
const [showAddMcp, setShowAddMcp] = useState(false);
|
|
||||||
|
|
||||||
// Load config and status on mount
|
// Load config and status on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -181,173 +131,21 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get active providers from config
|
// Toggle Gemini provider
|
||||||
const getActiveProviders = (): string[] => {
|
const toggleGemini = () => {
|
||||||
const active: string[] = [];
|
const providers = config?.providers || {};
|
||||||
if (config?.gemini?.enabled !== false) active.push('gemini');
|
const currentState = providers.gemini?.enabled ?? false;
|
||||||
if (config?.selectedProviders) {
|
const grokState = providers.grok?.enabled ?? false;
|
||||||
active.push(...config.selectedProviders.filter((p) => p !== 'gemini'));
|
|
||||||
}
|
|
||||||
return active;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle a provider
|
|
||||||
const toggleProvider = (providerId: string) => {
|
|
||||||
const current = getActiveProviders();
|
|
||||||
let updated: string[];
|
|
||||||
|
|
||||||
if (current.includes(providerId)) {
|
|
||||||
updated = current.filter((p) => p !== providerId);
|
|
||||||
} else {
|
|
||||||
updated = [...current, providerId];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separate gemini from MCP providers
|
|
||||||
const geminiEnabled = updated.includes('gemini');
|
|
||||||
const mcpProviders = updated.filter((p) => p !== 'gemini');
|
|
||||||
|
|
||||||
// Determine mode: parallel if multiple providers, sequential otherwise
|
|
||||||
const mode = updated.length > 1 ? 'parallel' : 'sequential';
|
|
||||||
|
|
||||||
saveConfig({
|
saveConfig({
|
||||||
gemini: {
|
enabled: !currentState || grokState, // Enable WebSearch if any provider is enabled
|
||||||
enabled: geminiEnabled,
|
providers: {
|
||||||
timeout: config?.gemini?.timeout ?? 55,
|
...providers,
|
||||||
|
gemini: {
|
||||||
|
...providers.gemini,
|
||||||
|
enabled: !currentState,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
selectedProviders: updated,
|
|
||||||
mode,
|
|
||||||
// Auto-enable if any provider is on
|
|
||||||
enabled: updated.length > 0,
|
|
||||||
// Set primary provider
|
|
||||||
provider: (mcpProviders[0] as WebSearchConfig['provider']) || 'auto',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse JSON input and add custom MCP
|
|
||||||
const addCustomMcp = () => {
|
|
||||||
if (!mcpJsonInput.trim()) return;
|
|
||||||
setMcpJsonError(null);
|
|
||||||
|
|
||||||
// Try to parse JSON
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(mcpJsonInput);
|
|
||||||
} catch (_err) {
|
|
||||||
// Check for common JSON issues
|
|
||||||
const input = mcpJsonInput.trim();
|
|
||||||
if (input.includes('//')) {
|
|
||||||
setMcpJsonError('Remove comments (// ...) from JSON before pasting');
|
|
||||||
} else if (!input.startsWith('{')) {
|
|
||||||
setMcpJsonError('JSON must start with { - check for extra characters');
|
|
||||||
} else if (!input.endsWith('}')) {
|
|
||||||
setMcpJsonError('JSON must end with } - check for missing closing brace');
|
|
||||||
} else {
|
|
||||||
setMcpJsonError('Invalid JSON syntax. Check for missing quotes, commas, or braces.');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate it's an object
|
|
||||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
||||||
setMcpJsonError('Expected a JSON object { ... }, not an array or primitive');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const obj = parsed as Record<string, unknown>;
|
|
||||||
const serversToAdd: CustomMcpConfig[] = [];
|
|
||||||
|
|
||||||
// Format 1: { "mcpServers": { "name": { ... } } } - Full Claude/CCS format
|
|
||||||
if (obj.mcpServers && typeof obj.mcpServers === 'object') {
|
|
||||||
for (const [name, serverConfig] of Object.entries(
|
|
||||||
obj.mcpServers as Record<string, unknown>
|
|
||||||
)) {
|
|
||||||
const cfg = serverConfig as Record<string, unknown>;
|
|
||||||
if (cfg && typeof cfg === 'object') {
|
|
||||||
serversToAdd.push({
|
|
||||||
name,
|
|
||||||
type: (cfg.type as 'http' | 'stdio') || 'http',
|
|
||||||
url: cfg.url as string,
|
|
||||||
headers: cfg.headers as Record<string, string>,
|
|
||||||
command: cfg.command as string,
|
|
||||||
args: cfg.args as string[],
|
|
||||||
env: cfg.env as Record<string, string>,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Format 2: { "type": "http", "url": "..." } - Single server without name
|
|
||||||
else if (obj.type && (obj.url || obj.command)) {
|
|
||||||
setMcpJsonError(
|
|
||||||
'Missing server name. Use format:\n{ "your-server-name": { "type": "http", "url": "..." } }'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Format 3: { "name": { ... } } - Direct server object (partial paste)
|
|
||||||
else {
|
|
||||||
for (const [name, value] of Object.entries(obj)) {
|
|
||||||
const cfg = value as Record<string, unknown>;
|
|
||||||
if (cfg && typeof cfg === 'object' && !Array.isArray(cfg)) {
|
|
||||||
// Check if this looks like a server config
|
|
||||||
if (cfg.type || cfg.url || cfg.command) {
|
|
||||||
serversToAdd.push({
|
|
||||||
name,
|
|
||||||
type: (cfg.type as 'http' | 'stdio') || 'http',
|
|
||||||
url: cfg.url as string,
|
|
||||||
headers: cfg.headers as Record<string, string>,
|
|
||||||
command: cfg.command as string,
|
|
||||||
args: cfg.args as string[],
|
|
||||||
env: cfg.env as Record<string, string>,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate we found servers
|
|
||||||
if (serversToAdd.length === 0) {
|
|
||||||
setMcpJsonError(
|
|
||||||
'No valid MCP server found. Expected format:\n{ "server-name": { "type": "http", "url": "..." } }'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate each server
|
|
||||||
for (const server of serversToAdd) {
|
|
||||||
if (!server.name) {
|
|
||||||
setMcpJsonError('Server name is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (server.type === 'http' && !server.url) {
|
|
||||||
setMcpJsonError(`"${server.name}" is missing "url" field`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (server.type === 'stdio' && !server.command) {
|
|
||||||
setMcpJsonError(`"${server.name}" is missing "command" field`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for duplicates
|
|
||||||
const existing = config?.customMcp || [];
|
|
||||||
const duplicates = serversToAdd.filter((s) => existing.some((e) => e.name === s.name));
|
|
||||||
if (duplicates.length > 0) {
|
|
||||||
setMcpJsonError(`Server "${duplicates[0].name}" already exists`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success - add the servers
|
|
||||||
saveConfig({ customMcp: [...existing, ...serversToAdd] });
|
|
||||||
setMcpJsonInput('');
|
|
||||||
setShowAddMcp(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeCustomMcp = (name: string) => {
|
|
||||||
const current = config?.customMcp || [];
|
|
||||||
// Also remove from selectedProviders
|
|
||||||
const selected = config?.selectedProviders || [];
|
|
||||||
saveConfig({
|
|
||||||
customMcp: current.filter((m) => m.name !== name),
|
|
||||||
selectedProviders: selected.filter((p) => p !== name),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -392,8 +190,25 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeProviders = config ? getActiveProviders() : [];
|
const isGeminiEnabled = config?.providers?.gemini?.enabled ?? false;
|
||||||
const activeCount = activeProviders.length;
|
const isGrokEnabled = config?.providers?.grok?.enabled ?? false;
|
||||||
|
|
||||||
|
// Toggle Grok provider
|
||||||
|
const toggleGrok = () => {
|
||||||
|
const providers = config?.providers || {};
|
||||||
|
const currentState = providers.grok?.enabled ?? false;
|
||||||
|
|
||||||
|
saveConfig({
|
||||||
|
enabled: isGeminiEnabled || !currentState, // Enable WebSearch if any provider is enabled
|
||||||
|
providers: {
|
||||||
|
...providers,
|
||||||
|
grok: {
|
||||||
|
...providers.grok,
|
||||||
|
enabled: !currentState,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -419,7 +234,7 @@ export function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold">WebSearch</h1>
|
<h1 className="text-lg font-semibold">WebSearch</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Toggle providers • Multiple = parallel
|
CLI-based web search for third-party profiles
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -454,9 +269,7 @@ export function SettingsPage() {
|
|||||||
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
|
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{activeCount === 0 && 'No providers enabled'}
|
{isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'}
|
||||||
{activeCount === 1 && '1 provider active'}
|
|
||||||
{activeCount > 1 && `${activeCount} providers (parallel)`}
|
|
||||||
</p>
|
</p>
|
||||||
{statusLoading ? (
|
{statusLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">Checking status...</p>
|
<p className="text-sm text-muted-foreground">Checking status...</p>
|
||||||
@@ -469,180 +282,139 @@ export function SettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Built-in Providers */}
|
{/* CLI Providers */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-base font-medium">Providers</h3>
|
<h3 className="text-base font-medium">Providers</h3>
|
||||||
<div className="space-y-2">
|
|
||||||
{BUILTIN_PROVIDERS.map((provider) => {
|
|
||||||
const isActive = activeProviders.includes(provider.id);
|
|
||||||
const Icon = provider.icon;
|
|
||||||
const isGemini = provider.id === 'gemini';
|
|
||||||
const isInstalled = isGemini
|
|
||||||
? status?.geminiCli.installed
|
|
||||||
: provider.isMcp
|
|
||||||
? status?.mcpServers.ccsManaged.includes(provider.id) ||
|
|
||||||
status?.mcpServers.userAdded.includes(provider.id)
|
|
||||||
: true;
|
|
||||||
|
|
||||||
return (
|
{/* Gemini CLI Provider */}
|
||||||
<div
|
<div
|
||||||
key={provider.id}
|
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
|
||||||
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
|
isGeminiEnabled
|
||||||
isActive
|
? 'border-primary/50 bg-primary/5'
|
||||||
? 'border-primary/50 bg-primary/5'
|
: 'border-border bg-background'
|
||||||
: 'border-border bg-background'
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<Terminal
|
||||||
<Icon
|
className={`w-5 h-5 ${isGeminiEnabled ? 'text-primary' : 'text-muted-foreground'}`}
|
||||||
className={`w-5 h-5 ${isActive ? 'text-primary' : 'text-muted-foreground'}`}
|
/>
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="font-mono font-medium">{provider.name}</p>
|
|
||||||
{isInstalled ? (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
|
|
||||||
configured
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
|
|
||||||
not configured
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">{provider.desc}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={isActive}
|
|
||||||
onCheckedChange={() => toggleProvider(provider.id)}
|
|
||||||
disabled={saving || !isInstalled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Custom MCPs */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-base font-medium">Custom MCPs</h3>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowAddMcp(!showAddMcp)}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAddMcp && (
|
|
||||||
<div className="space-y-3 p-4 rounded-lg border bg-background">
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="mcpJson" className="text-sm font-medium">
|
<div className="flex items-center gap-2">
|
||||||
Paste MCP Server JSON
|
<p className="font-mono font-medium">gemini</p>
|
||||||
</Label>
|
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
|
||||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
FREE
|
||||||
Paste the JSON config from your MCP provider docs
|
</span>
|
||||||
|
{status?.geminiCli?.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">
|
||||||
|
Google Gemini CLI (1000 req/day free)
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
|
||||||
id="mcpJson"
|
|
||||||
placeholder={`// Full format (from Claude settings):
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"my-mcp": {
|
|
||||||
"type": "http",
|
|
||||||
"url": "https://api.example.com/mcp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Or just the server part:
|
|
||||||
{
|
|
||||||
"my-mcp": {
|
|
||||||
"type": "http",
|
|
||||||
"url": "https://..."
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
value={mcpJsonInput}
|
|
||||||
onChange={(e) => {
|
|
||||||
setMcpJsonInput(e.target.value);
|
|
||||||
setMcpJsonError(null);
|
|
||||||
}}
|
|
||||||
className="w-full min-h-[160px] max-h-[400px] p-3 font-mono text-sm border rounded-md bg-muted/50 resize-y focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
{mcpJsonError && (
|
|
||||||
<p className="text-sm text-destructive mt-2">{mcpJsonError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
</div>
|
||||||
<Button
|
<Switch
|
||||||
size="sm"
|
checked={isGeminiEnabled}
|
||||||
onClick={addCustomMcp}
|
onCheckedChange={toggleGemini}
|
||||||
disabled={!mcpJsonInput.trim() || saving}
|
disabled={saving || !status?.geminiCli?.installed}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gemini Installation hint when not installed */}
|
||||||
|
{!status?.geminiCli?.installed && !statusLoading && (
|
||||||
|
<div className="p-4 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-900/20">
|
||||||
|
<p className="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">
|
||||||
|
Gemini CLI not installed
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300 mb-3">
|
||||||
|
Install globally (FREE tier available):
|
||||||
|
</p>
|
||||||
|
<code className="text-sm bg-amber-100 dark:bg-amber-900/40 px-2 py-1 rounded font-mono">
|
||||||
|
npm install -g @google/gemini-cli
|
||||||
|
</code>
|
||||||
|
<div className="mt-3">
|
||||||
|
<a
|
||||||
|
href="https://github.com/google-gemini/gemini-cli"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-amber-700 dark:text-amber-300 hover:underline inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
Add
|
<ExternalLink className="w-3 h-3" />
|
||||||
</Button>
|
View documentation
|
||||||
<Button
|
</a>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setShowAddMcp(false);
|
|
||||||
setMcpJsonInput('');
|
|
||||||
setMcpJsonError(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Custom MCP list */}
|
{/* Grok CLI Provider */}
|
||||||
{config?.customMcp && config.customMcp.length > 0 && (
|
<div
|
||||||
<div className="space-y-2">
|
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
|
||||||
{config.customMcp.map((mcp) => {
|
isGrokEnabled
|
||||||
const isActive = activeProviders.includes(mcp.name);
|
? 'border-primary/50 bg-primary/5'
|
||||||
return (
|
: 'border-border bg-background'
|
||||||
<div
|
}`}
|
||||||
key={mcp.name}
|
>
|
||||||
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
|
<div className="flex items-center gap-3">
|
||||||
isActive
|
<Terminal
|
||||||
? 'border-primary/50 bg-primary/5'
|
className={`w-5 h-5 ${isGrokEnabled ? 'text-primary' : 'text-muted-foreground'}`}
|
||||||
: 'border-border bg-background'
|
/>
|
||||||
}`}
|
<div>
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
<p className="font-mono font-medium">grok</p>
|
||||||
<Server
|
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 font-medium">
|
||||||
className={`w-5 h-5 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground'}`}
|
XAI_API_KEY
|
||||||
/>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
{status?.grokCli?.installed ? (
|
||||||
<p className="font-mono font-medium truncate">{mcp.name}</p>
|
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
|
||||||
<p className="text-sm text-muted-foreground truncate">{mcp.url}</p>
|
installed
|
||||||
</div>
|
</span>
|
||||||
</div>
|
) : (
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
|
||||||
<Switch
|
not installed
|
||||||
checked={isActive}
|
</span>
|
||||||
onCheckedChange={() => toggleProvider(mcp.name)}
|
)}
|
||||||
disabled={saving}
|
</div>
|
||||||
/>
|
<p className="text-sm text-muted-foreground">
|
||||||
<Button
|
xAI Grok CLI (web + X search)
|
||||||
variant="ghost"
|
</p>
|
||||||
size="sm"
|
</div>
|
||||||
onClick={() => removeCustomMcp(mcp.name)}
|
</div>
|
||||||
disabled={saving}
|
<Switch
|
||||||
className="h-8 w-8 p-0"
|
checked={isGrokEnabled}
|
||||||
>
|
onCheckedChange={toggleGrok}
|
||||||
<Trash2 className="w-4 h-4 text-destructive" />
|
disabled={saving || !status?.grokCli?.installed}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
{/* Grok Installation hint when not installed */}
|
||||||
})}
|
{!status?.grokCli?.installed && !statusLoading && (
|
||||||
|
<div className="p-4 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-900/20">
|
||||||
|
<p className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
|
||||||
|
Grok CLI not installed
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300 mb-3">
|
||||||
|
Install globally (requires xAI API key):
|
||||||
|
</p>
|
||||||
|
<code className="text-sm bg-blue-100 dark:bg-blue-900/40 px-2 py-1 rounded font-mono">
|
||||||
|
npm install -g grok-cli
|
||||||
|
</code>
|
||||||
|
<div className="mt-3">
|
||||||
|
<a
|
||||||
|
href="https://github.com/lalomorales22/grok-4-cli"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-blue-700 dark:text-blue-300 hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
View documentation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user