feat: add CcwLitellmStatus component for installation management and package discovery utility

- Implemented CcwLitellmStatus component to display installation status and provide install/uninstall actions.
- Integrated hooks for managing installation and uninstallation processes.
- Added package discovery utility to locate local Python packages with environment variable and configuration support.
- Enhanced diagnostics with detailed search results for package paths.
This commit is contained in:
catlog22
2026-02-18 11:16:42 +08:00
parent 5fb0a0dfbc
commit 3e2cb036de
10 changed files with 1597 additions and 349 deletions

View File

@@ -2,14 +2,11 @@
* LiteLLM API Routes Module
* Handles LiteLLM provider management, endpoint configuration, and cache management
*/
import { fileURLToPath } from 'url';
import { dirname, join as pathJoin } from 'path';
import { z } from 'zod';
import { spawn } from 'child_process';
import { getSystemPython } from '../../utils/python-utils.js';
import {
UvManager,
isUvAvailable,
ensureUvInstalled,
createCodexLensUvManager
} from '../../utils/uv-manager.js';
import { ensureLiteLLMEmbedderReady } from '../../tools/codex-lens.js';
@@ -40,11 +37,6 @@ const ModelPoolConfigSchema = z.object({
*/
const ModelPoolConfigUpdateSchema = ModelPoolConfigSchema.partial();
// Get current module path for package-relative lookups
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Package root: routes -> core -> src -> ccw -> package root
const PACKAGE_ROOT = pathJoin(__dirname, '..', '..', '..', '..');
import {
getAllProviders,
@@ -105,24 +97,6 @@ export function clearCcwLitellmStatusCache() {
ccwLitellmStatusCache.timestamp = 0;
}
/**
* Install ccw-litellm using UV package manager
* Delegates to ensureLiteLLMEmbedderReady for consistent dependency handling
* This ensures ccw-litellm installation doesn't break fastembed's onnxruntime dependencies
* @param _packagePath - Ignored, ensureLiteLLMEmbedderReady handles path discovery
* @returns Installation result
*/
async function installCcwLitellmWithUv(_packagePath: string | null): Promise<{ success: boolean; message?: string; error?: string }> {
// Delegate to the robust installation logic in codex-lens.ts
// This ensures consistent dependency handling within the shared venv,
// preventing onnxruntime conflicts that would break fastembed
const result = await ensureLiteLLMEmbedderReady();
if (result.success) {
clearCcwLitellmStatusCache();
}
return result;
}
function sanitizeProviderForResponse(provider: any): any {
if (!provider) return provider;
return {
@@ -877,28 +851,41 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
// Async check - use CodexLens venv Python for reliable detection
try {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
let result: { installed: boolean; version?: string; error?: string } = { installed: false };
// Check ONLY in CodexLens venv (where UV installs packages)
// Do NOT fallback to system pip - we want isolated venv dependencies
const uv = createCodexLensUvManager();
const venvPython = uv.getVenvPython();
const statusTimeout = process.platform === 'win32' ? 15000 : 10000;
if (uv.isVenvValid()) {
try {
const { stdout } = await execAsync(`"${venvPython}" -c "import ccw_litellm; print(ccw_litellm.__version__)"`, {
timeout: 10000,
windowsHide: true,
result = await new Promise<{ installed: boolean; version?: string }>((resolve) => {
const child = spawn(venvPython, ['-c', 'import ccw_litellm; print(ccw_litellm.__version__)'], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: statusTimeout,
windowsHide: true,
});
let stdout = '';
child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
child.on('close', (code: number | null) => {
if (code === 0) {
const version = stdout.trim();
if (version) {
console.log(`[ccw-litellm status] Found in CodexLens venv: ${version}`);
resolve({ installed: true, version });
return;
}
}
console.log('[ccw-litellm status] Not found in CodexLens venv');
resolve({ installed: false });
});
child.on('error', () => {
console.log('[ccw-litellm status] Spawn error checking venv');
resolve({ installed: false });
});
});
const version = stdout.trim();
if (version) {
result = { installed: true, version };
console.log(`[ccw-litellm status] Found in CodexLens venv: ${version}`);
}
} catch (venvErr) {
console.log('[ccw-litellm status] Not found in CodexLens venv');
result = { installed: false };
@@ -1320,95 +1307,19 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
if (pathname === '/api/litellm-api/ccw-litellm/install' && req.method === 'POST') {
handlePostRequest(req, res, async () => {
try {
const { spawn } = await import('child_process');
const path = await import('path');
const fs = await import('fs');
// Delegate entirely to ensureLiteLLMEmbedderReady for consistent installation
// This uses unified package discovery and handles UV → pip fallback
const result = await ensureLiteLLMEmbedderReady();
// Try to find ccw-litellm package in distribution
const possiblePaths = [
path.join(initialPath, 'ccw-litellm'),
path.join(initialPath, '..', 'ccw-litellm'),
path.join(process.cwd(), 'ccw-litellm'),
path.join(PACKAGE_ROOT, 'ccw-litellm'), // npm package internal path
];
let packagePath = '';
for (const p of possiblePaths) {
const pyproject = path.join(p, 'pyproject.toml');
if (fs.existsSync(pyproject)) {
packagePath = p;
break;
}
}
// Priority: Use UV if available
if (await isUvAvailable()) {
const uvResult = await installCcwLitellmWithUv(packagePath || null);
if (uvResult.success) {
// Broadcast installation event
broadcastToClients({
type: 'CCW_LITELLM_INSTALLED',
payload: { timestamp: new Date().toISOString(), method: 'uv' }
});
return { ...uvResult, path: packagePath || undefined };
}
// UV install failed, fall through to pip fallback
console.log('[ccw-litellm install] UV install failed, falling back to pip:', uvResult.error);
}
// Fallback: Use pip for installation
// Use shared Python detection for consistent cross-platform behavior
const pythonCmd = getSystemPython();
if (!packagePath) {
// Try pip install from PyPI as fallback
return new Promise((resolve) => {
const proc = spawn(pythonCmd, ['-m', 'pip', 'install', 'ccw-litellm'], { shell: true, timeout: 300000 });
let output = '';
let error = '';
proc.stdout?.on('data', (data) => { output += data.toString(); });
proc.stderr?.on('data', (data) => { error += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
// Clear status cache after successful installation
clearCcwLitellmStatusCache();
broadcastToClients({
type: 'CCW_LITELLM_INSTALLED',
payload: { timestamp: new Date().toISOString(), method: 'pip' }
});
resolve({ success: true, message: 'ccw-litellm installed from PyPI' });
} else {
resolve({ success: false, error: error || 'Installation failed' });
}
});
proc.on('error', (err) => resolve({ success: false, error: err.message }));
if (result.success) {
clearCcwLitellmStatusCache();
broadcastToClients({
type: 'CCW_LITELLM_INSTALLED',
payload: { timestamp: new Date().toISOString(), method: 'unified' }
});
}
// Install from local package
return new Promise((resolve) => {
const proc = spawn(pythonCmd, ['-m', 'pip', 'install', '-e', packagePath], { shell: true, timeout: 300000 });
let output = '';
let error = '';
proc.stdout?.on('data', (data) => { output += data.toString(); });
proc.stderr?.on('data', (data) => { error += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
// Clear status cache after successful installation
clearCcwLitellmStatusCache();
// Broadcast installation event
broadcastToClients({
type: 'CCW_LITELLM_INSTALLED',
payload: { timestamp: new Date().toISOString(), method: 'pip' }
});
resolve({ success: true, message: 'ccw-litellm installed successfully', path: packagePath });
} else {
resolve({ success: false, error: error || output || 'Installation failed' });
}
});
proc.on('error', (err) => resolve({ success: false, error: err.message }));
});
return result;
} catch (err) {
return { success: false, error: (err as Error).message };
}
@@ -1441,7 +1352,6 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
// Priority 2: Fallback to system pip uninstall
console.log('[ccw-litellm uninstall] Using pip fallback...');
const { spawn } = await import('child_process');
const pythonCmd = getSystemPython();
return new Promise((resolve) => {

View File

@@ -12,11 +12,9 @@
import { z } from 'zod';
import type { ToolSchema, ToolResult } from '../types/tool.js';
import { spawn, execSync, exec } from 'child_process';
import { existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
import { getSystemPython } from '../utils/python-utils.js';
import { existsSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { getSystemPython, parsePythonVersion, isPythonVersionCompatible } from '../utils/python-utils.js';
import { EXEC_TIMEOUTS } from '../utils/exec-constants.js';
import {
UvManager,
@@ -30,94 +28,15 @@ import {
getCodexLensPython,
getCodexLensPip,
} from '../utils/codexlens-path.js';
// Get directory of this module
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Check if a path is inside node_modules (unstable for editable installs)
* Paths inside node_modules will change when npm reinstalls packages,
* breaking editable (-e) pip installs that reference them.
*/
function isInsideNodeModules(pathToCheck: string): boolean {
const normalizedPath = pathToCheck.replace(/\\/g, '/').toLowerCase();
return normalizedPath.includes('/node_modules/');
}
/**
* Check if we're running in a development environment (not from node_modules)
* Also detects Yarn PnP (Plug'n'Play) which doesn't use node_modules.
*/
function isDevEnvironment(): boolean {
// Yarn PnP detection: if pnp version exists, it's a managed production environment
if ((process.versions as any).pnp) {
return false;
}
return !isInsideNodeModules(__dirname);
}
/**
* Find valid local package path for development installs.
* Returns null if running from node_modules (should use PyPI instead).
*
* IMPORTANT: When running from node_modules, local paths are unstable
* because npm reinstall will delete and recreate the node_modules directory,
* breaking any editable (-e) pip installs that reference them.
*/
function findLocalPackagePath(packageName: string): string | null {
// Always try to find local paths first, even when running from node_modules.
// codex-lens is a local development package not published to PyPI,
// so we must find it locally regardless of execution context.
const possiblePaths = [
join(process.cwd(), packageName),
join(__dirname, '..', '..', '..', packageName), // ccw/src/tools -> project root
join(homedir(), packageName),
];
// Also check common workspace locations
const cwd = process.cwd();
const cwdParent = dirname(cwd);
if (cwdParent !== cwd) {
possiblePaths.push(join(cwdParent, packageName));
}
// First pass: prefer non-node_modules paths (development environment)
for (const localPath of possiblePaths) {
if (isInsideNodeModules(localPath)) {
continue;
}
if (existsSync(join(localPath, 'pyproject.toml'))) {
console.log(`[CodexLens] Found local ${packageName} at: ${localPath}`);
return localPath;
}
}
// Second pass: allow node_modules paths (NPM global install)
for (const localPath of possiblePaths) {
if (existsSync(join(localPath, 'pyproject.toml'))) {
console.log(`[CodexLens] Found ${packageName} in node_modules at: ${localPath}`);
return localPath;
}
}
return null;
}
/**
* Find valid local codex-lens package path for development installs.
*/
function findLocalCodexLensPath(): string | null {
return findLocalPackagePath('codex-lens');
}
/**
* Find valid local ccw-litellm package path for development installs.
*/
function findLocalCcwLitellmPath(): string | null {
return findLocalPackagePath('ccw-litellm');
}
import {
findCodexLensPath,
findCcwLitellmPath,
formatSearchResults,
isDevEnvironment,
isInsideNodeModules,
type PackageDiscoveryResult,
type SearchAttempt,
} from '../utils/package-discovery.js';
// Bootstrap status cache
let bootstrapChecked = false;
@@ -143,6 +62,62 @@ const SEMANTIC_STATUS_TTL = 5 * 60 * 1000; // 5 minutes TTL
let currentIndexingProcess: ReturnType<typeof spawn> | null = null;
let currentIndexingAborted = false;
// Spawn timeout for checkVenvStatus (Windows cold start is slower)
const VENV_CHECK_TIMEOUT = process.platform === 'win32' ? 15000 : 10000;
/**
* Pre-flight check: verify Python 3.9+ is available before attempting bootstrap.
* Returns an error message if Python is not suitable, or null if OK.
*/
function preFlightCheck(): string | null {
try {
const pythonCmd = getSystemPython();
const version = execSync(`${pythonCmd} --version 2>&1`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUTS.PYTHON_VERSION,
}).trim();
const parsed = parsePythonVersion(version);
if (!parsed) {
return `Cannot parse Python version from: "${version}". Ensure Python 3.9+ is installed.`;
}
if (parsed.major !== 3 || parsed.minor < 9) {
return `Python ${parsed.major}.${parsed.minor} found, but 3.9+ is required. Install Python 3.9-3.12 or set CCW_PYTHON.`;
}
return null;
} catch (err) {
return `Python not found: ${(err as Error).message}. Install Python 3.9-3.12 and ensure it is in PATH.`;
}
}
/**
* Detect and repair a corrupted venv.
* A venv is considered corrupted if the directory exists but the Python executable is missing.
* @returns true if venv was repaired (deleted), false if no repair needed
*/
function repairVenvIfCorrupted(): boolean {
const venvPath = getCodexLensVenvDir();
if (!existsSync(venvPath)) {
return false; // No venv at all — nothing to repair
}
const pythonPath = getCodexLensPython();
if (existsSync(pythonPath)) {
return false; // Venv looks healthy
}
// Venv dir exists but python is missing — corrupted
console.warn(`[CodexLens] Corrupted venv detected: ${venvPath} exists but Python executable missing. Removing for recreation...`);
try {
rmSync(venvPath, { recursive: true, force: true });
clearVenvStatusCache();
console.log('[CodexLens] Corrupted venv removed successfully.');
return true;
} catch (err) {
console.error(`[CodexLens] Failed to remove corrupted venv: ${(err as Error).message}`);
return false;
}
}
// Define Zod schema for validation
const ParamsSchema = z.object({
action: z.enum([
@@ -194,6 +169,15 @@ interface BootstrapResult {
success: boolean;
error?: string;
message?: string;
warnings?: string[];
diagnostics?: {
pythonVersion?: string;
venvPath?: string;
packagePath?: string;
installer?: 'uv' | 'pip';
editable?: boolean;
searchedPaths?: SearchAttempt[];
};
}
interface ExecuteResult {
@@ -276,7 +260,7 @@ async function checkVenvStatus(force = false): Promise<ReadyStatus> {
return new Promise((resolve) => {
const child = spawn(pythonPath, ['-c', 'import sys; import codexlens; import watchdog; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"); print(codexlens.__version__)'], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10000,
timeout: VENV_CHECK_TIMEOUT,
});
let stdout = '';
@@ -476,13 +460,16 @@ async function ensureLiteLLMEmbedderReady(): Promise<BootstrapResult> {
});
if (importStatus.ok) {
return { success: true };
return { success: true, diagnostics: { venvPath: getCodexLensVenvDir() } };
}
console.log('[CodexLens] Installing ccw-litellm for LiteLLM embedding backend...');
// Find local ccw-litellm package path (only in development, not from node_modules)
const localPath = findLocalCcwLitellmPath();
// Find local ccw-litellm package path using unified discovery
const discovery = findCcwLitellmPath();
const localPath = discovery.path;
const editable = localPath ? (isDevEnvironment() && !discovery.insideNodeModules) : false;
const warnings: string[] = [];
// Priority: Use UV if available (faster, better dependency resolution)
if (await isUvAvailable()) {
@@ -495,6 +482,7 @@ async function ensureLiteLLMEmbedderReady(): Promise<BootstrapResult> {
const venvResult = await uv.createVenv();
if (!venvResult.success) {
console.log('[CodexLens] UV venv creation failed, falling back to pip:', venvResult.error);
warnings.push(`UV venv creation failed: ${venvResult.error}`);
// Fall through to pip fallback
}
}
@@ -502,20 +490,26 @@ async function ensureLiteLLMEmbedderReady(): Promise<BootstrapResult> {
if (uv.isVenvValid()) {
let uvResult;
if (localPath) {
console.log(`[CodexLens] Installing ccw-litellm from local path with UV: ${localPath}`);
uvResult = await uv.installFromProject(localPath);
console.log(`[CodexLens] Installing ccw-litellm from local path with UV: ${localPath} (editable: ${editable})`);
uvResult = await uv.installFromProject(localPath, undefined, editable);
} else {
console.log('[CodexLens] Installing ccw-litellm from PyPI with UV...');
uvResult = await uv.install(['ccw-litellm']);
}
if (uvResult.success) {
return { success: true };
return {
success: true,
diagnostics: { packagePath: localPath || undefined, venvPath: getCodexLensVenvDir(), installer: 'uv', editable },
warnings: warnings.length > 0 ? warnings : undefined,
};
}
console.log('[CodexLens] UV install failed, falling back to pip:', uvResult.error);
warnings.push(`UV install failed: ${uvResult.error}`);
}
} catch (uvErr) {
console.log('[CodexLens] UV error, falling back to pip:', (uvErr as Error).message);
warnings.push(`UV error: ${(uvErr as Error).message}`);
}
}
@@ -524,16 +518,33 @@ async function ensureLiteLLMEmbedderReady(): Promise<BootstrapResult> {
try {
if (localPath) {
console.log(`[CodexLens] Installing ccw-litellm from local path with pip: ${localPath}`);
execSync(`"${pipPath}" install -e "${localPath}"`, { stdio: 'inherit', timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL });
const pipFlag = editable ? '-e' : '';
const pipInstallSpec = editable ? `"${localPath}"` : `"${localPath}"`;
console.log(`[CodexLens] Installing ccw-litellm from local path with pip: ${localPath} (editable: ${editable})`);
execSync(`"${pipPath}" install ${pipFlag} ${pipInstallSpec}`.replace(/ +/g, ' '), { stdio: 'inherit', timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL });
} else {
console.log('[CodexLens] Installing ccw-litellm from PyPI with pip...');
execSync(`"${pipPath}" install ccw-litellm`, { stdio: 'inherit', timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL });
}
return { success: true };
return {
success: true,
diagnostics: { packagePath: localPath || undefined, venvPath: getCodexLensVenvDir(), installer: 'pip', editable },
warnings: warnings.length > 0 ? warnings : undefined,
};
} catch (err) {
return { success: false, error: `Failed to install ccw-litellm: ${(err as Error).message}` };
return {
success: false,
error: `Failed to install ccw-litellm: ${(err as Error).message}`,
diagnostics: {
packagePath: localPath || undefined,
venvPath: getCodexLensVenvDir(),
installer: 'pip',
editable,
searchedPaths: !localPath ? discovery.searchedPaths : undefined,
},
warnings: warnings.length > 0 ? warnings : undefined,
};
}
}
@@ -660,6 +671,15 @@ async function detectGpuSupport(): Promise<{ mode: GpuMode; available: GpuMode[]
async function bootstrapWithUv(gpuMode: GpuMode = 'cpu'): Promise<BootstrapResult> {
console.log('[CodexLens] Bootstrapping with UV package manager...');
// Pre-flight: verify Python is available and compatible
const preFlightError = preFlightCheck();
if (preFlightError) {
return { success: false, error: `Pre-flight failed: ${preFlightError}` };
}
// Auto-repair corrupted venv before proceeding
repairVenvIfCorrupted();
// Ensure UV is installed
const uvInstalled = await ensureUvInstalled();
if (!uvInstalled) {
@@ -678,49 +698,42 @@ async function bootstrapWithUv(gpuMode: GpuMode = 'cpu'): Promise<BootstrapResul
}
}
// Find local codex-lens package (only in development, not from node_modules)
const codexLensPath = findLocalCodexLensPath();
// Find local codex-lens package using unified discovery
const discovery = findCodexLensPath();
// Determine extras based on GPU mode
const extras = GPU_MODE_EXTRAS[gpuMode];
if (!codexLensPath) {
// codex-lens is a local-only package, not published to PyPI
// Generate dynamic paths for error message (cross-platform)
const possiblePaths = [
join(process.cwd(), 'codex-lens'),
join(__dirname, '..', '..', '..', 'codex-lens'),
join(homedir(), 'codex-lens'),
];
const cwd = process.cwd();
const cwdParent = dirname(cwd);
if (cwdParent !== cwd) {
possiblePaths.push(join(cwdParent, 'codex-lens'));
}
const pathsList = possiblePaths.map(p => ` - ${p}`).join('\n');
const errorMsg = `Cannot find codex-lens directory for local installation.\n\n` +
`codex-lens is a local development package (not published to PyPI) and must be installed from local files.\n\n` +
`To fix this:\n` +
`1. Ensure 'codex-lens' directory exists at one of these locations:\n${pathsList}\n` +
`2. Verify pyproject.toml exists in the codex-lens directory\n` +
`3. Run ccw from the correct working directory\n` +
`4. Or manually install: cd /path/to/codex-lens && pip install -e .[${extras.join(',')}]`;
return { success: false, error: errorMsg };
if (!discovery.path) {
return {
success: false,
error: formatSearchResults(discovery, 'codex-lens'),
diagnostics: { searchedPaths: discovery.searchedPaths, venvPath: getCodexLensVenvDir(), installer: 'uv' },
};
}
console.log(`[CodexLens] Installing from local path with UV: ${codexLensPath}`);
// Use non-editable install for production stability (editable only in dev)
const editable = isDevEnvironment() && !discovery.insideNodeModules;
console.log(`[CodexLens] Installing from local path with UV: ${discovery.path} (editable: ${editable})`);
console.log(`[CodexLens] Extras: ${extras.join(', ')}`);
const installResult = await uv.installFromProject(codexLensPath, extras);
const installResult = await uv.installFromProject(discovery.path, extras, editable);
if (!installResult.success) {
return { success: false, error: `Failed to install codex-lens: ${installResult.error}` };
return {
success: false,
error: `Failed to install codex-lens: ${installResult.error}`,
diagnostics: { packagePath: discovery.path, venvPath: getCodexLensVenvDir(), installer: 'uv', editable },
};
}
// Clear cache after successful installation
clearVenvStatusCache();
clearSemanticStatusCache();
console.log(`[CodexLens] Bootstrap with UV complete (${gpuMode} mode)`);
return { success: true, message: `Installed with UV (${gpuMode} mode)` };
return {
success: true,
message: `Installed with UV (${gpuMode} mode)`,
diagnostics: { packagePath: discovery.path, venvPath: getCodexLensVenvDir(), installer: 'uv', editable },
};
}
/**
@@ -754,8 +767,8 @@ async function installSemanticWithUv(gpuMode: GpuMode = 'cpu'): Promise<Bootstra
// Create UV manager
const uv = createCodexLensUvManager();
// Find local codex-lens package (only in development, not from node_modules)
const codexLensPath = findLocalCodexLensPath();
// Find local codex-lens package using unified discovery
const discovery = findCodexLensPath();
// Determine extras based on GPU mode
const extras = GPU_MODE_EXTRAS[gpuMode];
@@ -770,33 +783,13 @@ async function installSemanticWithUv(gpuMode: GpuMode = 'cpu'): Promise<Bootstra
console.log(`[CodexLens] Extras: ${extras.join(', ')}`);
// Install with extras - UV handles dependency conflicts automatically
if (!codexLensPath) {
// codex-lens is a local-only package, not published to PyPI
// Generate dynamic paths for error message (cross-platform)
const possiblePaths = [
join(process.cwd(), 'codex-lens'),
join(__dirname, '..', '..', '..', 'codex-lens'),
join(homedir(), 'codex-lens'),
];
const cwd = process.cwd();
const cwdParent = dirname(cwd);
if (cwdParent !== cwd) {
possiblePaths.push(join(cwdParent, 'codex-lens'));
}
const pathsList = possiblePaths.map(p => ` - ${p}`).join('\n');
const errorMsg = `Cannot find codex-lens directory for local installation.\n\n` +
`codex-lens is a local development package (not published to PyPI) and must be installed from local files.\n\n` +
`To fix this:\n` +
`1. Ensure 'codex-lens' directory exists at one of these locations:\n${pathsList}\n` +
`2. Verify pyproject.toml exists in the codex-lens directory\n` +
`3. Run ccw from the correct working directory\n` +
`4. Or manually install: cd /path/to/codex-lens && pip install -e .[${extras.join(',')}]`;
return { success: false, error: errorMsg };
if (!discovery.path) {
return { success: false, error: formatSearchResults(discovery, 'codex-lens') };
}
console.log(`[CodexLens] Reinstalling from local path with semantic extras...`);
const installResult = await uv.installFromProject(codexLensPath, extras);
const editable = isDevEnvironment() && !discovery.insideNodeModules;
console.log(`[CodexLens] Reinstalling from local path with semantic extras (editable: ${editable})...`);
const installResult = await uv.installFromProject(discovery.path, extras, editable);
if (!installResult.success) {
return { success: false, error: `Installation failed: ${installResult.error}` };
}
@@ -965,6 +958,15 @@ async function bootstrapVenv(): Promise<BootstrapResult> {
return bootstrapWithUv();
}
// Pre-flight: verify Python is available and compatible
const preFlightError = preFlightCheck();
if (preFlightError) {
return { success: false, error: `Pre-flight failed: ${preFlightError}` };
}
// Auto-repair corrupted venv before proceeding
repairVenvIfCorrupted();
// Fall back to pip logic...
// Ensure data directory exists
const dataDir = getCodexLensDataDir();
@@ -989,35 +991,24 @@ async function bootstrapVenv(): Promise<BootstrapResult> {
console.log('[CodexLens] Installing codex-lens package...');
const pipPath = getCodexLensPip();
// Try local path - codex-lens is local-only, not published to PyPI
const codexLensPath = findLocalCodexLensPath();
// Try local path using unified discovery
const discovery = findCodexLensPath();
if (!codexLensPath) {
// codex-lens is a local-only package, not published to PyPI
const errorMsg = `Cannot find codex-lens directory for local installation.\n\n` +
`codex-lens is a local development package (not published to PyPI) and must be installed from local files.\n\n` +
`To fix this:\n` +
`1. Ensure the 'codex-lens' directory exists in your project root\n` +
`2. Verify pyproject.toml exists in codex-lens directory\n` +
`3. Run ccw from the correct working directory\n` +
`4. Or manually install: cd codex-lens && pip install -e .`;
throw new Error(errorMsg);
if (!discovery.path) {
throw new Error(formatSearchResults(discovery, 'codex-lens'));
}
console.log(`[CodexLens] Installing from local path: ${codexLensPath}`);
execSync(`"${pipPath}" install -e "${codexLensPath}"`, { stdio: 'inherit', timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL });
const editable = isDevEnvironment() && !discovery.insideNodeModules;
const pipFlag = editable ? ' -e' : '';
console.log(`[CodexLens] Installing from local path: ${discovery.path} (editable: ${editable})`);
execSync(`"${pipPath}" install${pipFlag} "${discovery.path}"`, { stdio: 'inherit', timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL });
// Clear cache after successful installation
clearVenvStatusCache();
clearSemanticStatusCache();
return { success: true };
} catch (err) {
const errorMsg = `Failed to install codex-lens: ${(err as Error).message}\n\n` +
`codex-lens is a local development package. To fix this:\n` +
`1. Ensure the 'codex-lens' directory exists in your project root\n` +
`2. Run the installation from the correct working directory\n` +
`3. Or manually install: cd codex-lens && pip install -e .`;
return { success: false, error: errorMsg };
return { success: false, error: `Failed to install codex-lens: ${(err as Error).message}` };
}
}

View File

@@ -0,0 +1,318 @@
/**
* Unified Package Discovery for local Python packages (codex-lens, ccw-litellm)
*
* Provides a single, transparent path discovery mechanism with:
* - Environment variable overrides (highest priority)
* - ~/.codexlens/config.json configuration
* - Extended search paths (npm global, PACKAGE_ROOT, siblings, etc.)
* - Full search result transparency for diagnostics
*/
import { existsSync, readFileSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { homedir } from 'os';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
import { getCodexLensDataDir } from './codexlens-path.js';
import { EXEC_TIMEOUTS } from './exec-constants.js';
// Get directory of this module (src/utils/)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ========================================
// Types
// ========================================
/** Source that found the package path */
export type PackageSource =
| 'env' // Environment variable override
| 'config' // ~/.codexlens/config.json
| 'sibling' // Sibling directory to ccw project root
| 'npm-global' // npm global prefix
| 'cwd' // Current working directory
| 'cwd-parent' // Parent of current working directory
| 'homedir' // User home directory
| 'package-root'; // npm package internal path
/** A single search attempt result */
export interface SearchAttempt {
path: string;
source: PackageSource;
exists: boolean;
}
/** Result of package discovery */
export interface PackageDiscoveryResult {
/** Resolved package path, or null if not found */
path: string | null;
/** Source that found the package */
source: PackageSource | null;
/** All paths searched (for diagnostics) */
searchedPaths: SearchAttempt[];
/** Whether the found path is inside node_modules */
insideNodeModules: boolean;
}
/** Known local package names */
export type LocalPackageName = 'codex-lens' | 'ccw-litellm';
/** Environment variable mapping for each package */
const PACKAGE_ENV_VARS: Record<LocalPackageName, string> = {
'codex-lens': 'CODEXLENS_PACKAGE_PATH',
'ccw-litellm': 'CCW_LITELLM_PATH',
};
/** Config key mapping for each package */
const PACKAGE_CONFIG_KEYS: Record<LocalPackageName, string> = {
'codex-lens': 'codexLensPath',
'ccw-litellm': 'ccwLitellmPath',
};
// ========================================
// Helpers
// ========================================
/**
* Check if a path is inside node_modules
*/
export function isInsideNodeModules(pathToCheck: string): boolean {
const normalized = pathToCheck.replace(/\\/g, '/').toLowerCase();
return normalized.includes('/node_modules/');
}
/**
* Check if running in a development environment (not from node_modules)
*/
export function isDevEnvironment(): boolean {
// Yarn PnP detection
if ((process.versions as Record<string, unknown>).pnp) {
return false;
}
return !isInsideNodeModules(__dirname);
}
/**
* Read package paths from ~/.codexlens/config.json
*/
function readConfigPath(packageName: LocalPackageName): string | null {
try {
const configPath = join(getCodexLensDataDir(), 'config.json');
if (!existsSync(configPath)) return null;
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
const key = PACKAGE_CONFIG_KEYS[packageName];
const value = config?.packagePaths?.[key];
return typeof value === 'string' && value.trim() ? value.trim() : null;
} catch {
return null;
}
}
/**
* Get npm global prefix directory
*/
let _npmGlobalPrefix: string | null | undefined;
function getNpmGlobalPrefix(): string | null {
if (_npmGlobalPrefix !== undefined) return _npmGlobalPrefix;
try {
const result = execSync('npm prefix -g', {
encoding: 'utf-8',
timeout: EXEC_TIMEOUTS.SYSTEM_INFO,
stdio: ['pipe', 'pipe', 'pipe'],
});
_npmGlobalPrefix = result.trim() || null;
} catch {
_npmGlobalPrefix = null;
}
return _npmGlobalPrefix;
}
/**
* Check if a directory contains a valid Python package (has pyproject.toml)
*/
function isValidPackageDir(dir: string): boolean {
return existsSync(join(dir, 'pyproject.toml'));
}
// ========================================
// Main Discovery Function
// ========================================
/**
* Find a local Python package path with unified search logic.
*
* Search priority:
* 1. Environment variable (CODEXLENS_PACKAGE_PATH / CCW_LITELLM_PATH)
* 2. ~/.codexlens/config.json packagePaths
* 3. Sibling directory to ccw project root (src/utils -> ../../..)
* 4. npm global prefix node_modules path
* 5. Current working directory
* 6. Parent of current working directory
* 7. Home directory
*
* Two-pass search: first pass skips node_modules paths, second pass allows them.
*
* @param packageName - Package to find ('codex-lens' or 'ccw-litellm')
* @returns Discovery result with path, source, and all searched paths
*/
export function findPackagePath(packageName: LocalPackageName): PackageDiscoveryResult {
const searched: SearchAttempt[] = [];
// Helper to check and record a path
const check = (path: string, source: PackageSource): boolean => {
const resolvedPath = resolve(path);
const exists = isValidPackageDir(resolvedPath);
searched.push({ path: resolvedPath, source, exists });
return exists;
};
// 1. Environment variable (highest priority, skip two-pass)
const envKey = PACKAGE_ENV_VARS[packageName];
const envPath = process.env[envKey];
if (envPath) {
if (check(envPath, 'env')) {
return {
path: resolve(envPath),
source: 'env',
searchedPaths: searched,
insideNodeModules: isInsideNodeModules(envPath),
};
}
// Env var set but path invalid — continue searching but warn
console.warn(`[PackageDiscovery] ${envKey}="${envPath}" set but pyproject.toml not found, continuing search...`);
}
// 2. Config file
const configPath = readConfigPath(packageName);
if (configPath) {
if (check(configPath, 'config')) {
return {
path: resolve(configPath),
source: 'config',
searchedPaths: searched,
insideNodeModules: isInsideNodeModules(configPath),
};
}
}
// Build candidate paths for two-pass search
const candidates: { path: string; source: PackageSource }[] = [];
// 3. Sibling directory to ccw project root
// __dirname = src/utils/ → project root = ../../..
// Also try one more level up for nested structures
const projectRoot = join(__dirname, '..', '..', '..');
candidates.push({ path: join(projectRoot, packageName), source: 'sibling' });
candidates.push({ path: join(projectRoot, '..', packageName), source: 'sibling' });
// 4. npm global prefix
const npmPrefix = getNpmGlobalPrefix();
if (npmPrefix) {
// npm global: prefix/node_modules/claude-code-workflow/<packageName>
candidates.push({
path: join(npmPrefix, 'node_modules', 'claude-code-workflow', packageName),
source: 'npm-global',
});
// npm global: prefix/lib/node_modules/claude-code-workflow/<packageName> (Linux/Mac)
candidates.push({
path: join(npmPrefix, 'lib', 'node_modules', 'claude-code-workflow', packageName),
source: 'npm-global',
});
// npm global sibling: prefix/node_modules/<packageName>
candidates.push({
path: join(npmPrefix, 'node_modules', packageName),
source: 'npm-global',
});
}
// 5. Current working directory
const cwd = process.cwd();
candidates.push({ path: join(cwd, packageName), source: 'cwd' });
// 6. Parent of cwd (common workspace layout)
const cwdParent = dirname(cwd);
if (cwdParent !== cwd) {
candidates.push({ path: join(cwdParent, packageName), source: 'cwd-parent' });
}
// 7. Home directory
candidates.push({ path: join(homedir(), packageName), source: 'homedir' });
// Two-pass search: prefer non-node_modules paths first
// First pass: skip node_modules
for (const candidate of candidates) {
const resolvedPath = resolve(candidate.path);
if (isInsideNodeModules(resolvedPath)) continue;
if (check(resolvedPath, candidate.source)) {
console.log(`[PackageDiscovery] Found ${packageName} at: ${resolvedPath} (source: ${candidate.source})`);
return {
path: resolvedPath,
source: candidate.source,
searchedPaths: searched,
insideNodeModules: false,
};
}
}
// Second pass: allow node_modules paths
for (const candidate of candidates) {
const resolvedPath = resolve(candidate.path);
if (!isInsideNodeModules(resolvedPath)) continue;
// Skip if already checked in first pass
if (searched.some(s => s.path === resolvedPath)) continue;
if (check(resolvedPath, candidate.source)) {
console.log(`[PackageDiscovery] Found ${packageName} in node_modules at: ${resolvedPath} (source: ${candidate.source})`);
return {
path: resolvedPath,
source: candidate.source,
searchedPaths: searched,
insideNodeModules: true,
};
}
}
// Not found
return {
path: null,
source: null,
searchedPaths: searched,
insideNodeModules: false,
};
}
/**
* Find codex-lens package path (convenience wrapper)
*/
export function findCodexLensPath(): PackageDiscoveryResult {
return findPackagePath('codex-lens');
}
/**
* Find ccw-litellm package path (convenience wrapper)
*/
export function findCcwLitellmPath(): PackageDiscoveryResult {
return findPackagePath('ccw-litellm');
}
/**
* Format search results for error messages
*/
export function formatSearchResults(result: PackageDiscoveryResult, packageName: string): string {
const lines = [`Cannot find '${packageName}' package directory.\n`];
lines.push('Searched locations:');
for (const attempt of result.searchedPaths) {
const status = attempt.exists ? '✓' : '✗';
lines.push(` ${status} [${attempt.source}] ${attempt.path}`);
}
lines.push('');
lines.push('To fix this:');
const envKey = PACKAGE_ENV_VARS[packageName as LocalPackageName] || `${packageName.toUpperCase().replace(/-/g, '_')}_PATH`;
lines.push(` 1. Set environment variable: ${envKey}=/path/to/${packageName}`);
lines.push(` 2. Or add to ~/.codexlens/config.json: { "packagePaths": { "${PACKAGE_CONFIG_KEYS[packageName as LocalPackageName] || packageName}": "/path/to/${packageName}" } }`);
lines.push(` 3. Or ensure '${packageName}' directory exists as a sibling to the ccw project`);
return lines.join('\n');
}

View File

@@ -356,12 +356,13 @@ export class UvManager {
/**
* Install packages from a local project with optional extras
* Uses `uv pip install -e` for editable installs
* Uses `uv pip install` for standard installs, or `-e` for editable installs
* @param projectPath - Path to the project directory (must contain pyproject.toml or setup.py)
* @param extras - Optional array of extras to install (e.g., ['semantic', 'dev'])
* @param editable - Whether to install in editable mode (default: false for stability)
* @returns Installation result
*/
async installFromProject(projectPath: string, extras?: string[]): Promise<UvInstallResult> {
async installFromProject(projectPath: string, extras?: string[], editable = false): Promise<UvInstallResult> {
const startTime = Date.now();
// Ensure UV is available
@@ -383,9 +384,11 @@ export class UvManager {
}
return new Promise((resolve) => {
const args = ['pip', 'install', '-e', installSpec, '--python', this.getVenvPython()];
const args = editable
? ['pip', 'install', '-e', installSpec, '--python', this.getVenvPython()]
: ['pip', 'install', installSpec, '--python', this.getVenvPython()];
console.log(`[UV] Installing from project: ${installSpec}`);
console.log(`[UV] Installing from project: ${installSpec} (editable: ${editable})`);
const child = spawn(uvPath, args, {
stdio: ['ignore', 'pipe', 'pipe'],