/** * WebSearch MCP installer and ~/.claude.json provisioning. */ import * as fs from 'fs'; import * as path from 'path'; import { getWebSearchConfig } from '../../config/unified-config-loader'; import { getCcsDir } from '../config-manager'; import { getClaudeUserConfigPath } from '../claude-config-path'; import { info, warn } from '../ui'; import { InstanceManager } from '../../management/instance-manager'; import { installWebSearchHook } from './hook-installer'; const WEBSEARCH_MCP_SERVER = 'ccs-websearch-server.cjs'; const WEBSEARCH_MCP_SERVER_NAME = 'ccs-websearch'; interface ClaudeUserConfig { mcpServers?: Record; [key: string]: unknown; } function getCcsMcpDir(): string { return path.join(getCcsDir(), 'mcp'); } export function getWebSearchMcpServerName(): string { return WEBSEARCH_MCP_SERVER_NAME; } export function getWebSearchMcpServerPath(): string { return path.join(getCcsMcpDir(), WEBSEARCH_MCP_SERVER); } function hasMatchingContents(sourcePath: string, destinationPath: string): boolean { if (!fs.existsSync(destinationPath)) { return false; } const source = fs.readFileSync(sourcePath); try { const destination = fs.readFileSync(destinationPath); return source.equals(destination); } catch (error) { if (process.env.CCS_DEBUG) { console.error( warn(`Existing WebSearch MCP server is unreadable: ${(error as Error).message}`) ); } return false; } } function getTempPath(targetPath: string): string { const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; return `${targetPath}.${suffix}.tmp`; } function resolveBundledServerSourcePath(): string | null { const possiblePaths = [ path.join(__dirname, '..', '..', '..', 'lib', 'mcp', WEBSEARCH_MCP_SERVER), path.join(__dirname, '..', '..', 'lib', 'mcp', WEBSEARCH_MCP_SERVER), path.join(__dirname, '..', 'lib', 'mcp', WEBSEARCH_MCP_SERVER), ]; for (const candidate of possiblePaths) { if (fs.existsSync(candidate)) { return candidate; } } return null; } function readClaudeUserConfig(configPath: string): ClaudeUserConfig | null { if (!fs.existsSync(configPath)) { return {}; } try { const raw = fs.readFileSync(configPath, 'utf8'); const parsed = JSON.parse(raw); if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { return null; } return parsed as ClaudeUserConfig; } catch { return null; } } function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): boolean { const tempPath = getTempPath(configPath); try { fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); fs.renameSync(tempPath, configPath); return true; } finally { if (fs.existsSync(tempPath)) { fs.unlinkSync(tempPath); } } } function removeManagedServerConfig(configPath: string): boolean { if (!fs.existsSync(configPath)) { return false; } const config = readClaudeUserConfig(configPath); if (config === null) { if (process.env.CCS_DEBUG) { console.error(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`)); } return false; } const existingServers = config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) ? { ...(config.mcpServers as Record) } : {}; if (!(WEBSEARCH_MCP_SERVER_NAME in existingServers)) { return false; } delete existingServers[WEBSEARCH_MCP_SERVER_NAME]; const nextConfig: ClaudeUserConfig = { ...config }; if (Object.keys(existingServers).length === 0) { delete nextConfig.mcpServers; } else { nextConfig.mcpServers = existingServers; } try { writeClaudeUserConfig(configPath, nextConfig); if (process.env.CCS_DEBUG) { console.error(info(`Removed WebSearch MCP config from ${configPath}`)); } return true; } catch (error) { if (process.env.CCS_DEBUG) { console.error( warn( `Failed to remove WebSearch MCP config from ${configPath}: ${(error as Error).message}` ) ); } return false; } } export function installWebSearchMcpServer(): boolean { const wsConfig = getWebSearchConfig(); if (!wsConfig.enabled) { return false; } if (!installWebSearchHook()) { if (process.env.CCS_DEBUG) { console.error( warn('WebSearch MCP server install skipped because hook runtime is unavailable') ); } return false; } const sourcePath = resolveBundledServerSourcePath(); if (!sourcePath) { if (process.env.CCS_DEBUG) { console.error(warn(`WebSearch MCP server source not found: ${WEBSEARCH_MCP_SERVER}`)); } return false; } const mcpDir = getCcsMcpDir(); if (!fs.existsSync(mcpDir)) { fs.mkdirSync(mcpDir, { recursive: true, mode: 0o700 }); } const serverPath = getWebSearchMcpServerPath(); if (hasMatchingContents(sourcePath, serverPath)) { return true; } const tempPath = getTempPath(serverPath); try { fs.copyFileSync(sourcePath, tempPath); fs.chmodSync(tempPath, 0o755); fs.renameSync(tempPath, serverPath); return true; } catch (error) { if (process.env.CCS_DEBUG) { console.error(warn(`Failed to install WebSearch MCP server: ${(error as Error).message}`)); } return false; } finally { if (fs.existsSync(tempPath)) { fs.unlinkSync(tempPath); } } } export function ensureWebSearchMcpConfig(): boolean { const wsConfig = getWebSearchConfig(); if (!wsConfig.enabled) { return false; } const claudeUserConfigPath = getClaudeUserConfigPath(); const claudeUserConfigDir = path.dirname(claudeUserConfigPath); const config = readClaudeUserConfig(claudeUserConfigPath); if (config === null) { if (process.env.CCS_DEBUG) { console.error(warn('Malformed ~/.claude.json prevents WebSearch MCP provisioning')); } return false; } if (!fs.existsSync(claudeUserConfigDir)) { fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 }); } const existingServers = config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) ? (config.mcpServers as Record) : {}; const desiredServerConfig = { command: 'node', args: [getWebSearchMcpServerPath()], }; const currentConfig = existingServers[WEBSEARCH_MCP_SERVER_NAME]; if ( typeof currentConfig === 'object' && currentConfig !== null && JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig) ) { return true; } const nextConfig: ClaudeUserConfig = { ...config, mcpServers: { ...existingServers, [WEBSEARCH_MCP_SERVER_NAME]: desiredServerConfig, }, }; try { writeClaudeUserConfig(claudeUserConfigPath, nextConfig); if (process.env.CCS_DEBUG) { console.error(info(`Ensured WebSearch MCP config in ${claudeUserConfigPath}`)); } return true; } catch (error) { if (process.env.CCS_DEBUG) { console.error(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`)); } return false; } } export function ensureWebSearchMcp(): boolean { const wsConfig = getWebSearchConfig(); if (!wsConfig.enabled) { return false; } return installWebSearchMcpServer() && ensureWebSearchMcpConfig(); } export function syncWebSearchMcpToConfigDir(claudeConfigDir: string | undefined): boolean { if (!claudeConfigDir) { return false; } return new InstanceManager().syncMcpServers(claudeConfigDir); } export function uninstallWebSearchMcpServer(): boolean { const serverPath = getWebSearchMcpServerPath(); if (!fs.existsSync(serverPath)) { return false; } try { fs.unlinkSync(serverPath); return true; } catch (error) { if (process.env.CCS_DEBUG) { console.error(warn(`Failed to remove WebSearch MCP server: ${(error as Error).message}`)); } return false; } } export function removeWebSearchMcpConfig(): boolean { let removed = removeManagedServerConfig(getClaudeUserConfigPath()); const instanceManager = new InstanceManager(); for (const instanceName of instanceManager.listInstances()) { const instancePath = instanceManager.getInstancePath(instanceName); const instanceClaudeConfigPath = path.join(instancePath, '.claude.json'); removed = removeManagedServerConfig(instanceClaudeConfigPath) || removed; } return removed; } export function uninstallWebSearchMcp(): boolean { const removedConfig = removeWebSearchMcpConfig(); const removedServer = uninstallWebSearchMcpServer(); return removedConfig || removedServer; } export function ensureWebSearchMcpOrThrow(): void { const wsConfig = getWebSearchConfig(); if (!wsConfig.enabled) { return; } if (!ensureWebSearchMcp()) { throw new Error('WebSearch is enabled, but CCS could not prepare the local WebSearch tool.'); } }