mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
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:
318
ccw/src/utils/package-discovery.ts
Normal file
318
ccw/src/utils/package-discovery.ts
Normal 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');
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user