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: