feat: add first-class CCS WebSearch runtime

This commit is contained in:
Tam Nhu Tran committed 2026-03-30 16:10:39 -04:00
1 parent c45b2a1138
commit e260df7178
26 files changed
+1241 -158

No files matched your search

+5
View File
@@ -25,6 +25,11 @@ export function getClaudeConfigDir(): string {
return getDefaultClaudeConfigDir();
}
/** Resolve user-scope Claude JSON config path (~/.claude.json). */
export function getClaudeUserConfigPath(): string {
return path.join(getCcsHome(), '.claude.json');
}
/** Resolve Claude settings.json path. */
export function getClaudeSettingsPath(): string {
return path.join(getClaudeConfigDir(), 'settings.json');
+26 -16
View File
@@ -1,15 +1,15 @@
/**
* WebSearch Manager - Manages WebSearch hook for CCS
* WebSearch Manager - Manages CCS WebSearch runtime
*
* 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 deterministic local search backends,
* with legacy AI CLI tools kept only as optional fallback.
* CCS exposes a first-class local WebSearch tool for those profiles and keeps
* the legacy hook runtime only as a compatibility fallback.
*
* Simplified Architecture:
* - No MCP dependency for the default path
* - Uses real search providers first (DuckDuckGo, Brave)
* - Keeps Gemini/OpenCode/Grok as compatibility fallback only
* Runtime Architecture:
* - User-scope MCP server in ~/.claude.json for third-party profiles
* - Real search providers first (Exa, Tavily, Brave Search, DuckDuckGo)
* - Gemini/OpenCode/Grok retained as optional legacy fallback
*
* @module utils/websearch-manager
*/
@@ -53,6 +53,23 @@ export {
// Re-export hook environment
export { getWebSearchHookEnv } from './websearch/hook-env';
// Re-export MCP runtime helpers
export {
getWebSearchMcpServerName,
getWebSearchMcpServerPath,
installWebSearchMcpServer,
ensureWebSearchMcpConfig,
ensureWebSearchMcp,
uninstallWebSearchMcpServer,
removeWebSearchMcpConfig,
uninstallWebSearchMcp,
syncWebSearchMcpToConfigDir,
ensureWebSearchMcpOrThrow,
} from './websearch/mcp-installer';
// Re-export Claude launch arg helpers
export { appendThirdPartyWebSearchToolArgs } from './websearch/claude-tool-args';
// Re-export status and readiness functions
export {
getWebSearchCliProviders,
@@ -62,7 +79,7 @@ export {
displayWebSearchStatus,
} from './websearch/status';
// Re-export profile hook injection
// Re-export profile compatibility hook injection
export { ensureProfileHooks, ensureProfileHooksOrThrow } from './websearch/profile-hook-injector';
// Import for local use
@@ -77,11 +94,4 @@ export function clearAllCliCaches(): void {
clearOpenCodeCliCache();
}
// ========== Backward Compatibility Exports ==========
/**
* @deprecated Use installWebSearchHook instead - MCP is no longer used
*/
export function ensureMcpWebSearch(): boolean {
return false;
}
export { ensureWebSearchMcp as ensureMcpWebSearch } from './websearch/mcp-installer';
+51
View File
@@ -0,0 +1,51 @@
/**
* Claude launch argument helpers for third-party WebSearch.
*/
const NATIVE_WEBSEARCH_TOOL = 'WebSearch';
const DISALLOWED_TOOLS_FLAG = '--disallowedTools';
function parseToolValue(rawValue: string): string[] {
return rawValue
.split(',')
.map((value) => value.trim())
.filter((value) => value.length > 0);
}
function hasToolInFlag(args: string[], flag: string, toolName: string): boolean {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) {
for (let cursor = index + 1; cursor < args.length; cursor += 1) {
const value = args[cursor];
if (value.startsWith('--')) {
break;
}
if (parseToolValue(value).includes(toolName)) {
return true;
}
}
continue;
}
if (!arg.startsWith(`${flag}=`)) {
continue;
}
const rawValue = arg.slice(flag.length + 1);
if (parseToolValue(rawValue).includes(toolName)) {
return true;
}
}
return false;
}
export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] {
if (hasToolInFlag(args, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL)) {
return args;
}
return [...args, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL];
}
+3 -2
View File
@@ -1,7 +1,7 @@
/**
* WebSearch Hook Installer
*
* Manages installation and uninstallation of the WebSearch hook.
* Manages installation and uninstallation of the legacy WebSearch hook runtime.
*
* @module utils/websearch/hook-installer
*/
@@ -70,7 +70,8 @@ export function hasWebSearchHook(): boolean {
/**
* Install WebSearch hook to ~/.ccs/hooks/
*
* This hook intercepts WebSearch and executes via Gemini CLI.
* This hook now serves as a compatibility fallback and a shared provider runtime
* for the first-class CCS WebSearch MCP server.
*
* @returns true if hook installed successfully
*/
+18 -1
View File
@@ -50,6 +50,23 @@ export { removeHookConfig } from './hook-config';
// Hook Environment
export { getWebSearchHookEnv } from './hook-env';
// MCP Runtime
export {
getWebSearchMcpServerName,
getWebSearchMcpServerPath,
installWebSearchMcpServer,
ensureWebSearchMcpConfig,
ensureWebSearchMcp,
uninstallWebSearchMcpServer,
removeWebSearchMcpConfig,
uninstallWebSearchMcp,
syncWebSearchMcpToConfigDir,
ensureWebSearchMcpOrThrow,
} from './mcp-installer';
// Claude launch args
export { appendThirdPartyWebSearchToolArgs } from './claude-tool-args';
// Status and Readiness
export {
getWebSearchCliProviders,
@@ -61,5 +78,5 @@ export {
export { WEBSEARCH_API_KEY_PROVIDERS, getWebSearchApiKeyStates } from './provider-secrets';
// Profile Hook Injection
// Profile compatibility hook injection
export { ensureProfileHooks, ensureProfileHooksOrThrow } from './profile-hook-injector';
+329
View File
@@ -0,0 +1,329 @@
/**
* 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<string, unknown>;
[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<string, unknown>) }
: {};
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<string, unknown>)
: {};
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.');
}
}
+5 -3
View File
@@ -1,8 +1,9 @@
/**
* Profile Hook Injector
*
* Injects WebSearch hooks into per-profile settings files.
* This replaces the global ~/.claude/settings.json approach.
* Injects the legacy WebSearch compatibility hook into per-profile settings files.
* The first-class runtime now uses the CCS-managed MCP server; these hooks remain
* for compatibility and migration safety only.
*
* @module utils/websearch/profile-hook-injector
*/
@@ -76,7 +77,8 @@ function migrateGlobalHook(): void {
}
/**
* Ensure WebSearch hook is configured in profile's settings file
* Ensure the legacy WebSearch compatibility hook is configured in a profile's
* settings file when that path is still needed.
*
* @param profileName - Name of the profile (e.g., 'agy', 'gemini', 'glm')
* @returns true if hook is configured (existing or newly added)