fix: resolve GitHub issues #63, #66, #67, #68, #69, #70

- #70: Fix API Key Tester URL handling - normalize trailing slashes before
  version suffix detection to prevent double-slash URLs like //models
- #69: Fix memory embedder ignoring CodexLens config - add error handling
  for CodexLensConfig.load() with fallback to defaults
- #68: Fix ccw cli using wrong Python environment - add getCodexLensVenvPython()
  to resolve correct venv path on Windows/Unix
- #67: Fix LiteLLM API Provider test endpoint - actually test API key connection
  instead of just checking ccw-litellm installation
- #66: Fix help-routes.ts path configuration - use correct 'ccw-help' directory
  name and refactor getIndexDir to pure function
- #63: Fix CodexLens install state refresh - add cache invalidation after
  config save in codexlens-manager.js

Also includes targeted unit tests for the URL normalization logic.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
catlog22
2026-01-13 18:20:54 +08:00
parent 61cef8019a
commit 340137d347
12 changed files with 546 additions and 29 deletions

View File

@@ -7,6 +7,29 @@ import { join } from 'path';
import { homedir } from 'os';
import type { RouteContext } from './types.js';
/**
* Get the ccw-help index directory path (pure function)
* Priority: project path (.claude/skills/ccw-help/index) > user path (~/.claude/skills/ccw-help/index)
* @param projectPath - The project path to check first
*/
function getIndexDir(projectPath: string | null): string | null {
// Try project path first
if (projectPath) {
const projectIndexDir = join(projectPath, '.claude', 'skills', 'ccw-help', 'index');
if (existsSync(projectIndexDir)) {
return projectIndexDir;
}
}
// Fall back to user path
const userIndexDir = join(homedir(), '.claude', 'skills', 'ccw-help', 'index');
if (existsSync(userIndexDir)) {
return userIndexDir;
}
return null;
}
// ========== In-Memory Cache ==========
interface CacheEntry {
data: any;
@@ -61,14 +84,15 @@ let watchersInitialized = false;
/**
* Initialize file watchers for JSON indexes
* @param projectPath - The project path to resolve index directory
*/
function initializeFileWatchers(): void {
function initializeFileWatchers(projectPath: string | null): void {
if (watchersInitialized) return;
const indexDir = join(homedir(), '.claude', 'skills', 'command-guide', 'index');
const indexDir = getIndexDir(projectPath);
if (!existsSync(indexDir)) {
console.warn(`Command guide index directory not found: ${indexDir}`);
if (!indexDir) {
console.warn(`ccw-help index directory not found in project or user paths`);
return;
}
@@ -152,15 +176,20 @@ function groupCommandsByCategory(commands: any[]): any {
* @returns true if route was handled, false otherwise
*/
export async function handleHelpRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res } = ctx;
const { pathname, url, req, res, initialPath } = ctx;
// Initialize file watchers on first request
initializeFileWatchers();
initializeFileWatchers(initialPath);
const indexDir = join(homedir(), '.claude', 'skills', 'command-guide', 'index');
const indexDir = getIndexDir(initialPath);
// API: Get all commands with optional search
if (pathname === '/api/help/commands') {
if (!indexDir) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'ccw-help index directory not found' }));
return true;
}
const searchQuery = url.searchParams.get('q') || '';
const filePath = join(indexDir, 'all-commands.json');
@@ -191,6 +220,11 @@ export async function handleHelpRoutes(ctx: RouteContext): Promise<boolean> {
// API: Get workflow command relationships
if (pathname === '/api/help/workflows') {
if (!indexDir) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'ccw-help index directory not found' }));
return true;
}
const filePath = join(indexDir, 'command-relationships.json');
const relationships = getCachedData('command-relationships', filePath);
@@ -207,6 +241,11 @@ export async function handleHelpRoutes(ctx: RouteContext): Promise<boolean> {
// API: Get commands by category
if (pathname === '/api/help/commands/by-category') {
if (!indexDir) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'ccw-help index directory not found' }));
return true;
}
const filePath = join(indexDir, 'by-category.json');
const byCategory = getCachedData('by-category', filePath);

View File

@@ -334,12 +334,43 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
return true;
}
// Test connection using litellm client
const client = getLiteLLMClient();
const available = await client.isAvailable();
// Get the API key to test (prefer first key from apiKeys array, fall back to default apiKey)
let apiKeyValue: string | null = null;
if (provider.apiKeys && provider.apiKeys.length > 0) {
apiKeyValue = provider.apiKeys[0].key;
} else if (provider.apiKey) {
apiKeyValue = provider.apiKey;
}
if (!apiKeyValue) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'No API key configured for this provider' }));
return true;
}
// Resolve environment variables in the API key
const { resolveEnvVar } = await import('../../config/litellm-api-config-manager.js');
const resolvedKey = resolveEnvVar(apiKeyValue);
if (!resolvedKey) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'API key is empty or environment variable not set' }));
return true;
}
// Determine API base URL
const apiBase = provider.apiBase || getDefaultApiBase(provider.type);
// Test the API key connection
const testResult = await testApiKeyConnection(provider.type, apiBase, resolvedKey);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: available, provider: provider.type }));
res.end(JSON.stringify({
success: testResult.valid,
provider: provider.type,
latencyMs: testResult.latencyMs,
error: testResult.error,
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: (err as Error).message }));

View File

@@ -72,6 +72,10 @@ export async function testApiKeyConnection(
return { valid: false, error: urlValidation.error };
}
// Normalize apiBase: remove trailing slashes to prevent URL construction issues
// e.g., "https://api.openai.com/v1/" -> "https://api.openai.com/v1"
const normalizedApiBase = apiBase.replace(/\/+$/, '');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const startTime = Date.now();
@@ -80,7 +84,7 @@ export async function testApiKeyConnection(
if (providerType === 'anthropic') {
// Anthropic format: Use /v1/models endpoint (no cost, no model dependency)
// This validates the API key without making a billable request
const response = await fetch(`${apiBase}/models`, {
const response = await fetch(`${normalizedApiBase}/models`, {
method: 'GET',
headers: {
'x-api-key': apiKey,
@@ -114,8 +118,10 @@ export async function testApiKeyConnection(
return { valid: false, error: errorMessage };
} else {
// OpenAI-compatible format: GET /v1/models
const modelsUrl = apiBase.endsWith('/v1') ? `${apiBase}/models` : `${apiBase}/v1/models`;
// OpenAI-compatible format: GET /v{N}/models
// Detect if URL already ends with a version pattern like /v1, /v2, /v4, etc.
const hasVersionSuffix = /\/v\d+$/.test(normalizedApiBase);
const modelsUrl = hasVersionSuffix ? `${normalizedApiBase}/models` : `${normalizedApiBase}/v1/models`;
const response = await fetch(modelsUrl, {
method: 'GET',
headers: {