diff --git a/ccw/frontend/src/components/mcp/CrossCliSyncPanel.tsx b/ccw/frontend/src/components/mcp/CrossCliSyncPanel.tsx index cd37040e1..641d60f26 100644 --- a/ccw/frontend/src/components/mcp/CrossCliSyncPanel.tsx +++ b/ccw/frontend/src/components/mcp/CrossCliSyncPanel.tsx @@ -324,7 +324,9 @@ export function CrossCliSyncPanel({ onSuccess, className }: CrossCliSyncPanelPro )}

- {server.displayText} + {isHttpMcpServer(server) + ? server.url + : (isStdioMcpServer(server) ? server.command : '')}

diff --git a/ccw/frontend/src/lib/api.mcp.test.ts b/ccw/frontend/src/lib/api.mcp.test.ts index 708b36326..6f9781308 100644 --- a/ccw/frontend/src/lib/api.mcp.test.ts +++ b/ccw/frontend/src/lib/api.mcp.test.ts @@ -9,6 +9,7 @@ import { crossCliCopy, fetchAllProjects, fetchOtherProjectsServers, + isStdioMcpServer, type McpServer, } from './api'; @@ -64,11 +65,14 @@ describe('MCP API (frontend ↔ backend contract)', () => { expect(global1?.scope).toBe('global'); const projOnly = result.project[0]; - expect(projOnly?.command).toBe('node'); + expect(isStdioMcpServer(projOnly)).toBe(true); + if (isStdioMcpServer(projOnly)) { + expect(projOnly.command).toBe('node'); + expect(projOnly.env).toEqual({ A: '1' }); + expect(projOnly.args).toEqual(['x']); + } expect(projOnly?.enabled).toBe(true); expect(projOnly?.scope).toBe('project'); - expect(projOnly?.env).toEqual({ A: '1' }); - expect(projOnly?.args).toEqual(['x']); }); it('toggleMcpServer uses /api/mcp-toggle with { projectPath, serverName, enable }', async () => { @@ -150,6 +154,7 @@ describe('MCP API (frontend ↔ backend contract)', () => { const inputServer: McpServer = { name: 's1', + transport: 'stdio', command: 'node', args: ['a'], env: { K: 'V' }, @@ -290,4 +295,3 @@ describe('MCP API (frontend ↔ backend contract)', () => { expect(res.servers['D:/a']?.[0]?.enabled).toBe(false); }); }); - diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index 3c301beb7..3407492d8 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -3507,7 +3507,8 @@ function requireProjectPath(projectPath: string | undefined, ctx: string): strin function toServerConfig(server: Partial): UnknownRecord { // Check if this is an HTTP server if (server.transport === 'http') { - const config: UnknownRecord = { url: server.url }; + const url = 'url' in server && typeof server.url === 'string' ? server.url : ''; + const config: UnknownRecord = { url }; // Claude format: type field config.type = 'http'; @@ -3538,23 +3539,60 @@ function toServerConfig(server: Partial): UnknownRecord { // STDIO server (default) const config: UnknownRecord = {}; - if (typeof server.command === 'string') { - config.command = server.command; - } + if ('command' in server && typeof server.command === 'string') config.command = server.command; + if ('args' in server && Array.isArray(server.args) && server.args.length > 0) config.args = server.args; + if ('env' in server && server.env && Object.keys(server.env).length > 0) config.env = server.env; + if ('cwd' in server && typeof server.cwd === 'string' && server.cwd.trim()) config.cwd = server.cwd; - if (server.args && server.args.length > 0) { - config.args = server.args; - } + return config; +} - if (server.env && Object.keys(server.env).length > 0) { - config.env = server.env; - } +function _buildFallbackServer(serverName: string, config: Partial): McpServer { + const transport = config.transport ?? 'stdio'; + const enabled = config.enabled ?? true; + const scope = config.scope ?? 'project'; - if (server.cwd) { - config.cwd = server.cwd; + if (transport === 'http') { + const url = 'url' in config && typeof config.url === 'string' ? config.url : ''; + return { + name: serverName, + transport: 'http', + url, + enabled, + scope, + }; } - return config; + const command = + 'command' in config && typeof config.command === 'string' + ? config.command + : ''; + + const args = + 'args' in config && Array.isArray(config.args) + ? config.args + : undefined; + + const env = + 'env' in config && config.env && typeof config.env === 'object' + ? (config.env as Record) + : undefined; + + const cwd = + 'cwd' in config && typeof config.cwd === 'string' + ? config.cwd + : undefined; + + return { + name: serverName, + transport: 'stdio', + command, + args, + env, + cwd, + enabled, + scope, + }; } /** @@ -3572,12 +3610,14 @@ export async function updateMcpServer( // Validate based on transport type if (config.transport === 'http') { - if (typeof config.url !== 'string' || !config.url.trim()) { + const url = 'url' in config ? config.url : undefined; + if (typeof url !== 'string' || !url.trim()) { throw new Error('updateMcpServer: url is required for HTTP servers'); } } else { // STDIO server (default) - if (typeof config.command !== 'string' || !config.command.trim()) { + const command = 'command' in config ? config.command : undefined; + if (typeof command !== 'string' || !command.trim()) { throw new Error('updateMcpServer: command is required for STDIO servers'); } } @@ -3619,26 +3659,13 @@ export async function updateMcpServer( if (options.projectPath) { const servers = await fetchMcpServers(options.projectPath); - return [...servers.project, ...servers.global].find((s) => s.name === serverName) ?? { - name: serverName, - transport: config.transport ?? 'stdio', - ...(config.transport === 'http' ? { url: config.url! } : { command: config.command! }), - args: config.args, - env: config.env, - enabled: config.enabled ?? true, - scope: config.scope, - } as McpServer; + return ( + [...servers.project, ...servers.global].find((s) => s.name === serverName) ?? + _buildFallbackServer(serverName, config) + ); } - return { - name: serverName, - transport: config.transport ?? 'stdio', - ...(config.transport === 'http' ? { url: config.url! } : { command: config.command! }), - args: config.args, - env: config.env, - enabled: config.enabled ?? true, - scope: config.scope, - } as McpServer; + return _buildFallbackServer(serverName, config); } /** @@ -3756,12 +3783,15 @@ export async function toggleMcpServer( } const servers = await fetchMcpServers(projectPath); - return [...servers.project, ...servers.global].find((s) => s.name === serverName) ?? { - name: serverName, - command: '', - enabled, - scope: 'project', - }; + return ( + [...servers.project, ...servers.global].find((s) => s.name === serverName) ?? { + name: serverName, + transport: 'stdio', + command: '', + enabled, + scope: 'project', + } + ); } // ========== Codex MCP API ========== @@ -3769,9 +3799,7 @@ export async function toggleMcpServer( * Codex MCP Server - Read-only server with config path * Extends McpServer with optional configPath field */ -export interface CodexMcpServer extends McpServer { - configPath?: string; -} +export type CodexMcpServer = McpServer & { configPath?: string }; export interface CodexMcpServersResponse { servers: CodexMcpServer[]; @@ -3958,13 +3986,16 @@ export async function fetchOtherProjectsServers( servers[path] = Object.entries(projectServersRecord) // Exclude globally-defined servers; this section is meant for project-local discovery .filter(([name]) => !(name in userServers) && !(name in enterpriseServers)) - .map(([name, raw]) => { + .flatMap(([name, raw]) => { const normalized = normalizeServerConfig(raw); - return { + if (normalized.transport !== 'stdio') return []; + return [{ name, - ...normalized, + command: normalized.command, + args: normalized.args, + env: normalized.env, enabled: !disabledSet.has(name), - }; + }]; }); } @@ -4552,58 +4583,6 @@ export interface CcwMcpConfig { installedScopes: ('global' | 'project')[]; } -/** - * Platform detection for cross-platform MCP config - */ -const isWindows = typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win'); - -/** - * Build CCW MCP server config - */ -function buildCcwMcpServerConfig(config: { - enabledTools?: string[]; - projectRoot?: string; - allowedDirs?: string; - enableSandbox?: boolean; -}): { command: string; args: string[]; env: Record } { - const env: Record = {}; - - // Only use default when enabledTools is undefined (not provided) - // When enabledTools is an empty array, set to empty string to disable all tools - console.log('[buildCcwMcpServerConfig] config.enabledTools:', config.enabledTools); - if (config.enabledTools !== undefined) { - env.CCW_ENABLED_TOOLS = config.enabledTools.join(','); - console.log('[buildCcwMcpServerConfig] Set CCW_ENABLED_TOOLS to:', env.CCW_ENABLED_TOOLS); - } else { - env.CCW_ENABLED_TOOLS = 'write_file,edit_file,read_file,core_memory,ask_question,smart_search'; - console.log('[buildCcwMcpServerConfig] Using default CCW_ENABLED_TOOLS'); - } - - if (config.projectRoot) { - env.CCW_PROJECT_ROOT = config.projectRoot; - } - if (config.allowedDirs) { - env.CCW_ALLOWED_DIRS = config.allowedDirs; - } - if (config.enableSandbox) { - env.CCW_ENABLE_SANDBOX = '1'; - } - - // Cross-platform config - if (isWindows) { - return { - command: 'cmd', - args: ['/c', 'npx', '-y', 'ccw-mcp'], - env - }; - } - return { - command: 'npx', - args: ['-y', 'ccw-mcp'], - env - }; -} - /** * Fetch CCW Tools MCP configuration by checking if ccw-tools server exists */ @@ -4698,13 +4677,14 @@ export async function updateCcwConfig(config: { allowedDirs?: string; enableSandbox?: boolean; }): Promise { - const serverConfig = buildCcwMcpServerConfig(config); - - // Install/update to global config - const result = await addGlobalMcpServer('ccw-tools', serverConfig); - if (!result.success) { - throw new Error(result.error || 'Failed to update CCW config'); - } + const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-install-ccw', { + method: 'POST', + body: JSON.stringify({ + scope: 'global', + env: config, + }), + }); + if (result?.error) throw new Error(result.error || 'Failed to update CCW config'); return fetchCcwMcpConfig(); } @@ -4716,31 +4696,21 @@ export async function installCcwMcp( scope: 'global' | 'project' = 'global', projectPath?: string ): Promise { - const serverConfig = buildCcwMcpServerConfig({ - enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'], - }); + const path = scope === 'project' ? requireProjectPath(projectPath, 'installCcwMcp') : undefined; - if (scope === 'project' && projectPath) { - const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-copy-server', { - method: 'POST', - body: JSON.stringify({ - projectPath, - serverName: 'ccw-tools', - serverConfig, - configType: 'mcp', - }), - }); - if (result?.error) { - throw new Error(result.error || 'Failed to install CCW MCP to project'); - } - } else { - const result = await addGlobalMcpServer('ccw-tools', serverConfig); - if (!result.success) { - throw new Error(result.error || 'Failed to install CCW MCP'); - } - } + const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-install-ccw', { + method: 'POST', + body: JSON.stringify({ + scope, + projectPath: path, + env: { + enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'], + }, + }), + }); + if (result?.error) throw new Error(result.error || `Failed to install CCW MCP (${scope})`); - return fetchCcwMcpConfig(); + return fetchCcwMcpConfig(path); } /** @@ -4811,7 +4781,7 @@ export async function fetchCcwMcpConfigForCodex(): Promise { return { isInstalled: false, enabledTools: [], installedScopes: [] }; } - const env = ccwServer.env || {}; + const env = isStdioMcpServer(ccwServer) ? (ccwServer.env || {}) : {}; // Note: CCW_ENABLED_TOOLS can be empty string (all tools disabled), 'all' (default set), or comma-separated list const enabledToolsStr = env.CCW_ENABLED_TOOLS; let enabledTools: string[]; diff --git a/ccw/frontend/src/pages/McpManagerPage.tsx b/ccw/frontend/src/pages/McpManagerPage.tsx index 655d62125..c0a28c4d6 100644 --- a/ccw/frontend/src/pages/McpManagerPage.tsx +++ b/ccw/frontend/src/pages/McpManagerPage.tsx @@ -57,7 +57,6 @@ import { type McpServer, type McpServerConflict, type CcwMcpConfig, - type HttpMcpServer, isHttpMcpServer, isStdioMcpServer, } from '@/lib/api'; @@ -656,14 +655,31 @@ export function McpManagerPage() { // Template handlers const handleInstallTemplate = (template: any) => { - setEditingServer({ - name: template.name, - command: template.serverConfig.command, - args: template.serverConfig.args || [], - env: template.serverConfig.env, - scope: 'project', - enabled: true, - }); + const serverConfig = template?.serverConfig ?? {}; + const isHttp = + serverConfig.type === 'http' || + serverConfig.transport === 'http' || + typeof serverConfig.url === 'string'; + + if (isHttp) { + setEditingServer({ + name: template.name, + transport: 'http', + url: serverConfig.url ?? '', + scope: 'project', + enabled: true, + }); + } else { + setEditingServer({ + name: template.name, + transport: 'stdio', + command: serverConfig.command, + args: serverConfig.args || [], + env: serverConfig.env, + scope: 'project', + enabled: true, + }); + } setDialogOpen(true); }; @@ -1076,9 +1092,21 @@ export function McpManagerPage() { }} onSave={handleSaveAsTemplate} defaultName={serverToSaveAsTemplate?.name} - defaultCommand={serverToSaveAsTemplate?.command} - defaultArgs={serverToSaveAsTemplate?.args} - defaultEnv={serverToSaveAsTemplate?.env as Record} + defaultCommand={ + serverToSaveAsTemplate && isStdioMcpServer(serverToSaveAsTemplate) + ? serverToSaveAsTemplate.command + : undefined + } + defaultArgs={ + serverToSaveAsTemplate && isStdioMcpServer(serverToSaveAsTemplate) + ? serverToSaveAsTemplate.args + : undefined + } + defaultEnv={ + serverToSaveAsTemplate && isStdioMcpServer(serverToSaveAsTemplate) + ? (serverToSaveAsTemplate.env as Record) + : undefined + } /> ); diff --git a/ccw/src/commands/view.ts b/ccw/src/commands/view.ts index 7a855db3b..49a0711ec 100644 --- a/ccw/src/commands/view.ts +++ b/ccw/src/commands/view.ts @@ -22,42 +22,81 @@ interface SwitchWorkspaceResult { * Provides better error messages when response is not JSON (e.g., proxy errors) */ async function safeParseJson(response: Response, endpoint: string): Promise { - const contentType = response.headers.get('content-type'); + const contentType = response.headers.get('content-type') || 'unknown'; + const isJson = contentType.includes('application/json'); - // Check if response is JSON - if (!contentType?.includes('application/json')) { - // Get response text for error message (truncated to avoid huge output) + const truncate = (text: string, maxLen: number = 200): string => { + const trimmed = text.trim(); + if (trimmed.length <= maxLen) return trimmed; + return `${trimmed.slice(0, maxLen)}…`; + }; + + const isApiKeyProxyMessage = (text: string): boolean => { + return /APIKEY|api\s*key|apiKey/i.test(text); + }; + + // If response claims to not be JSON, surface a helpful error with a preview. + if (!isJson) { const text = await response.text(); - const preview = text.substring(0, 200); + const preview = truncate(text); - // Detect common proxy errors - if (text.includes('APIKEY') || text.includes('api key') || text.includes('apiKey')) { + if (isApiKeyProxyMessage(text)) { throw new Error( - `Request to ${endpoint} was intercepted by a proxy requiring API key. ` + + `Request to ${endpoint} was intercepted by a proxy requiring an API key. ` + `Check HTTP_PROXY/HTTPS_PROXY environment variables. ` + `Response: ${preview}` ); } throw new Error( - `Unexpected response from ${endpoint} (expected JSON, got: ${contentType || 'unknown'}). ` + + `Unexpected response from ${endpoint} (expected JSON, got: ${contentType}). ` + `This may indicate a proxy or network issue. Response: ${preview}` ); } - // Check for HTTP errors + // Read text once so we can provide good errors even when JSON parsing fails. + const text = await response.text(); + const preview = truncate(text); + + // Check for HTTP errors first; try to parse error JSON if possible. if (!response.ok) { + if (isApiKeyProxyMessage(text)) { + throw new Error( + `Request to ${endpoint} was intercepted by a proxy requiring an API key. ` + + `Check HTTP_PROXY/HTTPS_PROXY environment variables. ` + + `Response: ${preview}` + ); + } + let errorMessage = response.statusText; - try { - const body = await response.json() as { error?: string; message?: string }; - errorMessage = body.error || body.message || response.statusText; - } catch { - // Ignore JSON parse errors for error response + if (text.trim()) { + try { + const body = JSON.parse(text) as { error?: string; message?: string }; + errorMessage = body.error || body.message || response.statusText; + } catch { + errorMessage = `${response.statusText} (invalid JSON body)`; + } } - throw new Error(`HTTP ${response.status}: ${errorMessage}`); + + throw new Error(`HTTP ${response.status}: ${errorMessage}${preview ? `. Response: ${preview}` : ''}`); } - return response.json() as Promise; + try { + return JSON.parse(text) as T; + } catch { + if (isApiKeyProxyMessage(text)) { + throw new Error( + `Request to ${endpoint} was intercepted by a proxy requiring an API key. ` + + `Check HTTP_PROXY/HTTPS_PROXY environment variables. ` + + `Response: ${preview}` + ); + } + + throw new Error( + `Unexpected response from ${endpoint} (invalid JSON despite content-type: ${contentType}). ` + + `This may indicate a proxy or network issue. Response: ${preview}` + ); + } } /** diff --git a/ccw/src/core/routes/mcp-routes.ts b/ccw/src/core/routes/mcp-routes.ts index 0f9d9a099..447af46d7 100644 --- a/ccw/src/core/routes/mcp-routes.ts +++ b/ccw/src/core/routes/mcp-routes.ts @@ -1210,40 +1210,68 @@ export async function handleMcpRoutes(ctx: RouteContext): Promise { return true; } - // API: Install CCW MCP server to project + // API: Install CCW MCP server (global or project scope) if (pathname === '/api/mcp-install-ccw' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { if (!isRecord(body)) { return { error: 'Invalid request body', status: 400 }; } - const projectPath = body.projectPath; - if (typeof projectPath !== 'string' || !projectPath.trim()) { - return { error: 'projectPath is required', status: 400 }; + const rawScope = body.scope; + const scope = rawScope === 'global' || rawScope === 'project' ? rawScope : undefined; + + // Backward compatibility: allow legacy fields at top-level as well as body.env + const envInput = (isRecord(body.env) ? body.env : body) as Record; + + const projectPath = typeof body.projectPath === 'string' ? body.projectPath : undefined; + + const enableSandbox = envInput.enableSandbox === true; + + const enabledToolsRaw = envInput.enabledTools; + let enabledToolsEnv: string; + if (enabledToolsRaw === undefined || enabledToolsRaw === null) { + enabledToolsEnv = 'write_file,edit_file,read_file,core_memory,ask_question,smart_search'; + } else if (Array.isArray(enabledToolsRaw)) { + enabledToolsEnv = enabledToolsRaw.filter((t): t is string => typeof t === 'string').join(','); + } else if (typeof enabledToolsRaw === 'string') { + enabledToolsEnv = enabledToolsRaw; + } else { + enabledToolsEnv = 'write_file,edit_file,read_file,core_memory,ask_question,smart_search'; } - // Check if sandbox should be enabled - const enableSandbox = body.enableSandbox === true; + const projectRoot = typeof envInput.projectRoot === 'string' ? envInput.projectRoot : undefined; - // Parse enabled tools from request body - const enabledTools = Array.isArray(body.enabledTools) && body.enabledTools.length > 0 - ? (body.enabledTools as string[]).join(',') - : 'write_file,edit_file,read_file,core_memory,ask_question,smart_search'; + const allowedDirsRaw = envInput.allowedDirs; + let allowedDirsEnv: string | undefined; + if (Array.isArray(allowedDirsRaw)) { + allowedDirsEnv = allowedDirsRaw.filter((d): d is string => typeof d === 'string').join(','); + } else if (typeof allowedDirsRaw === 'string') { + allowedDirsEnv = allowedDirsRaw; + } - // Generate CCW MCP server config - // Use cmd /c on Windows to inherit Claude Code's working directory + // Generate CCW MCP server config using *server-side* platform detection. + // On WSL/Linux, this ensures we produce `npx -y ccw-mcp` even when the browser runs on Windows. const isWin = process.platform === 'win32'; + const env: Record = { CCW_ENABLED_TOOLS: enabledToolsEnv }; + if (projectRoot) env.CCW_PROJECT_ROOT = projectRoot; + if (allowedDirsEnv) env.CCW_ALLOWED_DIRS = allowedDirsEnv; + if (enableSandbox) env.CCW_ENABLE_SANDBOX = '1'; + const ccwMcpConfig: Record = { - command: isWin ? "cmd" : "npx", - args: isWin ? ["/c", "npx", "-y", "ccw-mcp"] : ["-y", "ccw-mcp"], - env: { - CCW_ENABLED_TOOLS: enabledTools, - ...(enableSandbox && { CCW_ENABLE_SANDBOX: "1" }) - } + command: isWin ? 'cmd' : 'npx', + args: isWin ? ['/c', 'npx', '-y', 'ccw-mcp'] : ['-y', 'ccw-mcp'], + env }; - // Use existing addMcpServerToProject to install CCW MCP - return addMcpServerToProject(projectPath, 'ccw-tools', ccwMcpConfig); + const resolvedScope: 'global' | 'project' = scope ?? (projectPath ? 'project' : 'global'); + if (resolvedScope === 'project') { + if (!projectPath || !projectPath.trim()) { + return { error: 'projectPath is required for project scope', status: 400 }; + } + return addMcpServerToProject(projectPath, 'ccw-tools', ccwMcpConfig); + } + + return addGlobalMcpServer('ccw-tools', ccwMcpConfig); }); return true; } diff --git a/codex-lens/pyproject.toml b/codex-lens/pyproject.toml index f6e6ecafe..4e81ddf4e 100644 --- a/codex-lens/pyproject.toml +++ b/codex-lens/pyproject.toml @@ -14,6 +14,7 @@ authors = [ ] dependencies = [ "typer~=0.9.0", + "click>=8.0.0,<9", "rich~=13.0.0", "pydantic~=2.0.0", "tree-sitter~=0.20.0", @@ -31,7 +32,7 @@ dependencies = [ # Semantic search using fastembed (ONNX-based, lightweight ~200MB) semantic = [ "numpy~=1.26.0", - "fastembed~=0.2.0", + "fastembed~=0.2.1", "hnswlib~=0.8.0", ] @@ -39,7 +40,7 @@ semantic = [ # Install with: pip install codexlens[semantic-gpu] semantic-gpu = [ "numpy~=1.26.0", - "fastembed~=0.2.0", + "fastembed~=0.2.1", "hnswlib~=0.8.0", "onnxruntime-gpu~=1.15.0", # CUDA support ] @@ -48,7 +49,7 @@ semantic-gpu = [ # Install with: pip install codexlens[semantic-directml] semantic-directml = [ "numpy~=1.26.0", - "fastembed~=0.2.0", + "fastembed~=0.2.1", "hnswlib~=0.8.0", "onnxruntime-directml~=1.15.0", # DirectML support ] @@ -105,10 +106,13 @@ lsp = [ ] [project.scripts] -codexlens-lsp = "codexlens.lsp:main" +codexlens-lsp = "codexlens.lsp.server:main" [project.urls] Homepage = "https://github.com/openai/codex-lens" [tool.setuptools] package-dir = { "" = "src" } + +[tool.setuptools.package-data] +"codexlens.lsp" = ["lsp-servers.json"] diff --git a/codex-lens/src/codexlens/api/file_context.py b/codex-lens/src/codexlens/api/file_context.py index 6e1f9408d..fafa209f8 100644 --- a/codex-lens/src/codexlens/api/file_context.py +++ b/codex-lens/src/codexlens/api/file_context.py @@ -216,6 +216,7 @@ def _detect_language(file_path: Path) -> str: ".go": "go", ".rs": "rust", ".java": "java", + ".swift": "swift", ".c": "c", ".cpp": "cpp", ".h": "c", diff --git a/codex-lens/src/codexlens/cli/commands.py b/codex-lens/src/codexlens/cli/commands.py index 8435c2991..26e06e9f6 100644 --- a/codex-lens/src/codexlens/cli/commands.py +++ b/codex-lens/src/codexlens/cli/commands.py @@ -79,6 +79,46 @@ def _parse_languages(raw: Optional[List[str]]) -> Optional[List[str]]: return langs or None +def _fail_mutually_exclusive(option_a: str, option_b: str, json_mode: bool) -> None: + msg = f"Options {option_a} and {option_b} are mutually exclusive." + if json_mode: + print_json(success=False, error=msg) + else: + console.print(f"[red]Error:[/red] {msg}") + raise typer.Exit(code=1) + + +def _extract_embedding_error(embed_result: Dict[str, Any]) -> str: + """Best-effort error extraction for embedding generation results.""" + raw_error = embed_result.get("error") + if isinstance(raw_error, str) and raw_error.strip(): + return raw_error.strip() + + result = embed_result.get("result") + if isinstance(result, dict): + details = result.get("details") + if isinstance(details, list): + collected: List[str] = [] + for item in details: + if not isinstance(item, dict): + continue + item_error = item.get("error") + if isinstance(item_error, str) and item_error.strip(): + collected.append(item_error.strip()) + + if collected: + # De-dupe while preserving order, then keep output short. + seen: set[str] = set() + unique: List[str] = [] + for err in collected: + if err not in seen: + seen.add(err) + unique.append(err) + return "; ".join(unique[:3]) + + return "Embedding generation failed (no error details provided)" + + def _get_index_root() -> Path: """Get the index root directory from config or default. @@ -126,16 +166,26 @@ def index_init( no_embeddings: bool = typer.Option(False, "--no-embeddings", help="Skip automatic embedding generation (if semantic deps installed)."), backend: Optional[str] = typer.Option(None, "--backend", "-b", help="Embedding backend: fastembed (local) or litellm (remote API). Defaults to settings.json config."), model: Optional[str] = typer.Option(None, "--model", "-m", help="Embedding model: profile name for fastembed or model name for litellm. Defaults to settings.json config."), - use_astgrep: Optional[bool] = typer.Option( - None, - "--use-astgrep/--no-use-astgrep", + use_astgrep: bool = typer.Option( + False, + "--use-astgrep", help="Prefer ast-grep parsers when available (experimental). Overrides settings.json config.", ), - static_graph: Optional[bool] = typer.Option( - None, - "--static-graph/--no-static-graph", + no_use_astgrep: bool = typer.Option( + False, + "--no-use-astgrep", + help="Disable ast-grep parsers. Overrides settings.json config.", + ), + static_graph: bool = typer.Option( + False, + "--static-graph", help="Persist global relationships during indexing for static graph expansion. Overrides settings.json config.", ), + no_static_graph: bool = typer.Option( + False, + "--no-static-graph", + help="Disable persisting global relationships. Overrides settings.json config.", + ), static_graph_types: Optional[str] = typer.Option( None, "--static-graph-types", @@ -171,10 +221,19 @@ def index_init( config.load_settings() # Ensure settings are loaded # Apply CLI overrides for parsing/indexing behavior - if use_astgrep is not None: - config.use_astgrep = bool(use_astgrep) - if static_graph is not None: - config.static_graph_enabled = bool(static_graph) + if use_astgrep and no_use_astgrep: + _fail_mutually_exclusive("--use-astgrep", "--no-use-astgrep", json_mode) + if use_astgrep: + config.use_astgrep = True + elif no_use_astgrep: + config.use_astgrep = False + + if static_graph and no_static_graph: + _fail_mutually_exclusive("--static-graph", "--no-static-graph", json_mode) + if static_graph: + config.static_graph_enabled = True + elif no_static_graph: + config.static_graph_enabled = False if static_graph_types is not None: allowed = {"imports", "inherits", "calls"} parsed = [ @@ -323,10 +382,11 @@ def progress_update(msg: str): console.print(f" Indexes processed: [bold]{embed_data['indexes_successful']}/{embed_data['indexes_processed']}[/bold]") else: if not json_mode: - console.print(f"[yellow]Warning:[/yellow] Embedding generation failed: {embed_result.get('error', 'Unknown error')}") + error_msg = _extract_embedding_error(embed_result) + console.print(f"[yellow]Warning:[/yellow] Embedding generation failed: {error_msg}") result["embeddings"] = { "generated": False, - "error": embed_result.get("error"), + "error": _extract_embedding_error(embed_result), } else: if not json_mode and verbose: @@ -848,12 +908,16 @@ def symbol( @app.command() def inspect( file: Path = typer.Argument(..., exists=True, dir_okay=False, help="File to analyze."), - symbols: bool = typer.Option(True, "--symbols/--no-symbols", help="Show discovered symbols."), + symbols: bool = typer.Option(False, "--symbols", help="Show discovered symbols (default)."), + no_symbols: bool = typer.Option(False, "--no-symbols", help="Hide discovered symbols."), json_mode: bool = typer.Option(False, "--json", help="Output JSON response."), verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."), ) -> None: """Analyze a single file and display symbols.""" _configure_logging(verbose, json_mode) + if symbols and no_symbols: + _fail_mutually_exclusive("--symbols", "--no-symbols", json_mode) + show_symbols = True if (symbols or not no_symbols) else False config = Config.load() factory = ParserFactory(config) @@ -867,7 +931,7 @@ def inspect( if json_mode: print_json(success=True, result=payload) else: - if symbols: + if show_symbols: render_file_inspect(indexed.path, indexed.language, indexed.symbols) else: render_status({"file": indexed.path, "language": indexed.language}) @@ -2690,10 +2754,16 @@ def index_embeddings( json_mode: bool = typer.Option(False, "--json", help="Output JSON response."), verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output."), centralized: bool = typer.Option( - True, - "--centralized/--distributed", - "-c/-d", - help="Use centralized vector storage (default) or distributed per-directory indexes.", + False, + "--centralized", + "-c", + help="Use centralized vector storage (default).", + ), + distributed: bool = typer.Option( + False, + "--distributed", + "-d", + help="Use distributed per-directory indexes.", ), ) -> None: """Generate semantic embeddings for code search. @@ -2730,6 +2800,9 @@ def index_embeddings( codexlens index embeddings ~/projects/my-app --centralized # Centralized vector storage """ _configure_logging(verbose, json_mode) + if centralized and distributed: + _fail_mutually_exclusive("--centralized", "--distributed", json_mode) + use_centralized = not distributed from codexlens.cli.embedding_manager import ( generate_embeddings, @@ -2867,7 +2940,7 @@ def progress_update(msg: str): console.print("[yellow]Cancelled.[/yellow] Use --force to skip this prompt.") raise typer.Exit(code=0) - if centralized: + if use_centralized: # Centralized mode: single HNSW index at project root if not index_root: index_root = index_path.parent if index_path else target_path @@ -2895,7 +2968,7 @@ def progress_update(msg: str): print_json(**result) else: if not result["success"]: - error_msg = result.get("error", "Unknown error") + error_msg = _extract_embedding_error(result) console.print(f"[red]Error:[/red] {error_msg}") # Provide helpful hints @@ -4245,14 +4318,22 @@ def embeddings_generate_deprecated( json_mode: bool = typer.Option(False, "--json", help="Output JSON response."), verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output."), centralized: bool = typer.Option( - True, - "--centralized/--distributed", - "-c/-d", - help="Use centralized vector storage (default) or distributed.", + False, + "--centralized", + "-c", + help="Use centralized vector storage (default).", + ), + distributed: bool = typer.Option( + False, + "--distributed", + "-d", + help="Use distributed per-directory indexes.", ), ) -> None: """[Deprecated] Use 'codexlens index embeddings' instead.""" _deprecated_command_warning("embeddings-generate", "index embeddings") + if centralized and distributed: + _fail_mutually_exclusive("--centralized", "--distributed", json_mode) index_embeddings( path=path, backend=backend, @@ -4263,6 +4344,7 @@ def embeddings_generate_deprecated( json_mode=json_mode, verbose=verbose, centralized=centralized, + distributed=distributed, ) diff --git a/codex-lens/src/codexlens/cli/embedding_manager.py b/codex-lens/src/codexlens/cli/embedding_manager.py index 9c68ea1ff..1180252dd 100644 --- a/codex-lens/src/codexlens/cli/embedding_manager.py +++ b/codex-lens/src/codexlens/cli/embedding_manager.py @@ -55,6 +55,11 @@ def is_embedding_backend_available(_backend: str): # type: ignore[no-redef] return False, "codexlens.semantic not available" +try: + from codexlens.semantic.vector_store import VectorStore +except ImportError: # pragma: no cover + VectorStore = None # type: ignore[assignment] + try: from codexlens.config import VECTORS_META_DB_NAME except ImportError: @@ -720,6 +725,11 @@ def generate_embeddings( # effective_batch_size is calculated above (dynamic or EMBEDDING_BATCH_SIZE fallback) try: + if VectorStore is None: + return { + "success": False, + "error": "Semantic search not available (VectorStore import failed). Install with: pip install codexlens[semantic]", + } with VectorStore(index_path) as vector_store: # Check model compatibility with existing embeddings if not force: diff --git a/codex-lens/src/codexlens/config.py b/codex-lens/src/codexlens/config.py index a420c96ae..8548dcfed 100644 --- a/codex-lens/src/codexlens/config.py +++ b/codex-lens/src/codexlens/config.py @@ -80,6 +80,7 @@ class Config: "go": {"extensions": [".go"], "tree_sitter_language": "go", "category": "code"}, "zig": {"extensions": [".zig"], "tree_sitter_language": "zig", "category": "code"}, "objective-c": {"extensions": [".m", ".mm"], "tree_sitter_language": "objc", "category": "code"}, + "swift": {"extensions": [".swift"], "tree_sitter_language": "swift", "category": "code"}, "c": {"extensions": [".c", ".h"], "tree_sitter_language": "c", "category": "code"}, "cpp": {"extensions": [".cc", ".cpp", ".hpp", ".cxx"], "tree_sitter_language": "cpp", "category": "code"}, "rust": {"extensions": [".rs"], "tree_sitter_language": "rust", "category": "code"}, diff --git a/codex-lens/src/codexlens/lsp/lsp-servers.json b/codex-lens/src/codexlens/lsp/lsp-servers.json new file mode 100644 index 000000000..bfc21fb9c --- /dev/null +++ b/codex-lens/src/codexlens/lsp/lsp-servers.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "version": "1.0.0", + "description": "Default language server configuration for codex-lens standalone LSP client", + "servers": [ + { + "languageId": "python", + "displayName": "Pyright", + "extensions": ["py", "pyi"], + "command": ["pyright-langserver", "--stdio"], + "enabled": true, + "initializationOptions": { + "pythonPath": "", + "pythonPlatform": "", + "pythonVersion": "3.13" + }, + "settings": { + "python.analysis": { + "typeCheckingMode": "standard", + "diagnosticMode": "workspace", + "exclude": ["**/node_modules", "**/__pycache__", "build", "dist"], + "include": ["src/**", "tests/**"], + "stubPath": "typings" + } + } + }, + { + "languageId": "typescript", + "displayName": "TypeScript Language Server", + "extensions": ["ts", "tsx"], + "command": ["typescript-language-server", "--stdio"], + "enabled": true, + "initializationOptions": {}, + "settings": {} + }, + { + "languageId": "javascript", + "displayName": "TypeScript Language Server (for JS)", + "extensions": ["js", "jsx", "mjs", "cjs"], + "command": ["typescript-language-server", "--stdio"], + "enabled": true, + "initializationOptions": {}, + "settings": {} + }, + { + "languageId": "go", + "displayName": "Gopls", + "extensions": ["go"], + "command": ["gopls", "serve"], + "enabled": true, + "initializationOptions": {}, + "settings": {} + }, + { + "languageId": "rust", + "displayName": "Rust Analyzer", + "extensions": ["rs"], + "command": ["rust-analyzer"], + "enabled": false, + "initializationOptions": {}, + "settings": {} + }, + { + "languageId": "c", + "displayName": "Clangd", + "extensions": ["c", "h"], + "command": ["clangd"], + "enabled": false, + "initializationOptions": {}, + "settings": {} + }, + { + "languageId": "cpp", + "displayName": "Clangd", + "extensions": ["cpp", "hpp", "cc", "cxx"], + "command": ["clangd"], + "enabled": false, + "initializationOptions": {}, + "settings": {} + } + ], + "defaults": { + "rootDir": ".", + "timeout": 30000, + "restartInterval": 5000, + "maxRestarts": 3 + } +} diff --git a/codex-lens/src/codexlens/lsp/standalone_manager.py b/codex-lens/src/codexlens/lsp/standalone_manager.py index 379915e8e..d2a57de54 100644 --- a/codex-lens/src/codexlens/lsp/standalone_manager.py +++ b/codex-lens/src/codexlens/lsp/standalone_manager.py @@ -14,6 +14,7 @@ from __future__ import annotations import asyncio +import importlib.resources as resources import json import logging import os @@ -117,7 +118,6 @@ def _find_config_file(self) -> Optional[Path]: 1. Explicit config_file parameter 2. {workspace_root}/lsp-servers.json 3. {workspace_root}/.codexlens/lsp-servers.json - 4. Package default (codexlens/lsp-servers.json) """ search_paths = [] @@ -127,7 +127,6 @@ def _find_config_file(self) -> Optional[Path]: search_paths.extend([ self.workspace_root / self.DEFAULT_CONFIG_FILE, self.workspace_root / ".codexlens" / self.DEFAULT_CONFIG_FILE, - Path(__file__).parent.parent.parent.parent / self.DEFAULT_CONFIG_FILE, # package root ]) for path in search_paths: @@ -135,21 +134,73 @@ def _find_config_file(self) -> Optional[Path]: return path return None + + def _load_builtin_config(self) -> Optional[dict[str, Any]]: + """Load the built-in default lsp-servers.json shipped with the package.""" + try: + text = ( + resources.files("codexlens.lsp") + .joinpath(self.DEFAULT_CONFIG_FILE) + .read_text(encoding="utf-8") + ) + except Exception as exc: + logger.error( + "Failed to load built-in %s template from package: %s", + self.DEFAULT_CONFIG_FILE, + exc, + ) + return None + + try: + return json.loads(text) + except Exception as exc: + logger.error( + "Built-in %s template shipped with the package is invalid JSON: %s", + self.DEFAULT_CONFIG_FILE, + exc, + ) + return None def _load_config(self) -> None: """Load language server configuration from JSON file.""" + self._configs.clear() + self._extension_map.clear() + config_path = self._find_config_file() if not config_path: - logger.warning(f"No {self.DEFAULT_CONFIG_FILE} found, using empty config") - return - - try: - with open(config_path, "r", encoding="utf-8") as f: - data = json.load(f) - except Exception as e: - logger.error(f"Failed to load config from {config_path}: {e}") - return + data = self._load_builtin_config() + if data is None: + logger.warning( + "No %s found and built-in defaults could not be loaded; using empty config", + self.DEFAULT_CONFIG_FILE, + ) + return + + root_config_path = self.workspace_root / self.DEFAULT_CONFIG_FILE + codexlens_config_path = ( + self.workspace_root / ".codexlens" / self.DEFAULT_CONFIG_FILE + ) + + logger.info( + "No %s found at %s or %s; using built-in defaults shipped with codex-lens. " + "To customize, copy the template to one of those locations and restart. " + "Language servers are spawned on-demand when first needed. " + "Ensure the language server commands in the config are installed and on PATH.", + self.DEFAULT_CONFIG_FILE, + root_config_path, + codexlens_config_path, + ) + config_source = "built-in defaults" + else: + try: + with open(config_path, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception as e: + logger.error(f"Failed to load config from {config_path}: {e}") + return + + config_source = str(config_path) # Parse defaults defaults = data.get("defaults", {}) @@ -186,7 +237,11 @@ def _load_config(self) -> None: for ext in config.extensions: self._extension_map[ext.lower()] = language_id - logger.info(f"Loaded {len(self._configs)} language server configs from {config_path}") + logger.info( + "Loaded %d language server configs from %s", + len(self._configs), + config_source, + ) def get_language_id(self, file_path: str) -> Optional[str]: """Get language ID for a file based on extension. diff --git a/codex-lens/src/codexlens/search/chain_search.py b/codex-lens/src/codexlens/search/chain_search.py index 835c20cb3..07b71a02a 100644 --- a/codex-lens/src/codexlens/search/chain_search.py +++ b/codex-lens/src/codexlens/search/chain_search.py @@ -3531,8 +3531,20 @@ def _search_parallel(self, index_paths: List[Path], self.logger.debug("Using single-threaded mode for vector search (GPU safety)") # Pre-load embedder to avoid initialization overhead per-search try: - from codexlens.semantic.embedder import get_embedder - get_embedder(profile="code", use_gpu=True) + from codexlens.semantic.factory import get_embedder as get_embedder_factory + + embedding_backend = "fastembed" + embedding_model = "code" + use_gpu = True + if self._config is not None: + embedding_backend = getattr(self._config, "embedding_backend", embedding_backend) or embedding_backend + embedding_model = getattr(self._config, "embedding_model", embedding_model) or embedding_model + use_gpu = bool(getattr(self._config, "embedding_use_gpu", use_gpu)) + + if embedding_backend == "litellm": + get_embedder_factory(backend="litellm", model=embedding_model) + else: + get_embedder_factory(backend="fastembed", profile=embedding_model, use_gpu=use_gpu) except Exception: pass # Ignore pre-load failures diff --git a/codex-lens/tests/lsp/test_packaging_metadata.py b/codex-lens/tests/lsp/test_packaging_metadata.py new file mode 100644 index 000000000..b51d0d502 --- /dev/null +++ b/codex-lens/tests/lsp/test_packaging_metadata.py @@ -0,0 +1,27 @@ +"""Packaging metadata tests for codex-lens (LSP/semantic extras).""" + +from __future__ import annotations + +from pathlib import Path + + +def _read_pyproject() -> str: + repo_root = Path(__file__).resolve().parents[2] + return (repo_root / "pyproject.toml").read_text(encoding="utf-8") + + +def test_lsp_script_entrypoint_points_to_server_main() -> None: + pyproject = _read_pyproject() + assert 'codexlens-lsp = "codexlens.lsp.server:main"' in pyproject + + +def test_semantic_extras_do_not_pin_yanked_fastembed_020() -> None: + pyproject = _read_pyproject() + assert "fastembed~=0.2.0" not in pyproject + assert "fastembed~=0.2.1" in pyproject + + +def test_click_dependency_is_explicitly_guarded() -> None: + pyproject = _read_pyproject() + assert "click>=8.0.0,<9" in pyproject + diff --git a/codex-lens/tests/lsp/test_standalone_manager_defaults.py b/codex-lens/tests/lsp/test_standalone_manager_defaults.py new file mode 100644 index 000000000..fe0a9cb6b --- /dev/null +++ b/codex-lens/tests/lsp/test_standalone_manager_defaults.py @@ -0,0 +1,31 @@ +"""Tests for StandaloneLspManager default config behavior.""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +import pytest + +from codexlens.lsp.standalone_manager import StandaloneLspManager + + +def test_loads_builtin_defaults_when_no_config_found( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + manager = StandaloneLspManager(workspace_root=str(tmp_path)) + + with caplog.at_level(logging.INFO): + asyncio.run(manager.start()) + + assert manager._configs # type: ignore[attr-defined] + assert manager.get_language_id(str(tmp_path / "example.py")) == "python" + + expected_root = str(tmp_path / "lsp-servers.json") + expected_codexlens = str(tmp_path / ".codexlens" / "lsp-servers.json") + + assert "using built-in defaults" in caplog.text.lower() + assert expected_root in caplog.text + assert expected_codexlens in caplog.text + diff --git a/codex-lens/tests/test_chain_search.py b/codex-lens/tests/test_chain_search.py index 09e4b1665..de46fe41a 100644 --- a/codex-lens/tests/test_chain_search.py +++ b/codex-lens/tests/test_chain_search.py @@ -155,3 +155,37 @@ def test_cascade_search_invalid_strategy(temp_paths: Path) -> None: engine.cascade_search("query", source_path, strategy="invalid_strategy") mock_binary.assert_called_once() + +def test_vector_warmup_uses_embedding_config(monkeypatch: pytest.MonkeyPatch, temp_paths: Path) -> None: + calls: list[dict[str, object]] = [] + + def fake_get_embedder(**kwargs: object) -> object: + calls.append(dict(kwargs)) + return object() + + import codexlens.semantic.factory as factory + + monkeypatch.setattr(factory, "get_embedder", fake_get_embedder) + + registry = RegistryStore(db_path=temp_paths / "registry.db") + registry.initialize() + mapper = PathMapper(index_root=temp_paths / "indexes") + config = Config( + data_dir=temp_paths / "data", + embedding_backend="fastembed", + embedding_model="fast", + embedding_use_gpu=False, + ) + + engine = ChainSearchEngine(registry, mapper, config=config) + monkeypatch.setattr(engine, "_get_executor", lambda _workers: MagicMock()) + + engine._search_parallel([], "query", SearchOptions(enable_vector=True)) + + assert calls == [ + { + "backend": "fastembed", + "profile": "fast", + "use_gpu": False, + } + ] diff --git a/codex-lens/tests/test_cli_help.py b/codex-lens/tests/test_cli_help.py new file mode 100644 index 000000000..dd51f64f4 --- /dev/null +++ b/codex-lens/tests/test_cli_help.py @@ -0,0 +1,61 @@ +"""Smoke tests for CodexLens CLI help output. + +These tests ensure that help text generation does not crash at import time +or during Click/Typer option parsing. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +from typer.testing import CliRunner + + +def _subprocess_env() -> dict[str, str]: + env = os.environ.copy() + codex_lens_root = Path(__file__).resolve().parents[1] + src_dir = codex_lens_root / "src" + existing = env.get("PYTHONPATH", "") + env["PYTHONPATH"] = str(src_dir) + (os.pathsep + existing if existing else "") + return env + + +def test_python_module_help_does_not_crash() -> None: + proc = subprocess.run( + [sys.executable, "-m", "codexlens", "--help"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=_subprocess_env(), + ) + assert proc.returncode == 0, proc.stderr + assert "Traceback" not in (proc.stderr or "") + + +def test_typer_app_help_does_not_crash() -> None: + from codexlens.cli.commands import app + + runner = CliRunner() + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0, result.output + + +def test_extract_embedding_error_uses_details() -> None: + from codexlens.cli.commands import _extract_embedding_error + + embed_result = { + "success": False, + "result": { + "details": [ + {"index_path": "/tmp/a/_index.db", "success": False, "error": "Backend timeout"}, + {"index_path": "/tmp/b/_index.db", "success": False, "error": "Rate limit"}, + ] + }, + } + msg = _extract_embedding_error(embed_result) + assert "Unknown error" not in msg + assert "Backend timeout" in msg diff --git a/codex-lens/tests/test_config.py b/codex-lens/tests/test_config.py index 96b7e7b50..d6acb3fae 100644 --- a/codex-lens/tests/test_config.py +++ b/codex-lens/tests/test_config.py @@ -180,9 +180,18 @@ def test_supported_languages(self): assert "typescript" in config.supported_languages assert "java" in config.supported_languages assert "go" in config.supported_languages + assert "swift" in config.supported_languages finally: del os.environ["CODEXLENS_DATA_DIR"] + def test_language_for_path_swift(self): + """Swift (.swift) files should be recognized as code.""" + with tempfile.TemporaryDirectory() as tmpdir: + config = Config(data_dir=Path(tmpdir)) + assert config.language_for_path("x.swift") == "swift" + assert config.language_for_path("X.SWIFT") == "swift" + assert config.category_for_path("x.swift") == "code" + def test_cache_dir_property(self): """Test cache_dir property.""" with tempfile.TemporaryDirectory() as tmpdir: