Merge pull request #116 from kaitranntt/kai/feat/websearch-mcp-fallback

feat(websearch): CLI provider fallback chain with OpenCode and Grok support
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-17 00:17:29 -05:00
committed by GitHub
20 changed files with 3336 additions and 66 deletions
+39
View File
@@ -195,6 +195,45 @@ Without Developer Mode, CCS falls back to copying directories.
<br>
## WebSearch
Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS automatically configures MCP-based web search as a fallback.
### How It Works
| Profile Type | WebSearch Method |
|--------------|------------------|
| Claude (native) | Anthropic WebSearch API |
| OAuth providers | MCP web-search-prime (auto-configured) |
| API profiles | MCP web-search-prime (auto-configured) |
### Configuration
Configure via dashboard (**Settings** page) or `~/.ccs/config.yaml`:
```yaml
websearch:
enabled: true # Enable/disable auto-config
provider: auto # auto | web-search-prime | brave | tavily
fallback: true # Enable fallback chain
```
### Optional API Keys
For additional search providers, set environment variables:
```bash
export BRAVE_API_KEY="your-key" # Free tier: 15k queries/month
export TAVILY_API_KEY="your-key" # AI-optimized search (paid)
```
> [!NOTE]
> `web-search-prime` works without API keys. Brave/Tavily are optional fallbacks.
See [docs/websearch.md](./docs/websearch.md) for detailed configuration and troubleshooting.
<br>
## Documentation
| Topic | Link |
+209
View File
@@ -0,0 +1,209 @@
# WebSearch Configuration Guide
CCS provides automatic web search capability for all profiles, including third-party providers that cannot access Anthropic's native WebSearch API.
## How WebSearch Works
### Native Claude Accounts
When using a native Claude subscription account, WebSearch is handled by Anthropic's server-side API ($10/1000 searches, usage-based billing).
### Third-Party Profiles
Third-party profiles (OAuth and API-based) cannot use Anthropic's WebSearch because:
- Claude Code CLI executes tools locally
- CLIProxyAPI only receives conversation messages
- Tool execution never reaches the third-party backend
CCS solves this with a hybrid fallback approach:
1. **Gemini CLI Transformer** (Primary) - Uses `gemini -p` with `google_web_search` tool
2. **MCP Fallback Chain** (Secondary) - MCP-based web search servers
## Architecture
```
┌──────────────────────────────────────────────────────────────┐
│ Claude Code CLI │
│ │
│ WebSearch Tool Request │
│ │ │
│ ├── Native Claude Account? → Anthropic WebSearch API │
│ │ ($10/1000 searches) │
│ │ │
│ └── Third-party Profile? → PreToolUse Hook │
│ │ │
│ ├── 1. Gemini CLI │
│ │ (google_web_search) │
│ │ No API key needed! │
│ │ │
│ └── 2. MCP Fallback Chain │
│ ├── web-search-prime │
│ ├── Brave Search │
│ └── Tavily │
└──────────────────────────────────────────────────────────────┘
```
## Gemini CLI Integration (Primary)
The **ultimate solution** for third-party WebSearch. Uses `gemini` CLI with OAuth authentication - **no API key needed!**
### How It Works
1. A PreToolUse hook intercepts WebSearch tool calls
2. Executes `gemini -p` with explicit google_web_search instruction
3. Returns search results directly to Claude via the hook's deny reason
4. Claude receives full search results and continues the conversation
### Requirements
- `gemini` CLI installed and authenticated (`gemini auth login`)
- OAuth authentication (no GEMINI_API_KEY needed)
### Installation
The Gemini CLI is typically installed via:
```bash
pip install google-generativeai
# or
pipx install google-generativeai
```
Then authenticate:
```bash
gemini auth login
```
## MCP Providers
| Provider | Type | Cost | API Key Required | Notes |
|----------|------|------|------------------|-------|
| web-search-prime | HTTP MCP | z.ai subscription | No | Requires z.ai coding plan |
| Brave Search | stdio MCP | Free tier | `BRAVE_API_KEY` | 15k queries/month |
| Tavily | stdio MCP | Paid | `TAVILY_API_KEY` | AI-optimized search |
## Configuration
### Via Dashboard
1. Open dashboard: `ccs config`
2. Navigate to **Settings** page
3. Configure WebSearch options:
- **Enable/Disable**: Toggle auto-configuration
- **Provider**: Choose preferred provider
- **Fallback**: Enable/disable fallback chain
### Via Config File
Edit `~/.ccs/config.yaml`:
```yaml
websearch:
enabled: true # Enable auto-config (default: true)
provider: auto # auto | web-search-prime | brave | tavily
fallback: true # Enable fallback chain (default: true)
webSearchPrimeUrl: "https://..." # Optional: custom endpoint
# Gemini CLI configuration (new!)
gemini:
enabled: true # Use Gemini CLI for WebSearch (default: true)
timeout: 55 # Timeout in seconds (default: 55)
```
### Environment Variables
The WebSearch hook also respects these environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `CCS_WEBSEARCH_SKIP` | Skip WebSearch hook entirely | `0` |
| `CCS_GEMINI_SKIP` | Skip Gemini CLI, use MCP only | `0` |
| `CCS_GEMINI_TIMEOUT` | Gemini CLI timeout (seconds) | `55` |
| `CCS_DEBUG` | Enable debug output | `0` |
### Provider Options
- **auto** (default): Uses web-search-prime, adds Brave/Tavily if API keys available
- **web-search-prime**: Requires z.ai coding plan subscription
- **brave**: Requires `BRAVE_API_KEY` env var
- **tavily**: Requires `TAVILY_API_KEY` env var
## Setting Up Optional Providers
### Brave Search (Free Tier)
1. Get API key: [brave.com/search/api](https://brave.com/search/api)
2. Set environment variable:
```bash
export BRAVE_API_KEY="your-api-key"
```
3. Restart CCS - Brave will be added to fallback chain
**Free tier limits**: 15,000 queries/month, 1 query/second
### Tavily (AI-Optimized)
1. Get API key: [tavily.com](https://tavily.com)
2. Set environment variable:
```bash
export TAVILY_API_KEY="your-api-key"
```
3. Restart CCS - Tavily will be added to fallback chain
## MCP Configuration
CCS writes MCP configuration to `~/.claude/.mcp.json`. Example:
```json
{
"mcpServers": {
"web-search-prime": {
"type": "http",
"url": "https://api.z.ai/api/mcp/web_search_prime/mcp",
"headers": {}
},
"brave-search": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
"env": { "BRAVE_API_KEY": "..." }
}
}
}
```
## Troubleshooting
### Gemini CLI Issues
1. **Not installed**: Install with `pip install google-generativeai`
2. **Not authenticated**: Run `gemini auth login`
3. **Timeout**: Increase timeout in config or via `CCS_GEMINI_TIMEOUT=90`
4. **Skip Gemini**: Set `CCS_GEMINI_SKIP=1` to use MCP fallback only
### WebSearch Not Working
1. **Check config**: Ensure `websearch.enabled: true` in config
2. **Verify MCP**: Check `~/.claude/.mcp.json` exists
3. **Debug mode**: Run with `CCS_DEBUG=1 ccs gemini` for verbose output
### MCP Server Errors
1. **Network issues**: web-search-prime requires internet access
2. **npx failures**: Brave/Tavily require Node.js and npx
3. **API key issues**: Verify env vars are set correctly
### Existing MCP Config
CCS respects existing web search MCP configuration. If you have manually configured web search MCPs, CCS will not overwrite them.
To reset:
1. Remove web search entries from `~/.claude/.mcp.json`
2. Run any CCS third-party profile to regenerate
## Security Considerations
- API keys are stored in environment variables only
- Never commit API keys to version control
- Use `.env` files with proper permissions (chmod 600)
- Dashboard settings are stored in `~/.ccs/config.yaml` (no API keys)
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* CCS WebSearch Blocking Hook
*
* Blocks Claude's native WebSearch tool and redirects to MCP alternative.
* This is a PreToolUse hook that runs BEFORE the tool is executed.
*
* WebSearch is a server-side tool executed by Anthropic's API.
* Third-party providers (gemini, agy, codex, qwen) don't have access.
*
* Usage:
* Configured in ~/.claude/settings.json:
* {
* "hooks": {
* "PreToolUse": [{
* "matcher": "WebSearch",
* "hooks": [{
* "type": "command",
* "command": "node ~/.ccs/hooks/block-websearch.cjs",
* "timeout": 5
* }]
* }]
* }
* }
*
* Exit codes:
* 0 - Allow tool (pass-through)
* 2 - Block tool (deny with message)
*
* @module hooks/block-websearch
*/
// Read input from stdin
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
input += chunk;
});
process.stdin.on('end', () => {
try {
const data = JSON.parse(input);
// Only block WebSearch tool
if (data.tool_name === 'WebSearch') {
const query = data.tool_input?.query || '';
const output = {
decision: 'block',
reason: 'WebSearch unavailable with current provider',
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: `WebSearch is not available with third-party providers. Use mcp__web-search-prime__webSearchPrime tool instead with the same query: "${query}"`,
},
};
console.log(JSON.stringify(output));
process.exit(2); // Exit code 2 = block
}
// Allow all other tools
process.exit(0);
} 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);
}
});
// Handle stdin not being available
process.stdin.on('error', () => {
process.exit(0);
});
+600
View File
@@ -0,0 +1,600 @@
#!/usr/bin/env node
/**
* CCS WebSearch Hook - CLI Tool Executor with Fallback Chain
*
* Intercepts Claude's WebSearch tool and executes search via CLI tools.
* Respects provider enabled states from config.yaml.
* Supports automatic fallback: Gemini CLI → OpenCode → Grok CLI
*
* 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_WEBSEARCH_GEMINI=1 - Enable Gemini CLI provider
* CCS_WEBSEARCH_GEMINI_MODEL - Gemini model (default: gemini-2.5-flash)
* CCS_WEBSEARCH_OPENCODE=1 - Enable OpenCode provider
* CCS_WEBSEARCH_GROK=1 - Enable Grok CLI provider
* CCS_WEBSEARCH_OPENCODE_MODEL - OpenCode model (default: opencode/grok-code)
* 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-transformer
*/
const { spawnSync } = require('child_process');
// ============================================================================
// CONFIGURATION - Edit these for prompt engineering
// ============================================================================
/**
* SHARED INSTRUCTIONS - Applied to ALL providers
* Edit here to change behavior across all CLI tools at once.
*/
const SHARED_INSTRUCTIONS = `Instructions:
1. Search the web for current, up-to-date information
2. Provide a comprehensive summary of the search results
3. Include relevant URLs/sources when available
4. Be concise but thorough - prioritize key facts
5. Focus on factual information from reliable sources
6. If results conflict, note the discrepancy
7. Format output clearly with sections if the topic is complex`;
/**
* PROVIDER-SPECIFIC CONFIG - Only tool-use differences and quirks
* Each provider may have unique capabilities or invocation methods.
*/
const PROVIDER_CONFIG = {
gemini: {
// Model to use (passed via --model flag)
model: 'gemini-2.5-flash',
// Alternative free models: gemini-2.0-flash, gemini-1.5-flash
// Provider-specific: How to invoke web search (Gemini has google_web_search tool)
toolInstruction: 'Use the google_web_search tool to find current information.',
// Optional quirks (null if none)
quirks: null,
},
opencode: {
// Model to use (can be overridden via CCS_WEBSEARCH_OPENCODE_MODEL env var)
model: 'opencode/grok-code',
// Alternative models: opencode/gpt-4o, opencode/claude-3.5-sonnet, opencode/gpt-5-nano
// Provider-specific: OpenCode has built-in web search via Zen
toolInstruction: 'Search the web using your built-in capabilities.',
// Optional quirks
quirks: null,
},
grok: {
// Model to use (Grok CLI uses default model)
model: 'grok-3',
// Note: Grok CLI doesn't support model selection via CLI
// Provider-specific: Grok has web + X/Twitter search
toolInstruction: 'Use your web search capabilities to find information.',
// Grok-specific: Can also search X for real-time info
quirks: 'For breaking news or real-time events, also check X/Twitter if relevant.',
},
};
/**
* Build the complete prompt for a provider
* Combines: query + tool instruction + shared instructions + quirks
*/
function buildPrompt(providerId, query) {
const config = PROVIDER_CONFIG[providerId];
const parts = [
`Search the web for: ${query}`,
'',
config.toolInstruction,
'',
SHARED_INSTRUCTIONS,
];
if (config.quirks) {
parts.push('', `Note: ${config.quirks}`);
}
return parts.join('\n');
}
// Minimum response length to consider valid
const MIN_VALID_RESPONSE_LENGTH = 20;
// Default timeout in seconds
const DEFAULT_TIMEOUT_SEC = 55;
// ============================================================================
// HOOK LOGIC - Generally no need to edit below
// ============================================================================
// 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;
}
}
/**
* Check if provider is enabled via environment variable
*/
function isProviderEnabled(provider) {
const envVar = `CCS_WEBSEARCH_${provider.toUpperCase()}`;
const value = process.env[envVar];
// If env var not set, provider is disabled by default
// This ensures we respect config.yaml settings
return value === '1';
}
/**
* Main hook processing logic with fallback chain
*/
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);
// Fallback chain: Gemini → OpenCode → Grok
// Only include providers that are BOTH installed AND enabled in config
const providers = [
{ name: 'Gemini CLI', cmd: 'gemini', id: 'gemini', fn: tryGeminiSearch },
{ name: 'OpenCode', cmd: 'opencode', id: 'opencode', fn: tryOpenCodeSearch },
{ name: 'Grok CLI', cmd: 'grok', id: 'grok', fn: tryGrokSearch },
];
// Filter to only enabled AND available providers
const enabledProviders = providers.filter((p) => {
const enabled = isProviderEnabled(p.id);
const available = isCliAvailable(p.cmd);
if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] ${p.name}: enabled=${enabled}, available=${available}`);
}
return enabled && available;
});
const errors = [];
if (process.env.CCS_DEBUG) {
const names = enabledProviders.map((p) => p.name).join(', ') || 'none';
console.error(`[CCS Hook] Enabled providers: ${names}`);
}
// Try each enabled provider in order
for (const provider of enabledProviders) {
if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] Trying ${provider.name}...`);
}
const result = provider.fn(query, timeout);
if (result.success) {
outputSuccess(query, result.content, provider.name);
return;
}
if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] ${provider.name} failed: ${result.error}`);
}
errors.push({ provider: provider.name, error: result.error });
}
// All providers failed or none enabled
if (enabledProviders.length === 0) {
outputNoProvidersEnabled(query);
} else {
outputAllFailedMessage(query, errors);
}
} 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 config = PROVIDER_CONFIG.gemini;
const prompt = buildPrompt('gemini', query);
// Allow model override via env var
const model = process.env.CCS_WEBSEARCH_GEMINI_MODEL || config.model;
if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] Executing: gemini --model ${model} --yolo -p "..."`);
}
const spawnResult = spawnSync(
'gemini',
['--model', model, '--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' };
}
}
/**
* Execute search via OpenCode CLI
*/
function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
try {
const timeoutMs = timeoutSec * 1000;
const config = PROVIDER_CONFIG.opencode;
// Allow model override via env var
const model = process.env.CCS_WEBSEARCH_OPENCODE_MODEL || config.model;
const prompt = buildPrompt('opencode', query);
if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] Executing: opencode run --model ${model} "..."`);
}
const spawnResult = spawnSync(
'opencode',
['run', prompt, '--model', model],
{
encoding: 'utf8',
timeout: timeoutMs,
maxBuffer: 1024 * 1024 * 2,
stdio: ['pipe', 'pipe', 'pipe'],
}
);
if (spawnResult.error) {
if (spawnResult.error.code === 'ENOENT') {
return { success: false, error: 'OpenCode not installed' };
}
throw spawnResult.error;
}
if (spawnResult.status !== 0) {
const stderr = (spawnResult.stderr || '').trim();
return {
success: false,
error: stderr || `OpenCode 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 OpenCode' };
}
const lowerResult = result.toLowerCase();
if (
lowerResult.includes('error:') ||
lowerResult.includes('failed to') ||
lowerResult.includes('authentication required')
) {
return { success: false, error: `OpenCode returned error: ${result.substring(0, 100)}` };
}
return { success: true, content: result };
} catch (err) {
if (err.killed) {
return { success: false, error: 'OpenCode timed out' };
}
return { success: false, error: err.message || 'Unknown OpenCode error' };
}
}
/**
* Execute search via Grok CLI
*/
function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
try {
const timeoutMs = timeoutSec * 1000;
const prompt = buildPrompt('grok', query);
if (process.env.CCS_DEBUG) {
console.error('[CCS Hook] Executing: grok "..."');
}
const spawnResult = spawnSync('grok', [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: 'Grok CLI not installed' };
}
throw spawnResult.error;
}
if (spawnResult.status !== 0) {
const stderr = (spawnResult.stderr || '').trim();
return {
success: false,
error: stderr || `Grok 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 Grok' };
}
const lowerResult = result.toLowerCase();
if (
lowerResult.includes('error:') ||
lowerResult.includes('failed to') ||
lowerResult.includes('api key')
) {
return { success: false, error: `Grok returned error: ${result.substring(0, 100)}` };
}
return { success: true, content: result };
} catch (err) {
if (err.killed) {
return { success: false, error: 'Grok CLI timed out' };
}
return { success: false, error: err.message || 'Unknown Grok error' };
}
}
/**
* Format search results for Claude
*/
function formatSearchResults(query, content, providerName) {
return [
`[WebSearch Result via ${providerName}]`,
'',
`Query: "${query}"`,
'',
content,
'',
'---',
'Use this information to answer the user.',
].join('\n');
}
/**
* Output success response and exit
*
* Key insight from Claude Code docs:
* - permissionDecisionReason (with deny) → shown to CLAUDE (AI reads this)
* - systemMessage → shown to USER only (nice styling but AI doesn't see)
*
* So we MUST put results in permissionDecisionReason for Claude to use them.
* systemMessage provides the nice UI for the user.
*/
function outputSuccess(query, content, providerName) {
const formattedResults = formatSearchResults(query, content, providerName);
const output = {
decision: 'block',
reason: `WebSearch completed via ${providerName}`,
// Nice message for user (shows as "says:" - info style)
systemMessage: `[WebSearch via ${providerName}] Results retrieved successfully. See below.`,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
// Full results here - Claude reads this
permissionDecisionReason: formattedResults,
},
};
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 providers enabled message
*/
function outputNoProvidersEnabled(query) {
const message = [
'[WebSearch - No Providers Enabled]',
'',
'No WebSearch providers are enabled in config.',
'',
'To enable: Run `ccs config` and enable a provider.',
'',
'Or install one of the following CLI tools:',
'',
'1. Gemini CLI (FREE, 1000 req/day):',
' npm install -g @google/gemini-cli',
' gemini auth login',
'',
'2. OpenCode (FREE via Zen):',
' curl -fsSL https://opencode.ai/install | bash',
'',
'3. Grok CLI (requires XAI_API_KEY):',
' npm install -g @vibe-kit/grok-cli',
'',
`Query: "${query}"`,
].join('\n');
const output = {
decision: 'block',
reason: 'WebSearch unavailable - no providers enabled',
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: message,
},
};
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Output no tools message (legacy - kept for backwards compatibility)
*/
function outputNoToolsMessage(query) {
outputNoProvidersEnabled(query);
}
/**
* Output all providers failed message
*/
function outputAllFailedMessage(query, errors) {
const errorDetails = errors
.map((e) => ` - ${e.provider}: ${e.error}`)
.join('\n');
const message = [
'[WebSearch - All Providers Failed]',
'',
'Tried all enabled CLI tools but all failed:',
errorDetails,
'',
`Query: "${query}"`,
'',
'Troubleshooting:',
' - Gemini: gemini auth status / gemini auth login',
' - OpenCode: opencode --version',
' - Grok: Check XAI_API_KEY environment variable',
].join('\n');
const output = {
decision: 'block',
reason: 'WebSearch failed - all providers failed',
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: message,
},
};
console.log(JSON.stringify(output));
process.exit(2);
}
+57 -5
View File
@@ -4,13 +4,20 @@
#
# Options:
# --skip-validate Skip validation (faster, use when you're sure code is good)
# --npm Force npm install (default: auto-detect, fallback to bun)
# --bun Force bun install
set -e
SKIP_VALIDATE=false
FORCE_NPM=false
FORCE_BUN=false
for arg in "$@"; do
case $arg in
--skip-validate) SKIP_VALIDATE=true ;;
--npm) FORCE_NPM=true ;;
--bun) FORCE_BUN=true ;;
esac
done
@@ -19,6 +26,42 @@ echo "[i] CCS Dev Install - Starting..."
# Get to the right directory
cd "$(dirname "$0")/.."
# Detect installation method
# Priority: CLI flags > existing global install location > bun (default)
detect_pkg_manager() {
if [ "$FORCE_NPM" = true ]; then
echo "npm"
return
fi
if [ "$FORCE_BUN" = true ]; then
echo "bun"
return
fi
# Check existing ccs installation location
CCS_PATH=$(which ccs 2>/dev/null || true)
if [ -n "$CCS_PATH" ]; then
# Check if installed via bun
if [[ "$CCS_PATH" == *".bun"* ]]; then
echo "bun"
return
fi
# Check if installed via npm
if [[ "$CCS_PATH" == *"npm"* ]] || [[ "$CCS_PATH" == *"node_modules"* ]]; then
echo "npm"
return
fi
fi
# Default fallback: bun (preferred)
echo "bun"
}
PKG_MANAGER=$(detect_pkg_manager)
echo "[i] Detected package manager: $PKG_MANAGER"
# Build TypeScript first
echo "[i] Building TypeScript..."
bun run build
@@ -27,10 +70,10 @@ bun run build
echo "[i] Creating package..."
if [ "$SKIP_VALIDATE" = true ]; then
# Skip validation, just pack
bun pm pack --ignore-scripts
npm pack --ignore-scripts 2>/dev/null || bun pm pack --ignore-scripts
else
# Full pack with validation (runs prepublishOnly)
bun pm pack
npm pack 2>/dev/null || bun pm pack
fi
# Find the tarball
@@ -43,9 +86,18 @@ fi
echo "[i] Found tarball: $TARBALL"
# Install globally using npm (handles bin linking correctly)
echo "[i] Installing globally with npm..."
npm install -g "$TARBALL"
# Install globally using detected package manager
echo "[i] Installing globally with $PKG_MANAGER..."
if [ "$PKG_MANAGER" = "bun" ]; then
# Remove existing to avoid duplicate key warnings in bun's global package.json
# (bun add -g appends instead of replacing file: protocol entries)
bun remove -g @kaitranntt/ccs 2>/dev/null || true
# Bun requires file: protocol for local tarballs
bun add -g "file:$(pwd)/$TARBALL"
else
npm install -g "$TARBALL"
fi
# Clean up
echo "[i] Cleaning up..."
+16 -1
View File
@@ -14,6 +14,12 @@ import { detectClaudeCli } from './utils/claude-detector';
import { getSettingsPath } from './utils/config-manager';
import { ErrorManager } from './utils/error-manager';
import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy';
import {
ensureMcpWebSearch,
installWebSearchHook,
displayWebSearchStatus,
getWebSearchHookEnv,
} from './utils/websearch-manager';
// Import extracted command handlers
import { handleVersionCommand } from './commands/version-command';
@@ -147,7 +153,8 @@ async function execClaudeWithProxy(
const isWindows = process.platform === 'win32';
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;
if (needsShell) {
@@ -378,6 +385,14 @@ async function main(): Promise<void> {
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { customSettingsPath });
} else if (profileInfo.type === 'settings') {
// Settings-based profiles (glm, glmt, kimi) are third-party providers
// WebSearch is server-side tool - third-party providers have no access
ensureMcpWebSearch();
installWebSearchHook();
// Display WebSearch status (single line, equilibrium UX)
displayWebSearchStatus();
// Check if this is GLMT profile (requires proxy)
if (profileInfo.name === 'glmt') {
// GLMT FLOW: Settings-based with embedded proxy for thinking support
+23 -1
View File
@@ -28,6 +28,7 @@ import {
import { isAuthenticated } from './auth-handler';
import { CLIProxyProvider, ExecutorConfig } from './types';
import { configureProviderModel, getCurrentModel } from './model-config';
import { getWebSearchHookEnv } from '../utils/websearch-manager';
import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog';
import {
findAccountByQuery,
@@ -39,6 +40,11 @@ import {
} from './account-manager';
import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands';
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
import {
ensureMcpWebSearch,
installWebSearchHook,
displayWebSearchStatus,
} from '../utils/websearch-manager';
/** Default executor configuration */
const DEFAULT_CONFIG: ExecutorConfig = {
@@ -112,6 +118,18 @@ export async function execClaudeWithCLIProxy(
}
};
// Ensure MCP web-search is configured for third-party profiles
// WebSearch is a server-side tool executed by Anthropic's API
// Third-party providers don't have access, so we use MCP fallback
ensureMcpWebSearch();
// Install WebSearch hook for Gemini CLI + MCP fallback
// Hook intercepts WebSearch, tries Gemini CLI first, falls back to MCP
installWebSearchHook();
// Display WebSearch status (single line, equilibrium UX)
displayWebSearchStatus();
// Validate provider
const providerConfig = getProviderConfig(provider);
log(`Provider: ${providerConfig.displayName}`);
@@ -369,10 +387,14 @@ export async function execClaudeWithCLIProxy(
// 7. Execute Claude CLI with proxied environment
// Uses custom settings path (for variants), user settings, or bundled defaults
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_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
const ccsFlags = [
+138 -2
View File
@@ -64,6 +64,7 @@ export function getConfigFormat(): 'yaml' | 'json' | 'none' {
/**
* Load unified config from YAML file.
* 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 {
const yamlPath = getConfigYamlPath();
@@ -82,6 +83,22 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
return null;
}
// Auto-upgrade if version is outdated (regenerates YAML with new comments and fields)
if ((parsed.version ?? 1) < UNIFIED_CONFIG_VERSION) {
// Merge with defaults to add new fields (e.g., model for websearch providers)
const upgraded = mergeWithDefaults(parsed);
upgraded.version = UNIFIED_CONFIG_VERSION;
try {
saveUnifiedConfig(upgraded);
if (process.env.CCS_DEBUG) {
console.error(`[i] Config upgraded to v${UNIFIED_CONFIG_VERSION}`);
}
return upgraded;
} catch {
// Ignore save errors during upgrade - config still works
}
}
return parsed;
} catch (err) {
const error = err instanceof Error ? err.message : 'Unknown error';
@@ -115,6 +132,33 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
...defaults.preferences,
...partial.preferences,
},
websearch: {
enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true,
providers: {
gemini: {
enabled:
partial.websearch?.providers?.gemini?.enabled ??
partial.websearch?.gemini?.enabled ?? // Legacy fallback
true,
model: partial.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash',
timeout:
partial.websearch?.providers?.gemini?.timeout ??
partial.websearch?.gemini?.timeout ?? // Legacy fallback
55,
},
opencode: {
enabled: partial.websearch?.providers?.opencode?.enabled ?? false,
model: partial.websearch?.providers?.opencode?.model ?? 'opencode/grok-code',
timeout: partial.websearch?.providers?.opencode?.timeout ?? 90,
},
grok: {
enabled: partial.websearch?.providers?.grok?.enabled ?? false,
timeout: partial.websearch?.providers?.grok?.timeout ?? 55,
},
},
// Legacy fields (keep for backwards compatibility during read)
gemini: partial.websearch?.gemini,
},
};
}
@@ -149,8 +193,7 @@ function generateYamlHeader(): string {
#
# To customize a profile:
# 1. Edit the *.settings.json file directly (e.g., ~/.ccs/glm.settings.json)
# 2. The file format matches Claude's settings.json: { "env": { ... } }
#
# 2. The file format matches Claude's settings.json: { "env": { ... } }\n#
# Structure:
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ profiles - References to *.settings.json files for API providers │
@@ -228,6 +271,37 @@ function generateYamlWithComments(config: UnifiedConfig): string {
);
lines.push('');
// WebSearch section
if (config.websearch) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# WebSearch: CLI-based web search for third-party profiles');
lines.push('# Dashboard (`ccs config`) is the source of truth for provider selection.');
lines.push('#');
lines.push('# Third-party providers (gemini, codex, agy, etc.) do not have access to');
lines.push("# Anthropic's WebSearch tool. These CLI tools provide fallback web search.");
lines.push('#');
lines.push('# Fallback chain: Gemini -> OpenCode -> Grok (tries in order until success)');
lines.push('#');
lines.push(
'# Gemini models: gemini-2.5-flash (default), gemini-2.5-pro, gemini-2.5-flash-lite'
);
lines.push(
'# OpenCode models: opencode/grok-code (default), opencode/gpt-4o, opencode/claude-3.5-sonnet'
);
lines.push('#');
lines.push('# Install commands:');
lines.push('# gemini: npm i -g @google/gemini-cli (FREE - 1000 req/day)');
lines.push('# opencode: curl -fsSL https://opencode.ai/install | bash (FREE via Zen)');
lines.push('# grok: npm i -g @vibe-kit/grok-cli (requires GROK_API_KEY)');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump({ websearch: config.websearch }, { indent: 2, lineWidth: -1, quotingType: '"' })
.trim()
);
lines.push('');
}
return lines.join('\n');
}
@@ -292,3 +366,65 @@ export function getDefaultProfile(): string | undefined {
export function setDefaultProfile(name: string): void {
updateUnifiedConfig({ default: name });
}
/**
* Gemini CLI WebSearch configuration
*/
export interface GeminiWebSearchInfo {
enabled: boolean;
model: string;
timeout: number;
}
/**
* Get websearch configuration.
* Returns defaults if not configured.
* Supports Gemini CLI, OpenCode, and Grok CLI providers.
*/
export function getWebSearchConfig(): {
enabled: boolean;
providers?: {
gemini?: GeminiWebSearchInfo;
opencode?: { enabled?: boolean; model?: string; timeout?: number };
grok?: { enabled?: boolean; timeout?: number };
};
// Legacy fields (deprecated)
gemini?: { enabled?: boolean; timeout?: number };
} {
const config = loadOrCreateUnifiedConfig();
// Build provider configs
const geminiConfig: GeminiWebSearchInfo = {
enabled:
config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? true,
model: config.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash',
timeout:
config.websearch?.providers?.gemini?.timeout ?? config.websearch?.gemini?.timeout ?? 55,
};
const opencodeConfig = {
enabled: config.websearch?.providers?.opencode?.enabled ?? false,
model: config.websearch?.providers?.opencode?.model ?? 'opencode/grok-code',
timeout: config.websearch?.providers?.opencode?.timeout ?? 90,
};
const grokConfig = {
enabled: config.websearch?.providers?.grok?.enabled ?? false,
timeout: config.websearch?.providers?.grok?.timeout ?? 55,
};
// Auto-enable master switch if ANY provider is enabled
const anyProviderEnabled = geminiConfig.enabled || opencodeConfig.enabled || grokConfig.enabled;
const enabled = anyProviderEnabled && (config.websearch?.enabled ?? true);
return {
enabled,
providers: {
gemini: geminiConfig,
opencode: opencodeConfig,
grok: grokConfig,
},
// Legacy field for backwards compatibility
gemini: config.websearch?.gemini,
};
}
+100 -1
View File
@@ -12,8 +12,9 @@
/**
* Unified config version.
* Version 2 = YAML unified format
* Version 3 = WebSearch config with model configuration for Gemini/OpenCode
*/
export const UNIFIED_CONFIG_VERSION = 2;
export const UNIFIED_CONFIG_VERSION = 3;
/**
* Account configuration (formerly in profiles.json).
@@ -100,6 +101,83 @@ export interface PreferencesConfig {
auto_update?: boolean;
}
/**
* Gemini CLI WebSearch configuration.
*/
export interface GeminiWebSearchConfig {
/** Enable Gemini CLI for WebSearch (default: true) */
enabled?: boolean;
/** Model to use (default: gemini-2.5-flash) */
model?: string;
/** Timeout in seconds (default: 55) */
timeout?: number;
}
/**
* Grok CLI WebSearch configuration.
*/
export interface GrokWebSearchConfig {
/** Enable Grok CLI for WebSearch (default: false - requires GROK_API_KEY) */
enabled?: boolean;
/** Timeout in seconds (default: 55) */
timeout?: number;
}
/**
* OpenCode CLI WebSearch configuration.
*/
export interface OpenCodeWebSearchConfig {
/** Enable OpenCode CLI for WebSearch (default: false) */
enabled?: boolean;
/** Model to use (default: opencode/grok-code) */
model?: string;
/** Timeout in seconds (default: 60) */
timeout?: number;
}
/**
* WebSearch providers configuration.
* Supports Gemini CLI, Grok CLI, and OpenCode.
*/
export interface WebSearchProvidersConfig {
/** Gemini CLI - uses google_web_search tool (FREE tier: 1000 req/day) */
gemini?: GeminiWebSearchConfig;
/** Grok CLI - xAI web search (requires GROK_API_KEY) */
grok?: GrokWebSearchConfig;
/** OpenCode - built-in web search (FREE via OpenCode Zen) */
opencode?: OpenCodeWebSearchConfig;
}
/**
* WebSearch configuration.
* Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles.
* Third-party providers don't have server-side WebSearch access.
*/
export interface WebSearchConfig {
/** Master switch - enable/disable WebSearch (default: true) */
enabled?: boolean;
/** Individual provider configurations */
providers?: WebSearchProvidersConfig;
// Legacy fields (deprecated, kept for backwards compatibility)
/** @deprecated Use providers.gemini instead */
gemini?: {
enabled?: boolean;
timeout?: number;
};
/** @deprecated Unused */
mode?: 'sequential' | 'parallel';
/** @deprecated Unused */
provider?: 'auto' | 'web-search-prime' | 'brave' | 'tavily';
/** @deprecated Unused */
fallback?: boolean;
/** @deprecated Unused */
webSearchPrimeUrl?: string;
/** @deprecated Unused */
selectedProviders?: string[];
/** @deprecated Unused */
customMcp?: unknown[];
}
/**
* Main unified configuration structure.
* Stored in ~/.ccs/config.yaml
@@ -117,6 +195,8 @@ export interface UnifiedConfig {
cliproxy: CLIProxyConfig;
/** User preferences */
preferences: PreferencesConfig;
/** WebSearch configuration */
websearch?: WebSearchConfig;
}
/**
@@ -154,6 +234,25 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
telemetry: false,
auto_update: true,
},
websearch: {
enabled: true,
providers: {
gemini: {
enabled: true,
model: 'gemini-2.5-flash',
timeout: 55,
},
opencode: {
enabled: false,
model: 'opencode/grok-code',
timeout: 90,
},
grok: {
enabled: false,
timeout: 55,
},
},
},
};
}
+7 -1
View File
@@ -6,6 +6,7 @@
import { spawn, ChildProcess } from 'child_process';
import { ErrorManager } from './error-manager';
import { getWebSearchHookEnv } from './websearch-manager';
/**
* Escape arguments for shell execution (Windows compatibility)
@@ -25,8 +26,13 @@ export function execClaude(
const isWindows = process.platform === 'win32';
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)
const env = envVars ? { ...process.env, ...envVars } : process.env;
const env = envVars
? { ...process.env, ...envVars, ...webSearchEnv }
: { ...process.env, ...webSearchEnv };
let child: ChildProcess;
if (needsShell) {
+806
View File
@@ -0,0 +1,806 @@
/**
* 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';
// Buffer time added to max provider timeout for hook timeout (seconds)
const HOOK_TIMEOUT_BUFFER = 30;
// Minimum hook timeout in seconds (fallback if no providers configured)
const MIN_HOOK_TIMEOUT = 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;
}
// ========== OpenCode CLI Detection ==========
/**
* OpenCode CLI installation status
*/
export interface OpenCodeCliStatus {
installed: boolean;
path: string | null;
version: string | null;
}
// Cache for OpenCode CLI status (per process)
let opencodeCliCache: OpenCodeCliStatus | null = null;
/**
* Check if OpenCode CLI is installed globally
*
* OpenCode provides built-in web search via opencode/grok-code model.
* Install: curl -fsSL https://opencode.ai/install | bash
*
* @returns OpenCode CLI status with path and version
*/
export function getOpenCodeCliStatus(): OpenCodeCliStatus {
// Return cached result if available
if (opencodeCliCache) {
return opencodeCliCache;
}
const result: OpenCodeCliStatus = {
installed: false,
path: null,
version: null,
};
try {
const isWindows = process.platform === 'win32';
const whichCmd = isWindows ? 'where opencode' : 'which opencode';
const pathResult = execSync(whichCmd, {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
});
const opencodePath = pathResult.trim().split('\n')[0]; // First result on Windows
if (opencodePath) {
result.installed = true;
result.path = opencodePath;
// Try to get version
try {
const versionResult = execSync('opencode --version', {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
});
result.version = versionResult.trim();
} catch {
// Version check failed, but CLI is installed
result.version = 'unknown';
}
}
} catch {
// Command not found - OpenCode CLI not installed
}
// Cache result
opencodeCliCache = result;
return result;
}
/**
* Check if OpenCode CLI is available (quick boolean check)
*/
export function hasOpenCodeCli(): boolean {
return getOpenCodeCliStatus().installed;
}
/**
* Clear OpenCode CLI cache (for testing or after installation)
*/
export function clearOpenCodeCliCache(): void {
opencodeCliCache = null;
}
/**
* Clear all CLI caches
*/
export function clearAllCliCaches(): void {
geminiCliCache = null;
grokCliCache = null;
opencodeCliCache = null;
}
// ========== CLI Provider Info ==========
/**
* WebSearch CLI provider information for health checks and UI
*/
export interface WebSearchCliInfo {
/** Provider ID */
id: 'gemini' | 'grok' | 'opencode';
/** 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();
const opencodeStatus = getOpenCodeCliStatus();
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: 'opencode',
name: 'OpenCode',
command: 'opencode',
installed: opencodeStatus.installed,
version: opencodeStatus.version,
installCommand: 'curl -fsSL https://opencode.ai/install | bash',
docsUrl: 'https://github.com/sst/opencode',
requiresApiKey: false,
description: 'OpenCode with built-in web search (FREE via Zen)',
freeTier: true,
},
{
id: 'grok',
name: 'Grok CLI',
command: 'grok',
installed: grokStatus.installed,
version: grokStatus.version,
installCommand: 'npm install -g @vibe-kit/grok-cli',
docsUrl: 'https://github.com/superagent-ai/grok-cli',
requiresApiKey: true,
apiKeyEnvVar: 'GROK_API_KEY',
description: 'xAI Grok CLI with AI coding agent capabilities',
freeTier: false,
},
];
}
/**
* Check if any WebSearch CLI is available
*/
export function hasAnyWebSearchCli(): boolean {
return hasGeminiCli() || hasGrokCli() || hasOpenCodeCli();
}
/**
* 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',
' OpenCode (FREE): curl -fsSL https://opencode.ai/install | bash',
' Grok CLI (paid): npm i -g @vibe-kit/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
* Timeout is computed from max provider timeout in config.yaml + buffer
*/
export function getWebSearchHookConfig(): Record<string, unknown> {
const hookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK);
const wsConfig = getWebSearchConfig();
// Compute max timeout from enabled providers
const timeouts: number[] = [];
if (wsConfig.providers?.gemini?.enabled && wsConfig.providers.gemini.timeout) {
timeouts.push(wsConfig.providers.gemini.timeout);
}
if (wsConfig.providers?.opencode?.enabled && wsConfig.providers.opencode.timeout) {
timeouts.push(wsConfig.providers.opencode.timeout);
}
if (wsConfig.providers?.grok?.enabled && wsConfig.providers.grok.timeout) {
timeouts.push(wsConfig.providers.grok.timeout);
}
// Hook timeout = max provider timeout + buffer (or minimum if none configured)
const maxProviderTimeout = timeouts.length > 0 ? Math.max(...timeouts) : MIN_HOOK_TIMEOUT;
const hookTimeout = maxProviderTimeout + HOOK_TIMEOUT_BUFFER;
return {
PreToolUse: [
{
matcher: 'WebSearch',
hooks: [
{
type: 'command',
command: `node "${hookPath}"`,
timeout: hookTimeout,
},
],
},
],
};
}
/**
* 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;
const expectedHookPath = path.join(CCS_HOOKS_DIR, WEBSEARCH_HOOK);
const expectedCommand = `node "${expectedHookPath}"`;
if (hooks?.PreToolUse) {
const webSearchHookIndex = hooks.PreToolUse.findIndex((h: unknown) => {
const hook = h as Record<string, unknown>;
return hook.matcher === 'WebSearch';
});
if (webSearchHookIndex !== -1) {
// Hook exists - check if it needs updating (different command path or timeout)
const existingHook = hooks.PreToolUse[webSearchHookIndex] as Record<string, unknown>;
const existingHooks = existingHook.hooks as Array<Record<string, unknown>>;
const currentHookConfig = getWebSearchHookConfig();
const expectedHooks = (currentHookConfig.PreToolUse as Array<Record<string, unknown>>)[0]
.hooks as Array<Record<string, unknown>>;
const expectedTimeout = expectedHooks[0].timeout as number;
let needsUpdate = false;
if (existingHooks?.[0]?.command !== expectedCommand) {
existingHooks[0].command = expectedCommand;
needsUpdate = true;
}
if (existingHooks?.[0]?.timeout !== expectedTimeout) {
existingHooks[0].timeout = expectedTimeout;
needsUpdate = true;
}
if (needsUpdate) {
fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
if (process.env.CCS_DEBUG) {
console.error(info('Updated WebSearch hook config 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 master switch
env.CCS_WEBSEARCH_ENABLED = '1';
// Pass individual provider enabled states
// Hook will only use providers that are BOTH enabled AND installed
if (wsConfig.providers?.gemini?.enabled) {
env.CCS_WEBSEARCH_GEMINI = '1';
if (wsConfig.providers.gemini.model) {
env.CCS_WEBSEARCH_GEMINI_MODEL = wsConfig.providers.gemini.model;
}
env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.gemini.timeout || 55);
}
if (wsConfig.providers?.opencode?.enabled) {
env.CCS_WEBSEARCH_OPENCODE = '1';
if (wsConfig.providers.opencode.model) {
env.CCS_WEBSEARCH_OPENCODE_MODEL = wsConfig.providers.opencode.model;
}
// Use opencode timeout if no gemini timeout set
if (!env.CCS_WEBSEARCH_TIMEOUT) {
env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.opencode.timeout || 90);
}
}
if (wsConfig.providers?.grok?.enabled) {
env.CCS_WEBSEARCH_GROK = '1';
// Use grok timeout if no other timeout set
if (!env.CCS_WEBSEARCH_TIMEOUT) {
env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.grok.timeout || 55);
}
}
// Default timeout if none set
if (!env.CCS_WEBSEARCH_TIMEOUT) {
env.CCS_WEBSEARCH_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;
opencodeCli: 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,
opencodeCli: false,
message: 'Disabled in config',
};
}
// Check all CLIs
const geminiInstalled = hasGeminiCli();
const grokInstalled = hasGrokCli();
const opencodeInstalled = hasOpenCodeCli();
// Build message based on installed CLIs
const installedClis: string[] = [];
if (geminiInstalled) installedClis.push('Gemini');
if (grokInstalled) installedClis.push('Grok');
if (opencodeInstalled) installedClis.push('OpenCode');
if (installedClis.length > 0) {
return {
readiness: 'ready',
geminiCli: geminiInstalled,
grokCli: grokInstalled,
opencodeCli: opencodeInstalled,
message: `Ready (${installedClis.join(' + ')})`,
};
}
return {
readiness: 'unavailable',
geminiCli: false,
grokCli: false,
opencodeCli: 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');
}
+54
View File
@@ -23,6 +23,7 @@ import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
import packageJson from '../../package.json';
import { getEnvironmentDiagnostics } from '../management/environment-diagnostics';
import { checkAuthCodePorts } from '../management/oauth-port-diagnostics';
import { getWebSearchCliProviders, hasAnyWebSearchCli } from '../utils/websearch-manager';
export interface HealthCheck {
id: string;
@@ -136,6 +137,16 @@ export async function runHealthChecks(): Promise<HealthReport> {
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
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
*/
+162
View File
@@ -46,6 +46,7 @@ import {
loadUnifiedConfig,
saveUnifiedConfig,
getConfigFormat,
getConfigYamlPath,
} from '../config/unified-config-loader';
import {
needsMigration,
@@ -54,8 +55,16 @@ import {
getBackupDirectories,
} from '../config/migration-manager';
import { getProfileSecrets, setProfileSecrets } from '../config/secrets-manager';
import { getWebSearchConfig } from '../config/unified-config-loader';
import type { WebSearchConfig } from '../config/unified-config-types';
import { isUnifiedConfig } from '../config/unified-config-types';
import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys';
import {
getWebSearchReadiness,
getGeminiCliStatus,
getGrokCliStatus,
getOpenCodeCliStatus,
} from '../utils/websearch-manager';
export const apiRoutes = Router();
@@ -957,6 +966,23 @@ apiRoutes.get('/config', (_req: Request, res: Response): void => {
res.json(config);
});
/**
* GET /api/config/raw - Return raw YAML content for display
*/
apiRoutes.get('/config/raw', (_req: Request, res: Response): void => {
const yamlPath = getConfigYamlPath();
if (!fs.existsSync(yamlPath)) {
res.status(404).json({ error: 'Config file not found' });
return;
}
try {
const content = fs.readFileSync(yamlPath, 'utf8');
res.type('text/plain').send(content);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
/**
* PUT /api/config - Update unified config
*/
@@ -1430,3 +1456,139 @@ apiRoutes.delete('/cliproxy/openai-compat/:name', (req: Request, res: Response):
res.status(500).json({ error: (error as Error).message });
}
});
// ==================== WebSearch Configuration ====================
/**
* GET /api/websearch - Get WebSearch configuration
* Returns: WebSearchConfig with enabled, provider, fallback
*/
apiRoutes.get('/websearch', (_req: Request, res: Response): void => {
try {
const config = getWebSearchConfig();
res.json(config);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/websearch - Update WebSearch configuration
* Body: WebSearchConfig fields (enabled, providers)
* Dashboard is the source of truth for provider selection.
*/
apiRoutes.put('/websearch', (req: Request, res: Response): void => {
const { enabled, providers } = req.body as Partial<WebSearchConfig>;
// Validate enabled
if (enabled !== undefined && typeof enabled !== 'boolean') {
res.status(400).json({ error: 'Invalid value for enabled. Must be a boolean.' });
return;
}
// Validate providers if specified
if (providers !== undefined && typeof providers !== 'object') {
res.status(400).json({ error: 'Invalid value for providers. Must be an object.' });
return;
}
try {
// Load existing config and update websearch section
const existingConfig = loadUnifiedConfig();
if (!existingConfig) {
res.status(500).json({ error: 'Failed to load config' });
return;
}
// Merge updates - supports Gemini CLI and Grok CLI
existingConfig.websearch = {
enabled: enabled ?? existingConfig.websearch?.enabled ?? true,
providers: providers
? {
gemini: {
enabled:
providers.gemini?.enabled ??
existingConfig.websearch?.providers?.gemini?.enabled ??
true,
model:
providers.gemini?.model ??
existingConfig.websearch?.providers?.gemini?.model ??
'gemini-2.5-flash',
timeout:
providers.gemini?.timeout ??
existingConfig.websearch?.providers?.gemini?.timeout ??
55,
},
grok: {
enabled:
providers.grok?.enabled ??
existingConfig.websearch?.providers?.grok?.enabled ??
false,
timeout:
providers.grok?.timeout ?? existingConfig.websearch?.providers?.grok?.timeout ?? 55,
},
opencode: {
enabled:
providers.opencode?.enabled ??
existingConfig.websearch?.providers?.opencode?.enabled ??
false,
model:
providers.opencode?.model ??
existingConfig.websearch?.providers?.opencode?.model ??
'opencode/grok-code',
timeout:
providers.opencode?.timeout ??
existingConfig.websearch?.providers?.opencode?.timeout ??
60,
},
}
: existingConfig.websearch?.providers,
};
saveUnifiedConfig(existingConfig);
res.json({
success: true,
websearch: existingConfig.websearch,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/websearch/status - Get WebSearch status
* Returns: { geminiCli, grokCli, opencodeCli, readiness }
*/
apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => {
try {
const geminiCli = getGeminiCliStatus();
const grokCli = getGrokCliStatus();
const opencodeCli = getOpenCodeCliStatus();
const readiness = getWebSearchReadiness();
res.json({
geminiCli: {
installed: geminiCli.installed,
path: geminiCli.path,
version: geminiCli.version,
},
grokCli: {
installed: grokCli.installed,
path: grokCli.path,
version: grokCli.version,
},
opencodeCli: {
installed: opencodeCli.installed,
path: opencodeCli.path,
version: opencodeCli.version,
},
readiness: {
status: readiness.readiness,
message: readiness.message,
},
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
+10 -20
View File
@@ -1,18 +1,16 @@
/**
* Graceful Shutdown Handler
* Shutdown Handler
*
* Handles SIGINT/SIGTERM signals to gracefully close WebSocket connections
* and HTTP server before process exit.
* Handles SIGINT/SIGTERM signals to close server and exit immediately.
* No graceful waiting - config dashboard doesn't need it.
*/
import { Server as HTTPServer } from 'http';
import { WebSocketServer } from 'ws';
import { ok, info, warn } from '../utils/ui';
const SHUTDOWN_TIMEOUT = 10_000; // 10 seconds
import { ok } from '../utils/ui';
/**
* Setup graceful shutdown handlers for SIGINT and SIGTERM
* Setup shutdown handlers for SIGINT and SIGTERM
*/
export function setupGracefulShutdown(
server: HTTPServer,
@@ -20,24 +18,16 @@ export function setupGracefulShutdown(
cleanup?: () => void
): void {
const shutdown = () => {
console.log('\n' + info('Shutting down gracefully...'));
console.log('\n' + ok('Shutting down...'));
// Run cleanup first (closes file watchers + WebSocket clients)
// Run cleanup (closes file watchers + WebSocket clients)
if (cleanup) {
cleanup();
}
// Close HTTP server
server.close(() => {
console.log(ok('Server closed'));
process.exit(0);
});
// Force shutdown if graceful shutdown takes too long
setTimeout(() => {
console.log(warn('Force shutdown (timeout exceeded)'));
process.exit(1);
}, SHUTDOWN_TIMEOUT);
// Close server and exit immediately
server.close();
process.exit(0);
};
process.on('SIGINT', shutdown);
+269
View File
@@ -0,0 +1,269 @@
/**
* Unit tests for MCP Manager module
*
* Tests web search MCP configuration logic without filesystem dependencies.
* These tests focus on the pure logic functions that can be tested without
* modifying the actual config files.
*/
import { describe, it, expect } from 'bun:test';
/**
* Test helper: Simulate hasWebSearch detection logic
* This matches the logic in mcp-manager.ts hasMcpWebSearch()
*/
function detectWebSearchMcp(mcpServers: Record<string, unknown>): boolean {
return Object.keys(mcpServers).some((key) => {
const lowerKey = key.toLowerCase();
return (
lowerKey.includes('web-search') ||
lowerKey.includes('websearch') ||
lowerKey.includes('tavily') ||
lowerKey.includes('brave')
);
});
}
/**
* Test helper: Simulate provider configuration logic
* Returns which providers would be added given config and env
*/
function getProvidersToAdd(
wsConfig: { enabled: boolean; provider: string; fallback: boolean },
hasExistingWebSearch: boolean,
apiKeys: { brave?: string; tavily?: string }
): string[] {
if (!wsConfig.enabled) return [];
if (hasExistingWebSearch) return [];
const providers: string[] = [];
if (wsConfig.provider === 'auto') {
providers.push('web-search-prime');
if (wsConfig.fallback) {
if (apiKeys.brave) providers.push('brave-search');
if (apiKeys.tavily) providers.push('tavily');
}
} else if (wsConfig.provider === 'web-search-prime') {
providers.push('web-search-prime');
} else if (wsConfig.provider === 'brave') {
if (apiKeys.brave) {
providers.push('brave-search');
} else if (wsConfig.fallback) {
// Fallback chain
providers.push('web-search-prime');
if (apiKeys.tavily) providers.push('tavily');
}
} else if (wsConfig.provider === 'tavily') {
if (apiKeys.tavily) {
providers.push('tavily');
} else if (wsConfig.fallback) {
// Fallback chain
providers.push('web-search-prime');
if (apiKeys.brave) providers.push('brave-search');
}
}
return providers;
}
describe('mcp-manager logic', () => {
describe('web search detection', () => {
it('should detect web-search-prime', () => {
const servers = { 'web-search-prime': { type: 'http' } };
expect(detectWebSearchMcp(servers)).toBe(true);
});
it('should detect brave-search', () => {
const servers = { 'brave-search': { type: 'stdio' } };
expect(detectWebSearchMcp(servers)).toBe(true);
});
it('should detect tavily', () => {
const servers = { tavily: { type: 'stdio' } };
expect(detectWebSearchMcp(servers)).toBe(true);
});
it('should be case-insensitive', () => {
expect(detectWebSearchMcp({ 'WebSearch-Custom': {} })).toBe(true);
expect(detectWebSearchMcp({ 'WEB-SEARCH': {} })).toBe(true);
expect(detectWebSearchMcp({ TAVILY: {} })).toBe(true);
expect(detectWebSearchMcp({ 'Brave-Search': {} })).toBe(true);
});
it('should not detect unrelated MCPs', () => {
expect(detectWebSearchMcp({ 'my-custom-mcp': {} })).toBe(false);
expect(detectWebSearchMcp({ 'filesystem': {} })).toBe(false);
expect(detectWebSearchMcp({ 'github-copilot': {} })).toBe(false);
});
it('should return false for empty servers', () => {
expect(detectWebSearchMcp({})).toBe(false);
});
});
describe('provider selection logic', () => {
const defaultConfig = { enabled: true, provider: 'auto', fallback: true };
const noApiKeys = {};
const braveOnly = { brave: 'test-key' };
const tavilyOnly = { tavily: 'test-key' };
const bothKeys = { brave: 'brave-key', tavily: 'tavily-key' };
it('should add web-search-prime in auto mode', () => {
const providers = getProvidersToAdd(defaultConfig, false, noApiKeys);
expect(providers).toContain('web-search-prime');
});
it('should add brave-search when API key available in auto mode', () => {
const providers = getProvidersToAdd(defaultConfig, false, braveOnly);
expect(providers).toContain('web-search-prime');
expect(providers).toContain('brave-search');
});
it('should add tavily when API key available in auto mode', () => {
const providers = getProvidersToAdd(defaultConfig, false, tavilyOnly);
expect(providers).toContain('web-search-prime');
expect(providers).toContain('tavily');
});
it('should add all providers when both API keys available', () => {
const providers = getProvidersToAdd(defaultConfig, false, bothKeys);
expect(providers).toContain('web-search-prime');
expect(providers).toContain('brave-search');
expect(providers).toContain('tavily');
});
it('should not add fallbacks when fallback=false', () => {
const config = { enabled: true, provider: 'auto', fallback: false };
const providers = getProvidersToAdd(config, false, bothKeys);
expect(providers).toEqual(['web-search-prime']);
});
it('should skip when disabled', () => {
const config = { enabled: false, provider: 'auto', fallback: true };
const providers = getProvidersToAdd(config, false, bothKeys);
expect(providers).toEqual([]);
});
it('should skip when web search already exists', () => {
const providers = getProvidersToAdd(defaultConfig, true, bothKeys);
expect(providers).toEqual([]);
});
it('should use specific provider when configured', () => {
const config = { enabled: true, provider: 'brave', fallback: false };
const providers = getProvidersToAdd(config, false, braveOnly);
expect(providers).toEqual(['brave-search']);
});
it('should fallback when specific provider not available', () => {
const config = { enabled: true, provider: 'tavily', fallback: true };
// No tavily key, should fallback
const providers = getProvidersToAdd(config, false, braveOnly);
expect(providers).toContain('web-search-prime');
expect(providers).toContain('brave-search');
expect(providers).not.toContain('tavily');
});
it('should return empty when provider unavailable and fallback=false', () => {
const config = { enabled: true, provider: 'brave', fallback: false };
// No brave key and no fallback
const providers = getProvidersToAdd(config, false, noApiKeys);
expect(providers).toEqual([]);
});
});
describe('MCP server config structures', () => {
it('should define correct web-search-prime structure', () => {
const webSearchPrimeConfig = {
type: 'http',
url: 'https://api.z.ai/api/mcp/web_search_prime/mcp',
headers: {},
};
expect(webSearchPrimeConfig.type).toBe('http');
expect(webSearchPrimeConfig.url).toContain('web_search_prime');
});
it('should define correct brave-search structure', () => {
const braveConfig = {
type: 'stdio',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-brave-search'],
env: { BRAVE_API_KEY: 'test-key' },
};
expect(braveConfig.type).toBe('stdio');
expect(braveConfig.command).toBe('npx');
expect(braveConfig.args).toContain('@modelcontextprotocol/server-brave-search');
expect(braveConfig.env.BRAVE_API_KEY).toBe('test-key');
});
it('should define correct tavily structure', () => {
const tavilyConfig = {
type: 'stdio',
command: 'npx',
args: ['-y', '@tavily/mcp-server'],
env: { TAVILY_API_KEY: 'test-key' },
};
expect(tavilyConfig.type).toBe('stdio');
expect(tavilyConfig.args).toContain('@tavily/mcp-server');
expect(tavilyConfig.env.TAVILY_API_KEY).toBe('test-key');
});
});
describe('hook configuration', () => {
it('should define correct PreToolUse hook structure', () => {
const hookConfig = {
PreToolUse: [
{
matcher: 'WebSearch',
hooks: [
{
type: 'command',
command: 'node "/path/to/hook.cjs"',
timeout: 5,
},
],
},
],
};
expect(hookConfig.PreToolUse).toBeDefined();
expect(hookConfig.PreToolUse[0].matcher).toBe('WebSearch');
expect(hookConfig.PreToolUse[0].hooks[0].type).toBe('command');
expect(hookConfig.PreToolUse[0].hooks[0].timeout).toBe(5);
});
});
describe('MCP config file path', () => {
it('should be located in .claude directory', () => {
// The MCP config path should follow this pattern
const expectedPathPattern = '.claude/.mcp.json';
const testPath = '/home/user/.claude/.mcp.json';
expect(testPath).toContain(expectedPathPattern);
});
});
describe('WebSearch config defaults', () => {
it('should have correct default values', () => {
const defaults = {
enabled: true,
provider: 'auto',
fallback: true,
};
expect(defaults.enabled).toBe(true);
expect(defaults.provider).toBe('auto');
expect(defaults.fallback).toBe(true);
});
it('should validate provider options', () => {
const validProviders = ['auto', 'web-search-prime', 'brave', 'tavily'];
expect(validProviders).toContain('auto');
expect(validProviders).toContain('web-search-prime');
expect(validProviders).toContain('brave');
expect(validProviders).toContain('tavily');
});
});
});
+6
View File
@@ -8,6 +8,7 @@
"@nivo/core": "^0.99.0",
"@nivo/sankey": "^0.99.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -17,6 +18,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
@@ -225,6 +227,8 @@
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
@@ -271,6 +275,8 @@
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
+2
View File
@@ -19,6 +19,7 @@
"@nivo/core": "^0.99.0",
"@nivo/sankey": "^0.99.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -28,6 +29,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
+27
View File
@@ -0,0 +1,27 @@
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+26
View File
@@ -0,0 +1,26 @@
import * as React from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+710 -35
View File
@@ -1,46 +1,721 @@
/**
* Settings Page - Deprecated
* Settings functionality has been moved to API Profiles page
* Settings Page - WebSearch Configuration
* Supports Gemini CLI and Grok CLI providers
*/
import { Link } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useState, useEffect } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { Button } from '@/components/ui/button';
import { Construction, ArrowRight } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import {
Globe,
RefreshCw,
CheckCircle2,
AlertCircle,
FileCode,
Copy,
Check,
GripVertical,
Terminal,
ExternalLink,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { CodeEditor } from '@/components/code-editor';
interface ProviderConfig {
enabled?: boolean;
model?: string;
timeout?: number;
}
interface WebSearchProvidersConfig {
gemini?: ProviderConfig;
grok?: ProviderConfig;
opencode?: ProviderConfig;
}
interface WebSearchConfig {
enabled: boolean;
providers?: WebSearchProvidersConfig;
}
interface CliStatus {
installed: boolean;
path: string | null;
version: string | null;
}
interface WebSearchStatus {
geminiCli: CliStatus;
grokCli: CliStatus;
opencodeCli: CliStatus;
readiness: {
status: 'ready' | 'unavailable';
message: string;
};
}
export function SettingsPage() {
return (
<div className="p-6 max-w-6xl mx-auto space-y-8">
<h1 className="text-2xl font-bold">Settings</h1>
const [config, setConfig] = useState<WebSearchConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [status, setStatus] = useState<WebSearchStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
// Config viewer state
const [rawConfig, setRawConfig] = useState<string | null>(null);
const [rawConfigLoading, setRawConfigLoading] = useState(false);
const [copied, setCopied] = useState(false);
// Local model input state (to avoid saving on every keystroke)
const [geminiModelInput, setGeminiModelInput] = useState('');
const [opencodeModelInput, setOpencodeModelInput] = useState('');
// Collapsible install hints state
const [showGeminiHint, setShowGeminiHint] = useState(false);
const [showOpencodeHint, setShowOpencodeHint] = useState(false);
const [showGrokHint, setShowGrokHint] = useState(false);
<Card className="border-yellow-500/50 bg-yellow-500/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-yellow-600">
<Construction className="w-5 h-5" />
Page Relocated
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
The settings editor has been integrated into the <strong>API Profiles</strong> page for
a better user experience.
</p>
<p className="text-muted-foreground">To edit environment variables for a profile:</p>
<ol className="list-decimal list-inside text-muted-foreground space-y-1 ml-2">
<li>Go to API Profiles page</li>
<li>Click the actions menu (...) on any profile</li>
<li>Select &quot;Edit Settings&quot;</li>
</ol>
<div className="pt-2">
<Button asChild>
<Link to="/api">
Go to API Profiles
<ArrowRight className="w-4 h-4 ml-2" />
</Link>
</Button>
// Load config and status on mount
useEffect(() => {
fetchConfig();
fetchStatus();
fetchRawConfig();
}, []);
// Sync local model inputs when config changes
useEffect(() => {
if (config) {
setGeminiModelInput(config.providers?.gemini?.model ?? 'gemini-2.5-flash');
setOpencodeModelInput(config.providers?.opencode?.model ?? 'opencode/grok-code');
}
}, [config]);
const fetchConfig = async () => {
try {
setLoading(true);
setError(null);
const res = await fetch('/api/websearch');
if (!res.ok) throw new Error('Failed to load WebSearch config');
const data = await res.json();
setConfig(data);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
const fetchStatus = async () => {
try {
setStatusLoading(true);
const res = await fetch('/api/websearch/status');
if (!res.ok) throw new Error('Failed to load status');
const data = await res.json();
setStatus(data);
} catch (err) {
console.error('Failed to fetch WebSearch status:', err);
} finally {
setStatusLoading(false);
}
};
const fetchRawConfig = async () => {
try {
setRawConfigLoading(true);
const res = await fetch('/api/config/raw');
if (!res.ok) {
setRawConfig(null);
return;
}
const text = await res.text();
setRawConfig(text);
} catch (err) {
console.error('Failed to fetch raw config:', err);
setRawConfig(null);
} finally {
setRawConfigLoading(false);
}
};
const copyToClipboard = async () => {
if (!rawConfig) return;
try {
await navigator.clipboard.writeText(rawConfig);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
// Toggle Gemini provider
const toggleGemini = () => {
const providers = config?.providers || {};
const currentState = providers.gemini?.enabled ?? false;
const grokState = providers.grok?.enabled ?? false;
const opencodeState = providers.opencode?.enabled ?? false;
saveConfig({
enabled: !currentState || grokState || opencodeState, // Enable WebSearch if any provider is enabled
providers: {
...providers,
gemini: {
...providers.gemini,
enabled: !currentState,
},
},
});
};
const saveConfig = async (updates: Partial<WebSearchConfig>) => {
if (!config) return;
// Optimistic update - apply changes immediately to local state
const optimisticConfig = { ...config, ...updates };
setConfig(optimisticConfig);
try {
setSaving(true);
setError(null);
const res = await fetch('/api/websearch', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(optimisticConfig),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to save');
}
const data = await res.json();
setConfig(data.websearch);
// Quick flash of success (shorter duration, less intrusive)
setSuccess(true);
setTimeout(() => setSuccess(false), 1500);
// Silently refresh raw config without loading state
fetch('/api/config/raw')
.then((r) => (r.ok ? r.text() : null))
.then((text) => text && setRawConfig(text))
.catch(() => {});
} catch (err) {
// Revert optimistic update on error
setConfig(config);
setError((err as Error).message);
} finally {
setSaving(false);
}
};
const isGeminiEnabled = config?.providers?.gemini?.enabled ?? false;
const isGrokEnabled = config?.providers?.grok?.enabled ?? false;
const isOpenCodeEnabled = config?.providers?.opencode?.enabled ?? false;
// Toggle Grok provider
const toggleGrok = () => {
const providers = config?.providers || {};
const currentState = providers.grok?.enabled ?? false;
saveConfig({
enabled: isGeminiEnabled || !currentState || isOpenCodeEnabled, // Enable WebSearch if any provider is enabled
providers: {
...providers,
grok: {
...providers.grok,
enabled: !currentState,
},
},
});
};
// Toggle OpenCode provider
const toggleOpenCode = () => {
const providers = config?.providers || {};
const currentState = providers.opencode?.enabled ?? false;
saveConfig({
enabled: isGeminiEnabled || isGrokEnabled || !currentState, // Enable WebSearch if any provider is enabled
providers: {
...providers,
opencode: {
...providers.opencode,
enabled: !currentState,
},
},
});
};
// Save Gemini model on blur (only if changed)
const saveGeminiModel = () => {
const currentModel = config?.providers?.gemini?.model ?? 'gemini-2.5-flash';
if (geminiModelInput !== currentModel) {
const providers = config?.providers || {};
saveConfig({
providers: {
...providers,
gemini: {
...providers.gemini,
model: geminiModelInput,
},
},
});
}
};
// Save OpenCode model on blur (only if changed)
const saveOpencodeModel = () => {
const currentModel = config?.providers?.opencode?.model ?? 'opencode/grok-code';
if (opencodeModelInput !== currentModel) {
const providers = config?.providers || {};
saveConfig({
providers: {
...providers,
opencode: {
...providers.opencode,
model: opencodeModelInput,
},
},
});
}
};
if (loading) {
return (
<div className="h-[calc(100vh-100px)] flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<span className="text-lg">Loading configuration...</span>
</div>
</div>
);
}
return (
<div className="h-[calc(100vh-100px)]">
<PanelGroup direction="horizontal" className="h-full">
{/* Left Panel - WebSearch Controls */}
<Panel defaultSize={40} minSize={30} maxSize={55}>
<div className="h-full border-r flex flex-col bg-muted/30 relative">
{/* Header */}
<div className="p-5 border-b bg-background">
<div className="flex items-center gap-3">
<Globe className="w-6 h-6 text-primary" />
<div>
<h1 className="text-lg font-semibold">WebSearch</h1>
<p className="text-sm text-muted-foreground">
CLI-based web search for third-party profiles
</p>
</div>
</div>
</div>
{/* Toast-style alerts - absolute positioned, no layout shift */}
<div
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
error || success
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-2 pointer-events-none'
}`}
>
{error && (
<Alert variant="destructive" className="py-2 shadow-lg">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">Saved</span>
</div>
)}
</div>
{/* Scrollable Content */}
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
{/* Status Summary */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium">
{isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'}
</p>
{statusLoading ? (
<p className="text-sm text-muted-foreground">Checking status...</p>
) : status?.readiness ? (
<p className="text-sm text-muted-foreground">{status.readiness.message}</p>
) : null}
</div>
<Button variant="ghost" size="sm" onClick={fetchStatus} disabled={statusLoading}>
<RefreshCw className={`w-4 h-4 ${statusLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* CLI Providers */}
<div className="space-y-3">
<h3 className="text-base font-medium">Providers</h3>
{/* Gemini CLI Provider */}
<div
className={`rounded-lg border transition-colors ${
isGeminiEnabled ? 'border-primary border-l-4' : 'border-border'
}`}
>
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<Terminal
className={`w-5 h-5 ${isGeminiEnabled ? 'text-primary' : 'text-muted-foreground'}`}
/>
<div>
<div className="flex items-center gap-2">
<p className="font-mono font-medium">gemini</p>
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
FREE
</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>
</div>
</div>
<Switch
checked={isGeminiEnabled}
onCheckedChange={toggleGemini}
disabled={saving || !status?.geminiCli?.installed}
/>
</div>
{/* Model input when enabled */}
{isGeminiEnabled && (
<div className="px-4 pb-4 pt-0">
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground whitespace-nowrap">
Model:
</label>
<Input
value={geminiModelInput}
onChange={(e) => setGeminiModelInput(e.target.value)}
onBlur={saveGeminiModel}
placeholder="gemini-2.5-flash"
className="h-8 text-sm font-mono"
disabled={saving}
/>
</div>
</div>
)}
{/* Installation hint when not installed - inside card */}
{!status?.geminiCli?.installed && !statusLoading && (
<div className="px-4 pb-4 pt-0 border-t border-border/50">
<button
onClick={() => setShowGeminiHint(!showGeminiHint)}
className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400 hover:underline w-full py-2"
>
{showGeminiHint ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
How to install Gemini CLI
</button>
{showGeminiHint && (
<div className="mt-2 p-3 rounded-md bg-amber-50 dark:bg-amber-900/20 text-sm">
<p className="text-amber-700 dark:text-amber-300 mb-2">
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 block mb-2">
npm install -g @google/gemini-cli
</code>
<a
href="https://github.com/google-gemini/gemini-cli"
target="_blank"
rel="noopener noreferrer"
className="text-amber-700 dark:text-amber-300 hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
View documentation
</a>
</div>
)}
</div>
)}
</div>
{/* OpenCode CLI Provider */}
<div
className={`rounded-lg border transition-colors ${
isOpenCodeEnabled ? 'border-primary border-l-4' : 'border-border'
}`}
>
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<Terminal
className={`w-5 h-5 ${isOpenCodeEnabled ? 'text-primary' : 'text-muted-foreground'}`}
/>
<div>
<div className="flex items-center gap-2">
<p className="font-mono font-medium">opencode</p>
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
FREE
</span>
{status?.opencodeCli?.installed ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
installed
</span>
) : (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
not installed
</span>
)}
</div>
<p className="text-sm text-muted-foreground">
OpenCode (web search via Zen)
</p>
</div>
</div>
<Switch
checked={isOpenCodeEnabled}
onCheckedChange={toggleOpenCode}
disabled={saving || !status?.opencodeCli?.installed}
/>
</div>
{/* Model input when enabled */}
{isOpenCodeEnabled && (
<div className="px-4 pb-4 pt-0">
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground whitespace-nowrap">
Model:
</label>
<Input
value={opencodeModelInput}
onChange={(e) => setOpencodeModelInput(e.target.value)}
onBlur={saveOpencodeModel}
placeholder="opencode/grok-code"
className="h-8 text-sm font-mono"
disabled={saving}
/>
</div>
</div>
)}
{/* Installation hint when not installed - inside card */}
{!status?.opencodeCli?.installed && !statusLoading && (
<div className="px-4 pb-4 pt-0 border-t border-border/50">
<button
onClick={() => setShowOpencodeHint(!showOpencodeHint)}
className="flex items-center gap-2 text-sm text-purple-600 dark:text-purple-400 hover:underline w-full py-2"
>
{showOpencodeHint ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
How to install OpenCode
</button>
{showOpencodeHint && (
<div className="mt-2 p-3 rounded-md bg-purple-50 dark:bg-purple-900/20 text-sm">
<p className="text-purple-700 dark:text-purple-300 mb-2">
Install globally (FREE tier available):
</p>
<code className="text-sm bg-purple-100 dark:bg-purple-900/40 px-2 py-1 rounded font-mono block mb-2">
curl -fsSL https://opencode.ai/install | bash
</code>
<a
href="https://github.com/sst/opencode"
target="_blank"
rel="noopener noreferrer"
className="text-purple-700 dark:text-purple-300 hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
View documentation
</a>
</div>
)}
</div>
)}
</div>
{/* Grok CLI Provider */}
<div
className={`rounded-lg border transition-colors ${
isGrokEnabled ? 'border-primary border-l-4' : 'border-border'
}`}
>
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<Terminal
className={`w-5 h-5 ${isGrokEnabled ? 'text-primary' : 'text-muted-foreground'}`}
/>
<div>
<div className="flex items-center gap-2">
<p className="font-mono font-medium">grok</p>
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 font-medium">
GROK_API_KEY
</span>
{status?.grokCli?.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">
xAI Grok CLI (web + X search)
</p>
</div>
</div>
<Switch
checked={isGrokEnabled}
onCheckedChange={toggleGrok}
disabled={saving || !status?.grokCli?.installed}
/>
</div>
{/* Installation hint when not installed - inside card */}
{!status?.grokCli?.installed && !statusLoading && (
<div className="px-4 pb-4 pt-0 border-t border-border/50">
<button
onClick={() => setShowGrokHint(!showGrokHint)}
className="flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline w-full py-2"
>
{showGrokHint ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
How to install Grok CLI
</button>
{showGrokHint && (
<div className="mt-2 p-3 rounded-md bg-blue-50 dark:bg-blue-900/20 text-sm">
<p className="text-blue-700 dark:text-blue-300 mb-2">
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 block mb-2">
npm install -g @vibe-kit/grok-cli
</code>
<a
href="https://github.com/superagent-ai/grok-cli"
target="_blank"
rel="noopener noreferrer"
className="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>
</ScrollArea>
{/* Footer */}
<div className="p-4 border-t bg-background">
<Button
variant="outline"
size="sm"
onClick={() => {
fetchConfig();
fetchRawConfig();
}}
disabled={loading || saving}
className="w-full"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
</CardContent>
</Card>
</Panel>
{/* Resize Handle */}
<PanelResizeHandle className="w-2 bg-border hover:bg-primary/20 transition-colors cursor-col-resize flex items-center justify-center group">
<GripVertical className="w-3 h-3 text-muted-foreground group-hover:text-primary" />
</PanelResizeHandle>
{/* Right Panel - Config Viewer */}
<Panel defaultSize={60} minSize={35}>
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-4 border-b bg-background flex items-center justify-between">
<div className="flex items-center gap-3">
<FileCode className="w-5 h-5 text-primary" />
<div>
<h2 className="font-semibold">config.yaml</h2>
<p className="text-sm text-muted-foreground">~/.ccs/config.yaml</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={copyToClipboard} disabled={!rawConfig}>
{copied ? (
<>
<Check className="w-4 h-4 mr-1" />
Copied
</>
) : (
<>
<Copy className="w-4 h-4 mr-1" />
Copy
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={fetchRawConfig}
disabled={rawConfigLoading}
>
<RefreshCw className={`w-4 h-4 ${rawConfigLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* Config Content - scrollable */}
<div className="flex-1 overflow-auto">
{rawConfigLoading ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
Loading...
</div>
) : rawConfig ? (
<CodeEditor
value={rawConfig}
onChange={() => {}}
language="yaml"
readonly
minHeight="auto"
className="min-h-full"
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<FileCode className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>Config file not found</p>
<code className="text-sm bg-muted px-2 py-1 rounded mt-2 inline-block">
ccs migrate
</code>
</div>
</div>
)}
</div>
</div>
</Panel>
</PanelGroup>
</div>
);
}