mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-12 17:21:19 +08:00
Add comprehensive tests for CLI functionality and CodexLens compatibility
- Introduced tests for stale running fallback in CLI watch functionality to ensure proper handling of saved conversations. - Added compatibility tests for CodexLens CLI to verify index initialization despite compatibility conflicts. - Implemented tests for Smart Search MCP usage to validate default settings and path handling. - Created tests for UV Manager to ensure Python preference handling works as expected. - Added a detailed guide for CCW/Codex commands and skills, covering core commands, execution modes, and templates.
This commit is contained in:
@@ -226,7 +226,7 @@ export function run(argv: string[]): void {
|
||||
.option('--output-type <type>', 'Output type: stdout, stderr, both', 'both')
|
||||
.option('--turn <n>', 'Turn number for cache (default: latest)')
|
||||
.option('--raw', 'Raw output only (no formatting)')
|
||||
.option('--final', 'Output final result only (agent_message content, now default)')
|
||||
.option('--final', 'Output strict final result only (no parsed/stdout fallback)')
|
||||
.option('--verbose', 'Show full metadata + raw output')
|
||||
.option('--timeout <seconds>', 'Timeout for watch command')
|
||||
.option('--all', 'Show all executions in show command')
|
||||
|
||||
@@ -181,7 +181,7 @@ interface OutputViewOptions {
|
||||
outputType?: 'stdout' | 'stderr' | 'both';
|
||||
turn?: string;
|
||||
raw?: boolean;
|
||||
final?: boolean; // Explicit --final (same as default, kept for compatibility)
|
||||
final?: boolean; // Explicit --final (strict final result, no parsed/stdout fallback)
|
||||
verbose?: boolean; // Show full metadata + raw stdout/stderr
|
||||
project?: string; // Optional project path for lookup
|
||||
}
|
||||
@@ -470,10 +470,23 @@ async function outputAction(conversationId: string | undefined, options: OutputV
|
||||
return;
|
||||
}
|
||||
|
||||
// Default (and --final): output final result only
|
||||
// Prefer finalOutput (agent_message only) > parsedOutput (filtered) > raw stdout
|
||||
const outputContent = result.finalOutput?.content || result.parsedOutput?.content || result.stdout?.content;
|
||||
if (outputContent) {
|
||||
const finalOutputContent = result.finalOutput?.content;
|
||||
|
||||
if (options.final) {
|
||||
if (finalOutputContent !== undefined) {
|
||||
console.log(finalOutputContent);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(chalk.yellow('No final agent result found in cached output.'));
|
||||
console.error(chalk.gray(' Try without --final for best-effort output, or use --verbose to inspect raw stdout/stderr.'));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default output: prefer strict final result, then fall back to best-effort parsed/plain output.
|
||||
const outputContent = finalOutputContent ?? result.parsedOutput?.content ?? result.stdout?.content;
|
||||
if (outputContent !== undefined) {
|
||||
console.log(outputContent);
|
||||
}
|
||||
}
|
||||
@@ -1351,7 +1364,7 @@ async function showAction(options: { all?: boolean }): Promise<void> {
|
||||
// 1. Try to fetch active executions from dashboard
|
||||
let activeExecs: Array<{
|
||||
id: string; tool: string; mode: string; status: string;
|
||||
prompt: string; startTime: number; isComplete?: boolean;
|
||||
prompt: string; startTime: number | string | Date; isComplete?: boolean;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
@@ -1382,6 +1395,7 @@ async function showAction(options: { all?: boolean }): Promise<void> {
|
||||
// 2. Get recent history from SQLite
|
||||
const historyLimit = options.all ? 100 : 20;
|
||||
const history = await getExecutionHistoryAsync(process.cwd(), { limit: historyLimit, recursive: true });
|
||||
const historyById = new Map(history.executions.map(exec => [exec.id, exec]));
|
||||
|
||||
// 3. Build unified list: active first, then history (de-duped)
|
||||
const seenIds = new Set<string>();
|
||||
@@ -1393,16 +1407,26 @@ async function showAction(options: { all?: boolean }): Promise<void> {
|
||||
// Active executions (running)
|
||||
for (const exec of activeExecs) {
|
||||
if (exec.status === 'running') {
|
||||
const normalizedStartTime = normalizeTimestampMs(exec.startTime);
|
||||
const matchingHistory = historyById.get(exec.id);
|
||||
const shouldSuppressActiveRow = matchingHistory !== undefined && isSavedExecutionNewerThanActive(
|
||||
normalizedStartTime,
|
||||
matchingHistory.updated_at || matchingHistory.timestamp
|
||||
);
|
||||
|
||||
if (shouldSuppressActiveRow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(exec.id);
|
||||
const elapsed = Math.floor((Date.now() - exec.startTime) / 1000);
|
||||
rows.push({
|
||||
id: exec.id,
|
||||
tool: exec.tool,
|
||||
mode: exec.mode,
|
||||
status: 'running',
|
||||
prompt: (exec.prompt || '').replace(/\n/g, ' ').substring(0, 50),
|
||||
time: `${elapsed}s ago`,
|
||||
duration: `${elapsed}s...`,
|
||||
time: normalizedStartTime !== undefined ? getTimeAgo(new Date(normalizedStartTime)) : 'unknown',
|
||||
duration: normalizedStartTime !== undefined ? formatRunningDuration(Date.now() - normalizedStartTime) : 'running',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1513,6 +1537,18 @@ async function watchAction(watchId: string | undefined, options: { timeout?: str
|
||||
}
|
||||
|
||||
if (exec.status === 'running') {
|
||||
const savedConversation = getHistoryStore(process.cwd()).getConversation(watchId);
|
||||
const shouldPreferSavedConversation = !!savedConversation && isSavedExecutionNewerThanActive(
|
||||
normalizeTimestampMs((exec as { startTime?: unknown }).startTime),
|
||||
savedConversation.updated_at || savedConversation.created_at
|
||||
);
|
||||
|
||||
if (shouldPreferSavedConversation) {
|
||||
process.stderr.write(chalk.gray(`\nExecution already completed (status: ${savedConversation.latest_status}).\n`));
|
||||
process.stderr.write(chalk.dim(`Use: ccw cli output ${watchId}\n`));
|
||||
return savedConversation.latest_status === 'success' ? 0 : 1;
|
||||
}
|
||||
|
||||
// Still running — wait and poll again
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
return poll();
|
||||
@@ -1667,7 +1703,7 @@ async function detailAction(conversationId: string | undefined): Promise<void> {
|
||||
* @returns {string}
|
||||
*/
|
||||
function getTimeAgo(date: Date): string {
|
||||
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
|
||||
if (seconds < 60) return 'just now';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
@@ -1676,6 +1712,71 @@ function getTimeAgo(date: Date): string {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function normalizeTimestampMs(value: unknown): number | undefined {
|
||||
if (value instanceof Date) {
|
||||
const time = value.getTime();
|
||||
return Number.isFinite(time) ? time : undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value > 0 && value < 1_000_000_000_000 ? value * 1000 : value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const numericValue = Number(trimmed);
|
||||
if (Number.isFinite(numericValue)) {
|
||||
return numericValue > 0 && numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(trimmed);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatRunningDuration(elapsedMs: number): string {
|
||||
const safeElapsedMs = Math.max(0, elapsedMs);
|
||||
const totalSeconds = Math.floor(safeElapsedMs / 1000);
|
||||
|
||||
if (totalSeconds < 60) return `${totalSeconds}s...`;
|
||||
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (totalSeconds < 3600) {
|
||||
return seconds === 0 ? `${minutes}m...` : `${minutes}m ${seconds}s...`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const remainingMinutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
if (totalSeconds < 86400) {
|
||||
return remainingMinutes === 0 ? `${hours}h...` : `${hours}h ${remainingMinutes}m...`;
|
||||
}
|
||||
|
||||
const days = Math.floor(totalSeconds / 86400);
|
||||
const remainingHours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
return remainingHours === 0 ? `${days}d...` : `${days}d ${remainingHours}h...`;
|
||||
}
|
||||
|
||||
function isSavedExecutionNewerThanActive(
|
||||
activeStartTimeMs: number | undefined,
|
||||
savedTimestamp: unknown
|
||||
): boolean {
|
||||
if (activeStartTimeMs === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const savedTimestampMs = normalizeTimestampMs(savedTimestamp);
|
||||
if (savedTimestampMs === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return savedTimestampMs >= activeStartTimeMs;
|
||||
}
|
||||
|
||||
/**ccw cli -p
|
||||
* CLI command entry point
|
||||
* @param {string} subcommand - Subcommand (status, exec, history, detail)
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
getEnrichedConversation,
|
||||
getHistoryWithNativeInfo
|
||||
} from '../../tools/cli-executor.js';
|
||||
import { getHistoryStore } from '../../tools/cli-history-store.js';
|
||||
import { StoragePaths } from '../../config/storage-paths.js';
|
||||
import { listAllNativeSessions } from '../../tools/native-session-discovery.js';
|
||||
import { SmartContentFormatter } from '../../tools/cli-output-converter.js';
|
||||
import { generateSmartContext, formatSmartContext } from '../../tools/smart-context.js';
|
||||
@@ -51,6 +53,7 @@ import {
|
||||
getCodeIndexMcp
|
||||
} from '../../tools/claude-cli-tools.js';
|
||||
import type { RouteContext } from './types.js';
|
||||
import { existsSync } from 'fs';
|
||||
import { resolve, normalize } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
@@ -171,6 +174,84 @@ export function getActiveExecutions(): ActiveExecutionDto[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeTimestampMs(value: unknown): number | undefined {
|
||||
if (value instanceof Date) {
|
||||
const time = value.getTime();
|
||||
return Number.isFinite(time) ? time : undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value > 0 && value < 1_000_000_000_000 ? value * 1000 : value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const numericValue = Number(trimmed);
|
||||
if (Number.isFinite(numericValue)) {
|
||||
return numericValue > 0 && numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(trimmed);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isSavedExecutionNewerThanActive(activeStartTimeMs: number | undefined, savedTimestamp: unknown): boolean {
|
||||
if (activeStartTimeMs === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const savedTimestampMs = normalizeTimestampMs(savedTimestamp);
|
||||
if (savedTimestampMs === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return savedTimestampMs >= activeStartTimeMs;
|
||||
}
|
||||
|
||||
function getSavedConversationWithNativeInfo(projectPath: string, executionId: string) {
|
||||
const historyDbPath = StoragePaths.project(projectPath).historyDb;
|
||||
if (!existsSync(historyDbPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return getHistoryStore(projectPath).getConversationWithNativeInfo(executionId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSupersededActiveExecutions(projectPath: string): void {
|
||||
const supersededIds: string[] = [];
|
||||
|
||||
for (const [executionId, activeExec] of activeExecutions.entries()) {
|
||||
const savedConversation = getSavedConversationWithNativeInfo(projectPath, executionId);
|
||||
if (!savedConversation) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSavedExecutionNewerThanActive(
|
||||
normalizeTimestampMs(activeExec.startTime),
|
||||
savedConversation.updated_at || savedConversation.created_at
|
||||
)) {
|
||||
supersededIds.push(executionId);
|
||||
}
|
||||
}
|
||||
|
||||
supersededIds.forEach(executionId => {
|
||||
activeExecutions.delete(executionId);
|
||||
});
|
||||
|
||||
if (supersededIds.length > 0) {
|
||||
console.log(`[ActiveExec] Removed ${supersededIds.length} superseded execution(s): ${supersededIds.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update active execution state from hook events
|
||||
* Called by hooks-routes when CLI events are received from terminal execution
|
||||
@@ -240,6 +321,10 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
|
||||
// API: Get Active CLI Executions (for state recovery)
|
||||
if (pathname === '/api/cli/active' && req.method === 'GET') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
cleanupStaleExecutions();
|
||||
cleanupSupersededActiveExecutions(projectPath);
|
||||
|
||||
const executions = getActiveExecutions().map(exec => ({
|
||||
...exec,
|
||||
isComplete: exec.status !== 'running'
|
||||
@@ -537,6 +622,8 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
// API: CLI Execution Detail (GET) or Delete (DELETE)
|
||||
if (pathname === '/api/cli/execution') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
cleanupStaleExecutions();
|
||||
cleanupSupersededActiveExecutions(projectPath);
|
||||
const executionId = url.searchParams.get('id');
|
||||
|
||||
if (!executionId) {
|
||||
@@ -564,10 +651,17 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
const conversation = getSavedConversationWithNativeInfo(projectPath, executionId) || getConversationDetailWithNativeInfo(projectPath, executionId);
|
||||
|
||||
// Handle GET request - return conversation with native session info
|
||||
// First check in-memory active executions (for running/recently completed)
|
||||
const activeExec = activeExecutions.get(executionId);
|
||||
if (activeExec) {
|
||||
const shouldPreferSavedConversation = !!activeExec && !!conversation && isSavedExecutionNewerThanActive(
|
||||
normalizeTimestampMs(activeExec.startTime),
|
||||
conversation.updated_at || conversation.created_at
|
||||
);
|
||||
|
||||
if (activeExec && !shouldPreferSavedConversation) {
|
||||
// Return active execution data as conversation record format
|
||||
// Note: Convert output array buffer back to string for API compatibility
|
||||
const activeConversation = {
|
||||
@@ -594,8 +688,6 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fall back to database query for saved conversations
|
||||
const conversation = getConversationDetailWithNativeInfo(projectPath, executionId);
|
||||
if (!conversation) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Conversation not found' }));
|
||||
|
||||
@@ -9,7 +9,7 @@ import { join, dirname, resolve } from 'path';
|
||||
import { parseSessionFile, formatConversation, extractConversationPairs, type ParsedSession, type ParsedTurn } from './session-content-parser.js';
|
||||
import { getDiscoverer, getNativeSessions } from './native-session-discovery.js';
|
||||
import { StoragePaths, ensureStorageDir, getProjectId, getCCWHome } from '../config/storage-paths.js';
|
||||
import type { CliOutputUnit } from './cli-output-converter.js';
|
||||
import { createOutputParser, flattenOutputUnits, type CliOutputUnit } from './cli-output-converter.js';
|
||||
|
||||
// Debug logging for history save investigation (Iteration 4)
|
||||
const DEBUG_SESSION_ID = 'DBG-parallel-ccw-cli-test-2026-03-07';
|
||||
@@ -34,6 +34,27 @@ function writeDebugLog(event: string, data: Record<string, any>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function reconstructFinalOutputFromStdout(rawStdout: string, canTrustStdout: boolean): string | undefined {
|
||||
if (!canTrustStdout || !rawStdout.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parser = createOutputParser('json-lines');
|
||||
const units = parser.parse(Buffer.from(rawStdout, 'utf8'), 'stdout');
|
||||
units.push(...parser.flush());
|
||||
|
||||
const reconstructed = flattenOutputUnits(units, {
|
||||
includeTypes: ['agent_message'],
|
||||
stripCommandJsonBlocks: true
|
||||
});
|
||||
|
||||
return reconstructed || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Types
|
||||
export interface ConversationTurn {
|
||||
turn: number;
|
||||
@@ -764,8 +785,14 @@ export class CliHistoryStore {
|
||||
}
|
||||
|
||||
// Add final output if available (agent_message only for --final flag)
|
||||
if (turn.final_output) {
|
||||
const finalContent = turn.final_output;
|
||||
// For older records that lack final_output, attempt reconstruction from raw JSONL stdout.
|
||||
const canTrustStdoutForFinal = !!(turn.cached || !turn.truncated);
|
||||
const reconstructedFinalOutput = turn.final_output
|
||||
? undefined
|
||||
: reconstructFinalOutputFromStdout(turn.cached ? (turn.stdout_full || '') : (turn.stdout || ''), canTrustStdoutForFinal);
|
||||
const finalContent = turn.final_output ?? reconstructedFinalOutput;
|
||||
|
||||
if (finalContent !== undefined) {
|
||||
const totalBytes = finalContent.length;
|
||||
const content = finalContent.substring(offset, offset + limit);
|
||||
result.finalOutput = {
|
||||
|
||||
@@ -185,6 +185,7 @@ interface ExecuteResult {
|
||||
output?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
warning?: string;
|
||||
results?: unknown;
|
||||
files?: unknown;
|
||||
symbols?: unknown;
|
||||
@@ -1228,6 +1229,143 @@ function parseProgressLine(line: string): ProgressInfo | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldRetryWithoutEnrich(args: string[], error?: string): boolean {
|
||||
return args.includes('--enrich') && Boolean(error && /No such option:\s+--enrich/i.test(error));
|
||||
}
|
||||
|
||||
function shouldRetryWithoutLanguageFilters(args: string[], error?: string): boolean {
|
||||
return args.includes('--language') && Boolean(error && /Got unexpected extra arguments?\b/i.test(error));
|
||||
}
|
||||
|
||||
function stripFlag(args: string[], flag: string): string[] {
|
||||
return args.filter((arg) => arg !== flag);
|
||||
}
|
||||
|
||||
function stripOptionWithValues(args: string[], option: string): string[] {
|
||||
const nextArgs: string[] = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
if (args[index] === option) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
nextArgs.push(args[index]);
|
||||
}
|
||||
return nextArgs;
|
||||
}
|
||||
|
||||
function shouldRetryWithAstGrepPreference(args: string[], error?: string): boolean {
|
||||
return !args.includes('--use-astgrep')
|
||||
&& !args.includes('--no-use-astgrep')
|
||||
&& Boolean(error && /Options --use-astgrep and --no-use-astgrep are mutually exclusive/i.test(error));
|
||||
}
|
||||
|
||||
function shouldRetryWithStaticGraphPreference(args: string[], error?: string): boolean {
|
||||
return !args.includes('--static-graph')
|
||||
&& !args.includes('--no-static-graph')
|
||||
&& Boolean(error && /Options --static-graph and --no-static-graph are mutually exclusive/i.test(error));
|
||||
}
|
||||
|
||||
function stripAnsiCodes(value: string): string {
|
||||
return value
|
||||
.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
.replace(/\x1b\][0-9;]*\x07/g, '')
|
||||
.replace(/\x1b\][^\x07]*\x07/g, '');
|
||||
}
|
||||
|
||||
function tryExtractJsonPayload(raw: string): unknown | null {
|
||||
const cleanOutput = stripAnsiCodes(raw).trim();
|
||||
const jsonStart = cleanOutput.search(/[\[{]/);
|
||||
if (jsonStart === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startChar = cleanOutput[jsonStart];
|
||||
const endChar = startChar === '{' ? '}' : ']';
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escapeNext = false;
|
||||
let jsonEnd = -1;
|
||||
|
||||
for (let index = jsonStart; index < cleanOutput.length; index += 1) {
|
||||
const char = cleanOutput[index];
|
||||
|
||||
if (escapeNext) {
|
||||
escapeNext = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\' && inString) {
|
||||
escapeNext = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inString) {
|
||||
if (char === startChar) {
|
||||
depth += 1;
|
||||
} else if (char === endChar) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
jsonEnd = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonEnd === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(cleanOutput.slice(jsonStart, jsonEnd));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractStructuredError(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (typeof record.error === 'string' && record.error.trim()) {
|
||||
return record.error.trim();
|
||||
}
|
||||
if (typeof record.message === 'string' && record.message.trim()) {
|
||||
return record.message.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractCodexLensFailure(stdout: string, stderr: string, code: number | null): string {
|
||||
const structuredStdout = extractStructuredError(tryExtractJsonPayload(stdout));
|
||||
if (structuredStdout) {
|
||||
return structuredStdout;
|
||||
}
|
||||
|
||||
const structuredStderr = extractStructuredError(tryExtractJsonPayload(stderr));
|
||||
if (structuredStderr) {
|
||||
return structuredStderr;
|
||||
}
|
||||
|
||||
const cleanStdout = stripAnsiCodes(stdout).trim();
|
||||
const cleanStderr = stripAnsiCodes(stderr)
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !/^DEBUG\b/i.test(line))
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
return cleanStderr || cleanStdout || stripAnsiCodes(stderr).trim() || `Process exited with code ${code ?? 'unknown'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute CodexLens CLI command with real-time progress updates
|
||||
* @param args - CLI arguments
|
||||
@@ -1235,6 +1373,64 @@ function parseProgressLine(line: string): ProgressInfo | null {
|
||||
* @returns Execution result
|
||||
*/
|
||||
async function executeCodexLens(args: string[], options: ExecuteOptions = {}): Promise<ExecuteResult> {
|
||||
let attemptArgs = [...args];
|
||||
let result = await executeCodexLensOnce(attemptArgs, options);
|
||||
const compatibilityWarnings: string[] = [];
|
||||
|
||||
const compatibilityRetries = [
|
||||
{
|
||||
shouldRetry: shouldRetryWithoutEnrich,
|
||||
transform: (currentArgs: string[]) => stripFlag(currentArgs, '--enrich'),
|
||||
warning: 'CodexLens CLI does not support --enrich; retried without it.',
|
||||
},
|
||||
{
|
||||
shouldRetry: shouldRetryWithoutLanguageFilters,
|
||||
transform: (currentArgs: string[]) => stripOptionWithValues(currentArgs, '--language'),
|
||||
warning: 'CodexLens CLI rejected --language filters; retried without language scoping.',
|
||||
},
|
||||
{
|
||||
shouldRetry: shouldRetryWithAstGrepPreference,
|
||||
transform: (currentArgs: string[]) => [...currentArgs, '--use-astgrep'],
|
||||
warning: 'CodexLens CLI hit a Typer ast-grep option conflict; retried with explicit --use-astgrep.',
|
||||
},
|
||||
{
|
||||
shouldRetry: shouldRetryWithStaticGraphPreference,
|
||||
transform: (currentArgs: string[]) => [...currentArgs, '--static-graph'],
|
||||
warning: 'CodexLens CLI hit a Typer static-graph option conflict; retried with explicit --static-graph.',
|
||||
},
|
||||
];
|
||||
|
||||
for (const retry of compatibilityRetries) {
|
||||
if (result.success || !retry.shouldRetry(attemptArgs, result.error)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
compatibilityWarnings.push(retry.warning);
|
||||
attemptArgs = retry.transform(attemptArgs);
|
||||
const retryResult = await executeCodexLensOnce(attemptArgs, options);
|
||||
result = retryResult.success
|
||||
? retryResult
|
||||
: {
|
||||
...retryResult,
|
||||
error: retryResult.error
|
||||
? `${retryResult.error} (after compatibility retry; initial error: ${result.error})`
|
||||
: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
if (compatibilityWarnings.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const warning = compatibilityWarnings.join(' ');
|
||||
return {
|
||||
...result,
|
||||
warning,
|
||||
message: result.message ? `${result.message} ${warning}` : warning,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeCodexLensOnce(args: string[], options: ExecuteOptions = {}): Promise<ExecuteResult> {
|
||||
const { timeout = 300000, cwd = process.cwd(), onProgress } = options; // Default 5 min
|
||||
|
||||
// Ensure ready
|
||||
@@ -1362,7 +1558,11 @@ async function executeCodexLens(args: string[], options: ExecuteOptions = {}): P
|
||||
if (code === 0) {
|
||||
safeResolve({ success: true, output: stdout.trim() });
|
||||
} else {
|
||||
safeResolve({ success: false, error: stderr.trim() || `Process exited with code ${code}` });
|
||||
safeResolve({
|
||||
success: false,
|
||||
error: extractCodexLensFailure(stdout, stderr, code),
|
||||
output: stdout.trim() || undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1379,7 +1579,7 @@ async function initIndex(params: Params): Promise<ExecuteResult> {
|
||||
// Use 'index init' subcommand (new CLI structure)
|
||||
const args = ['index', 'init', path];
|
||||
if (languages && languages.length > 0) {
|
||||
args.push('--language', languages.join(','));
|
||||
args.push(...languages.flatMap((language) => ['--language', language]));
|
||||
}
|
||||
|
||||
return executeCodexLens(args, { cwd: path });
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { statSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import {
|
||||
ensureReady as ensureCodexLensReady,
|
||||
executeCodexLens,
|
||||
@@ -398,17 +400,106 @@ function splitResultsWithExtraFiles<T extends { file: string }>(
|
||||
return { results, extra_files };
|
||||
}
|
||||
|
||||
interface SearchScope {
|
||||
workingDirectory: string;
|
||||
searchPaths: string[];
|
||||
targetFile?: string;
|
||||
}
|
||||
|
||||
function sanitizeSearchQuery(query: string | undefined): string | undefined {
|
||||
if (!query) {
|
||||
return query;
|
||||
}
|
||||
|
||||
return query.replace(/\r?\n\s*/g, ' ').trim();
|
||||
}
|
||||
|
||||
function sanitizeSearchPath(pathValue: string | undefined): string | undefined {
|
||||
if (!pathValue) {
|
||||
return pathValue;
|
||||
}
|
||||
|
||||
return pathValue.replace(/\r?\n\s*/g, '').trim();
|
||||
}
|
||||
|
||||
function resolveSearchScope(pathValue: string = '.', paths: string[] = []): SearchScope {
|
||||
const normalizedPath = sanitizeSearchPath(pathValue) || '.';
|
||||
const normalizedPaths = paths.map((item) => sanitizeSearchPath(item) || item);
|
||||
const fallbackPath = normalizedPath || getProjectRoot();
|
||||
|
||||
try {
|
||||
const resolvedPath = resolve(fallbackPath);
|
||||
const stats = statSync(resolvedPath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
return {
|
||||
workingDirectory: dirname(resolvedPath),
|
||||
searchPaths: normalizedPaths.length > 0 ? normalizedPaths : [resolvedPath],
|
||||
targetFile: resolvedPath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
workingDirectory: resolvedPath,
|
||||
searchPaths: normalizedPaths.length > 0 ? normalizedPaths : ['.'],
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
workingDirectory: fallbackPath,
|
||||
searchPaths: normalizedPaths.length > 0 ? normalizedPaths : [normalizedPath || '.'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeResultFilePath(filePath: string, workingDirectory: string): string {
|
||||
return resolve(workingDirectory, filePath).replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function filterResultsToTargetFile<T extends { file: string }>(results: T[], scope: SearchScope): T[] {
|
||||
if (!scope.targetFile) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const normalizedTarget = scope.targetFile.replace(/\\/g, '/');
|
||||
return results.filter((result) => normalizeResultFilePath(result.file, scope.workingDirectory) === normalizedTarget);
|
||||
}
|
||||
|
||||
function collectBackendError(
|
||||
errors: string[],
|
||||
backendName: string,
|
||||
backendResult: PromiseSettledResult<SearchResult>,
|
||||
): void {
|
||||
if (backendResult.status === 'rejected') {
|
||||
errors.push(`${backendName}: ${String(backendResult.reason)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!backendResult.value.success) {
|
||||
errors.push(`${backendName}: ${backendResult.value.error || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function mergeWarnings(...warnings: Array<string | undefined>): string | undefined {
|
||||
const merged = [...new Set(
|
||||
warnings
|
||||
.filter((warning): warning is string => typeof warning === 'string' && warning.trim().length > 0)
|
||||
.map((warning) => warning.trim())
|
||||
)];
|
||||
return merged.length > 0 ? merged.join(' | ') : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CodexLens index exists for current directory
|
||||
* @param path - Directory path to check
|
||||
* @returns Index status
|
||||
*/
|
||||
async function checkIndexStatus(path: string = '.'): Promise<IndexStatus> {
|
||||
const scope = resolveSearchScope(path);
|
||||
try {
|
||||
// Fetch both status and config in parallel
|
||||
const [statusResult, configResult] = await Promise.all([
|
||||
executeCodexLens(['status', '--json'], { cwd: path }),
|
||||
executeCodexLens(['config', 'show', '--json'], { cwd: path }),
|
||||
executeCodexLens(['status', '--json'], { cwd: scope.workingDirectory }),
|
||||
executeCodexLens(['config', 'show', '--json'], { cwd: scope.workingDirectory }),
|
||||
]);
|
||||
|
||||
// Parse config
|
||||
@@ -694,6 +785,7 @@ function buildRipgrepCommand(params: {
|
||||
*/
|
||||
async function executeInitAction(params: Params, force: boolean = false): Promise<SearchResult> {
|
||||
const { path = '.', languages } = params;
|
||||
const scope = resolveSearchScope(path);
|
||||
|
||||
// Check CodexLens availability
|
||||
const readyStatus = await ensureCodexLensReady();
|
||||
@@ -706,12 +798,12 @@ async function executeInitAction(params: Params, force: boolean = false): Promis
|
||||
|
||||
// Build args with --no-embeddings for FTS-only index (faster)
|
||||
// Use 'index init' subcommand (new CLI structure)
|
||||
const args = ['index', 'init', path, '--no-embeddings'];
|
||||
const args = ['index', 'init', scope.workingDirectory, '--no-embeddings'];
|
||||
if (force) {
|
||||
args.push('--force'); // Force full rebuild
|
||||
}
|
||||
if (languages && languages.length > 0) {
|
||||
args.push('--language', languages.join(','));
|
||||
args.push(...languages.flatMap((language) => ['--language', language]));
|
||||
}
|
||||
|
||||
// Track progress updates
|
||||
@@ -719,7 +811,7 @@ async function executeInitAction(params: Params, force: boolean = false): Promis
|
||||
let lastProgress: ProgressInfo | null = null;
|
||||
|
||||
const result = await executeCodexLens(args, {
|
||||
cwd: path,
|
||||
cwd: scope.workingDirectory,
|
||||
timeout: 1800000, // 30 minutes for large codebases
|
||||
onProgress: (progress: ProgressInfo) => {
|
||||
progressUpdates.push(progress);
|
||||
@@ -730,7 +822,7 @@ async function executeInitAction(params: Params, force: boolean = false): Promis
|
||||
// Build metadata with progress info
|
||||
const metadata: SearchMetadata = {
|
||||
action: force ? 'init_force' : 'init',
|
||||
path,
|
||||
path: scope.workingDirectory,
|
||||
};
|
||||
|
||||
if (lastProgress !== null) {
|
||||
@@ -766,8 +858,9 @@ async function executeInitAction(params: Params, force: boolean = false): Promis
|
||||
*/
|
||||
async function executeStatusAction(params: Params): Promise<SearchResult> {
|
||||
const { path = '.' } = params;
|
||||
const scope = resolveSearchScope(path);
|
||||
|
||||
const indexStatus = await checkIndexStatus(path);
|
||||
const indexStatus = await checkIndexStatus(scope.workingDirectory);
|
||||
|
||||
// Build detailed status message
|
||||
const statusParts: string[] = [];
|
||||
@@ -815,6 +908,7 @@ async function executeStatusAction(params: Params): Promise<SearchResult> {
|
||||
*/
|
||||
async function executeUpdateAction(params: Params): Promise<SearchResult> {
|
||||
const { path = '.', languages } = params;
|
||||
const scope = resolveSearchScope(path);
|
||||
|
||||
// Check CodexLens availability
|
||||
const readyStatus = await ensureCodexLensReady();
|
||||
@@ -826,7 +920,7 @@ async function executeUpdateAction(params: Params): Promise<SearchResult> {
|
||||
}
|
||||
|
||||
// Check if index exists first
|
||||
const indexStatus = await checkIndexStatus(path);
|
||||
const indexStatus = await checkIndexStatus(scope.workingDirectory);
|
||||
if (!indexStatus.indexed) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -836,9 +930,9 @@ async function executeUpdateAction(params: Params): Promise<SearchResult> {
|
||||
|
||||
// Build args for incremental init (without --force)
|
||||
// Use 'index init' subcommand (new CLI structure)
|
||||
const args = ['index', 'init', path];
|
||||
const args = ['index', 'init', scope.workingDirectory];
|
||||
if (languages && languages.length > 0) {
|
||||
args.push('--language', languages.join(','));
|
||||
args.push(...languages.flatMap((language) => ['--language', language]));
|
||||
}
|
||||
|
||||
// Track progress updates
|
||||
@@ -846,7 +940,7 @@ async function executeUpdateAction(params: Params): Promise<SearchResult> {
|
||||
let lastProgress: ProgressInfo | null = null;
|
||||
|
||||
const result = await executeCodexLens(args, {
|
||||
cwd: path,
|
||||
cwd: scope.workingDirectory,
|
||||
timeout: 600000, // 10 minutes for incremental updates
|
||||
onProgress: (progress: ProgressInfo) => {
|
||||
progressUpdates.push(progress);
|
||||
@@ -891,6 +985,7 @@ async function executeUpdateAction(params: Params): Promise<SearchResult> {
|
||||
*/
|
||||
async function executeWatchAction(params: Params): Promise<SearchResult> {
|
||||
const { path = '.', languages, debounce = 1000 } = params;
|
||||
const scope = resolveSearchScope(path);
|
||||
|
||||
// Check CodexLens availability
|
||||
const readyStatus = await ensureCodexLensReady();
|
||||
@@ -902,7 +997,7 @@ async function executeWatchAction(params: Params): Promise<SearchResult> {
|
||||
}
|
||||
|
||||
// Check if index exists first
|
||||
const indexStatus = await checkIndexStatus(path);
|
||||
const indexStatus = await checkIndexStatus(scope.workingDirectory);
|
||||
if (!indexStatus.indexed) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -911,15 +1006,15 @@ async function executeWatchAction(params: Params): Promise<SearchResult> {
|
||||
}
|
||||
|
||||
// Build args for watch command
|
||||
const args = ['watch', path, '--debounce', debounce.toString()];
|
||||
const args = ['watch', scope.workingDirectory, '--debounce', debounce.toString()];
|
||||
if (languages && languages.length > 0) {
|
||||
args.push('--language', languages.join(','));
|
||||
args.push(...languages.flatMap((language) => ['--language', language]));
|
||||
}
|
||||
|
||||
// Start watcher in background (non-blocking)
|
||||
// Note: The watcher runs until manually stopped
|
||||
const result = await executeCodexLens(args, {
|
||||
cwd: path,
|
||||
cwd: scope.workingDirectory,
|
||||
timeout: 5000, // Short timeout for initial startup check
|
||||
});
|
||||
|
||||
@@ -975,11 +1070,11 @@ async function executeFuzzyMode(params: Params): Promise<SearchResult> {
|
||||
// If both failed, return error
|
||||
if (resultsMap.size === 0) {
|
||||
const errors: string[] = [];
|
||||
if (ftsResult.status === 'rejected') errors.push(`FTS: ${ftsResult.reason}`);
|
||||
if (ripgrepResult.status === 'rejected') errors.push(`Ripgrep: ${ripgrepResult.reason}`);
|
||||
collectBackendError(errors, 'FTS', ftsResult);
|
||||
collectBackendError(errors, 'Ripgrep', ripgrepResult);
|
||||
return {
|
||||
success: false,
|
||||
error: `Both search backends failed: ${errors.join('; ')}`,
|
||||
error: `Both search backends failed: ${errors.join('; ') || 'unknown error'}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1032,6 +1127,7 @@ async function executeFuzzyMode(params: Params): Promise<SearchResult> {
|
||||
*/
|
||||
async function executeAutoMode(params: Params): Promise<SearchResult> {
|
||||
const { query, path = '.' } = params;
|
||||
const scope = resolveSearchScope(path);
|
||||
|
||||
if (!query) {
|
||||
return {
|
||||
@@ -1041,7 +1137,7 @@ async function executeAutoMode(params: Params): Promise<SearchResult> {
|
||||
}
|
||||
|
||||
// Check index status
|
||||
const indexStatus = await checkIndexStatus(path);
|
||||
const indexStatus = await checkIndexStatus(scope.workingDirectory);
|
||||
|
||||
// Classify intent with index and embeddings awareness
|
||||
const classification = classifyIntent(
|
||||
@@ -1098,6 +1194,7 @@ async function executeAutoMode(params: Params): Promise<SearchResult> {
|
||||
*/
|
||||
async function executeRipgrepMode(params: Params): Promise<SearchResult> {
|
||||
const { query, paths = [], contextLines = 0, maxResults = 5, extraFilesCount = 10, maxContentLength = 200, includeHidden = false, path = '.', regex = true, caseSensitive = true, tokenize = true, codeOnly = true, withDoc = false, excludeExtensions } = params;
|
||||
const scope = resolveSearchScope(path, paths);
|
||||
// withDoc overrides codeOnly
|
||||
const effectiveCodeOnly = withDoc ? false : codeOnly;
|
||||
|
||||
@@ -1126,7 +1223,7 @@ async function executeRipgrepMode(params: Params): Promise<SearchResult> {
|
||||
|
||||
// Use CodexLens fts mode as fallback
|
||||
const args = ['search', query, '--limit', totalToFetch.toString(), '--method', 'fts', '--json'];
|
||||
const result = await executeCodexLens(args, { cwd: path });
|
||||
const result = await executeCodexLens(args, { cwd: scope.workingDirectory });
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
@@ -1156,8 +1253,10 @@ async function executeRipgrepMode(params: Params): Promise<SearchResult> {
|
||||
// Keep empty results
|
||||
}
|
||||
|
||||
const scopedResults = filterResultsToTargetFile(allResults, scope);
|
||||
|
||||
// Split results: first N with full content, rest as file paths only
|
||||
const { results, extra_files } = splitResultsWithExtraFiles(allResults, maxResults, extraFilesCount);
|
||||
const { results, extra_files } = splitResultsWithExtraFiles(scopedResults, maxResults, extraFilesCount);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -1176,7 +1275,7 @@ async function executeRipgrepMode(params: Params): Promise<SearchResult> {
|
||||
// Use ripgrep - request more results to support split
|
||||
const { command, args, tokens } = buildRipgrepCommand({
|
||||
query,
|
||||
paths: paths.length > 0 ? paths : [path],
|
||||
paths: scope.searchPaths,
|
||||
contextLines,
|
||||
maxResults: totalToFetch, // Fetch more to support split
|
||||
includeHidden,
|
||||
@@ -1187,7 +1286,7 @@ async function executeRipgrepMode(params: Params): Promise<SearchResult> {
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: path || getProjectRoot(),
|
||||
cwd: scope.workingDirectory || getProjectRoot(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
@@ -1312,6 +1411,7 @@ async function executeRipgrepMode(params: Params): Promise<SearchResult> {
|
||||
*/
|
||||
async function executeCodexLensExactMode(params: Params): Promise<SearchResult> {
|
||||
const { query, path = '.', maxResults = 5, extraFilesCount = 10, maxContentLength = 200, enrich = false, excludeExtensions, codeOnly = true, withDoc = false, offset = 0 } = params;
|
||||
const scope = resolveSearchScope(path);
|
||||
// withDoc overrides codeOnly
|
||||
const effectiveCodeOnly = withDoc ? false : codeOnly;
|
||||
|
||||
@@ -1332,7 +1432,7 @@ async function executeCodexLensExactMode(params: Params): Promise<SearchResult>
|
||||
}
|
||||
|
||||
// Check index status
|
||||
const indexStatus = await checkIndexStatus(path);
|
||||
const indexStatus = await checkIndexStatus(scope.workingDirectory);
|
||||
|
||||
// Request more results to support split (full content + extra files)
|
||||
const totalToFetch = maxResults + extraFilesCount;
|
||||
@@ -1348,7 +1448,7 @@ async function executeCodexLensExactMode(params: Params): Promise<SearchResult>
|
||||
if (excludeExtensions && excludeExtensions.length > 0) {
|
||||
args.push('--exclude-extensions', excludeExtensions.join(','));
|
||||
}
|
||||
const result = await executeCodexLens(args, { cwd: path });
|
||||
const result = await executeCodexLens(args, { cwd: scope.workingDirectory });
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
@@ -1359,7 +1459,7 @@ async function executeCodexLensExactMode(params: Params): Promise<SearchResult>
|
||||
backend: 'codexlens',
|
||||
count: 0,
|
||||
query,
|
||||
warning: indexStatus.warning,
|
||||
warning: mergeWarnings(indexStatus.warning, result.warning),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1379,6 +1479,8 @@ async function executeCodexLensExactMode(params: Params): Promise<SearchResult>
|
||||
// Keep empty results
|
||||
}
|
||||
|
||||
allResults = filterResultsToTargetFile(allResults, scope);
|
||||
|
||||
// Fallback to fuzzy mode if exact returns no results
|
||||
if (allResults.length === 0) {
|
||||
const fuzzyArgs = ['search', query, '--limit', totalToFetch.toString(), '--offset', offset.toString(), '--method', 'fts', '--use-fuzzy', '--json'];
|
||||
@@ -1393,18 +1495,18 @@ async function executeCodexLensExactMode(params: Params): Promise<SearchResult>
|
||||
if (excludeExtensions && excludeExtensions.length > 0) {
|
||||
fuzzyArgs.push('--exclude-extensions', excludeExtensions.join(','));
|
||||
}
|
||||
const fuzzyResult = await executeCodexLens(fuzzyArgs, { cwd: path });
|
||||
const fuzzyResult = await executeCodexLens(fuzzyArgs, { cwd: scope.workingDirectory });
|
||||
|
||||
if (fuzzyResult.success) {
|
||||
try {
|
||||
const parsed = JSON.parse(stripAnsi(fuzzyResult.output || '{}'));
|
||||
const data = parsed.result?.results || parsed.results || parsed;
|
||||
allResults = (Array.isArray(data) ? data : []).map((item: any) => ({
|
||||
allResults = filterResultsToTargetFile((Array.isArray(data) ? data : []).map((item: any) => ({
|
||||
file: item.path || item.file,
|
||||
score: item.score || 0,
|
||||
content: truncateContent(item.content || item.excerpt, maxContentLength),
|
||||
symbol: item.symbol || null,
|
||||
}));
|
||||
})), scope);
|
||||
} catch {
|
||||
// Keep empty results
|
||||
}
|
||||
@@ -1421,7 +1523,7 @@ async function executeCodexLensExactMode(params: Params): Promise<SearchResult>
|
||||
backend: 'codexlens',
|
||||
count: results.length,
|
||||
query,
|
||||
warning: indexStatus.warning,
|
||||
warning: mergeWarnings(indexStatus.warning, fuzzyResult.warning),
|
||||
note: 'No exact matches found, showing fuzzy results',
|
||||
fallback: 'fuzzy',
|
||||
},
|
||||
@@ -1442,7 +1544,7 @@ async function executeCodexLensExactMode(params: Params): Promise<SearchResult>
|
||||
backend: 'codexlens',
|
||||
count: results.length,
|
||||
query,
|
||||
warning: indexStatus.warning,
|
||||
warning: mergeWarnings(indexStatus.warning, result.warning),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1455,6 +1557,7 @@ async function executeCodexLensExactMode(params: Params): Promise<SearchResult>
|
||||
async function executeHybridMode(params: Params): Promise<SearchResult> {
|
||||
const timer = createTimer();
|
||||
const { query, path = '.', maxResults = 5, extraFilesCount = 10, maxContentLength = 200, enrich = false, excludeExtensions, codeOnly = true, withDoc = false, offset = 0 } = params;
|
||||
const scope = resolveSearchScope(path);
|
||||
// withDoc overrides codeOnly
|
||||
const effectiveCodeOnly = withDoc ? false : codeOnly;
|
||||
|
||||
@@ -1476,7 +1579,7 @@ async function executeHybridMode(params: Params): Promise<SearchResult> {
|
||||
}
|
||||
|
||||
// Check index status
|
||||
const indexStatus = await checkIndexStatus(path);
|
||||
const indexStatus = await checkIndexStatus(scope.workingDirectory);
|
||||
timer.mark('index_status_check');
|
||||
|
||||
// Request more results to support split (full content + extra files)
|
||||
@@ -1493,7 +1596,7 @@ async function executeHybridMode(params: Params): Promise<SearchResult> {
|
||||
if (excludeExtensions && excludeExtensions.length > 0) {
|
||||
args.push('--exclude-extensions', excludeExtensions.join(','));
|
||||
}
|
||||
const result = await executeCodexLens(args, { cwd: path });
|
||||
const result = await executeCodexLens(args, { cwd: scope.workingDirectory });
|
||||
timer.mark('codexlens_search');
|
||||
|
||||
if (!result.success) {
|
||||
@@ -1506,7 +1609,7 @@ async function executeHybridMode(params: Params): Promise<SearchResult> {
|
||||
backend: 'codexlens',
|
||||
count: 0,
|
||||
query,
|
||||
warning: indexStatus.warning,
|
||||
warning: mergeWarnings(indexStatus.warning, result.warning),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1519,7 +1622,7 @@ async function executeHybridMode(params: Params): Promise<SearchResult> {
|
||||
try {
|
||||
const parsed = JSON.parse(stripAnsi(result.output || '{}'));
|
||||
const data = parsed.result?.results || parsed.results || parsed;
|
||||
allResults = (Array.isArray(data) ? data : []).map((item: any) => {
|
||||
allResults = filterResultsToTargetFile((Array.isArray(data) ? data : []).map((item: any) => {
|
||||
const rawScore = item.score || 0;
|
||||
// Hybrid mode returns distance scores (lower is better).
|
||||
// Convert to similarity scores (higher is better) for consistency.
|
||||
@@ -1531,7 +1634,7 @@ async function executeHybridMode(params: Params): Promise<SearchResult> {
|
||||
content: truncateContent(item.content || item.excerpt, maxContentLength),
|
||||
symbol: item.symbol || null,
|
||||
};
|
||||
});
|
||||
}), scope);
|
||||
timer.mark('parse_results');
|
||||
|
||||
initialCount = allResults.length;
|
||||
@@ -1562,7 +1665,7 @@ async function executeHybridMode(params: Params): Promise<SearchResult> {
|
||||
backend: 'codexlens',
|
||||
count: 0,
|
||||
query,
|
||||
warning: indexStatus.warning || 'Failed to parse JSON output',
|
||||
warning: mergeWarnings(indexStatus.warning, result.warning, 'Failed to parse JSON output'),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1591,7 +1694,7 @@ async function executeHybridMode(params: Params): Promise<SearchResult> {
|
||||
count: results.length,
|
||||
query,
|
||||
note,
|
||||
warning: indexStatus.warning,
|
||||
warning: mergeWarnings(indexStatus.warning, result.warning),
|
||||
suggested_weights: getRRFWeights(query),
|
||||
timing: TIMING_ENABLED ? timings : undefined,
|
||||
},
|
||||
@@ -1943,6 +2046,7 @@ function withTimeout<T>(promise: Promise<T>, ms: number, modeName: string): Prom
|
||||
*/
|
||||
async function executePriorityFallbackMode(params: Params): Promise<SearchResult> {
|
||||
const { query, path = '.' } = params;
|
||||
const scope = resolveSearchScope(path);
|
||||
const fallbackHistory: string[] = [];
|
||||
|
||||
if (!query) {
|
||||
@@ -1950,7 +2054,7 @@ async function executePriorityFallbackMode(params: Params): Promise<SearchResult
|
||||
}
|
||||
|
||||
// Check index status first
|
||||
const indexStatus = await checkIndexStatus(path);
|
||||
const indexStatus = await checkIndexStatus(scope.workingDirectory);
|
||||
|
||||
// 1. Try Hybrid search (highest priority) - 90s timeout for large indexes
|
||||
if (indexStatus.indexed && indexStatus.has_embeddings) {
|
||||
@@ -2034,13 +2138,15 @@ export const schema: ToolSchema = {
|
||||
name: 'smart_search',
|
||||
description: `Unified code search tool. Choose an action and provide its required parameters.
|
||||
|
||||
Recommended MCP flow: use **action=\"search\"** for lookups, **action=\"init\"** to create a static FTS index, and **action=\"update\"** when files change. Use **watch** only for explicit long-running auto-update sessions.
|
||||
|
||||
**Actions & Required Parameters:**
|
||||
|
||||
* **search** (default): Search file content.
|
||||
* **query** (string, **REQUIRED**): Content to search for.
|
||||
* *mode* (string): 'fuzzy' (default, FTS+ripgrep) or 'semantic' (dense+reranker).
|
||||
* *limit* (number): Max results (default: 20).
|
||||
* *path* (string): Directory to search (default: current).
|
||||
* *mode* (string): 'fuzzy' (default, FTS+ripgrep for stage-1 lexical search) or 'semantic' (dense+reranker, best when embeddings exist).
|
||||
* *limit* (number): Max results with full content (default: 5).
|
||||
* *path* (string): Directory or single file to search (default: current directory; file paths are auto-scoped back to that file).
|
||||
* *contextLines* (number): Context lines around matches (default: 0).
|
||||
* *regex* (boolean): Use regex matching (default: true).
|
||||
* *caseSensitive* (boolean): Case-sensitive search (default: true).
|
||||
@@ -2051,11 +2157,11 @@ export const schema: ToolSchema = {
|
||||
* *offset* (number): Pagination offset (default: 0).
|
||||
* *includeHidden* (boolean): Include hidden files (default: false).
|
||||
|
||||
* **init**: Create FTS index (incremental, skips existing).
|
||||
* **init**: Create a static FTS index (incremental, skips existing, no embeddings).
|
||||
* *path* (string): Directory to index (default: current).
|
||||
* *languages* (array): Languages to index (e.g., ["javascript", "typescript"]).
|
||||
|
||||
* **init_force**: Force full rebuild (delete and recreate index).
|
||||
* **init_force**: Force full rebuild (delete and recreate static index).
|
||||
* *path* (string): Directory to index (default: current).
|
||||
|
||||
* **status**: Check index status. (No required params)
|
||||
@@ -2070,7 +2176,7 @@ export const schema: ToolSchema = {
|
||||
smart_search(query="authentication logic") # Content search (default action)
|
||||
smart_search(query="MyClass", mode="semantic") # Semantic search
|
||||
smart_search(action="find_files", pattern="*.ts") # Find TypeScript files
|
||||
smart_search(action="init", path="/project") # Initialize index
|
||||
smart_search(action="init", path="/project") # Build static FTS index
|
||||
smart_search(query="auth", limit=10, offset=0) # Paginated search`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
@@ -2078,12 +2184,12 @@ export const schema: ToolSchema = {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['init', 'init_force', 'search', 'find_files', 'status', 'update', 'watch', 'search_files'],
|
||||
description: 'Action: search (content search), find_files (path pattern matching), init (create index, incremental), init_force (force full rebuild), status (check index), update (incremental update), watch (auto-update). Note: search_files is deprecated.',
|
||||
description: 'Action: search (content search; default and recommended), find_files (path pattern matching), init (create static FTS index, incremental), init_force (force full rebuild), status (check index), update (incremental refresh), watch (auto-update watcher; opt-in). Note: search_files is deprecated.',
|
||||
default: 'search',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Content search query (for action="search")',
|
||||
description: 'Content search query (for action="search"). Recommended default workflow: action=search with fuzzy mode, plus init/update for static indexing.',
|
||||
},
|
||||
pattern: {
|
||||
type: 'string',
|
||||
@@ -2092,7 +2198,7 @@ export const schema: ToolSchema = {
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: SEARCH_MODES,
|
||||
description: 'Search mode: fuzzy (FTS + ripgrep fusion, default), semantic (dense + reranker for natural language queries)',
|
||||
description: 'Search mode: fuzzy (FTS + ripgrep fusion, default) or semantic (dense + reranker for natural language queries when embeddings exist).',
|
||||
default: 'fuzzy',
|
||||
},
|
||||
output_mode: {
|
||||
@@ -2103,7 +2209,7 @@ export const schema: ToolSchema = {
|
||||
},
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'Directory path for init/search actions (default: current directory)',
|
||||
description: 'Directory path for init/search actions (default: current directory). For action=search, a single file path is also accepted and results are automatically scoped back to that file.',
|
||||
},
|
||||
paths: {
|
||||
type: 'array',
|
||||
@@ -2120,13 +2226,13 @@ export const schema: ToolSchema = {
|
||||
},
|
||||
maxResults: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results (default: 20)',
|
||||
default: 20,
|
||||
description: 'Maximum number of full-content results (default: 5)',
|
||||
default: 5,
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Alias for maxResults (default: 20)',
|
||||
default: 20,
|
||||
description: 'Alias for maxResults (default: 5)',
|
||||
default: 5,
|
||||
},
|
||||
extraFilesCount: {
|
||||
type: 'number',
|
||||
@@ -2184,6 +2290,7 @@ export const schema: ToolSchema = {
|
||||
*/
|
||||
async function executeFindFilesAction(params: Params): Promise<SearchResult> {
|
||||
const { pattern, path = '.', limit = 20, offset = 0, includeHidden = false, caseSensitive = true } = params;
|
||||
const scope = resolveSearchScope(path);
|
||||
|
||||
if (!pattern) {
|
||||
return {
|
||||
@@ -2207,7 +2314,7 @@ async function executeFindFilesAction(params: Params): Promise<SearchResult> {
|
||||
|
||||
// Try CodexLens file list command
|
||||
const args = ['list-files', '--json'];
|
||||
const result = await executeCodexLens(args, { cwd: path });
|
||||
const result = await executeCodexLens(args, { cwd: scope.workingDirectory });
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
@@ -2290,7 +2397,7 @@ async function executeFindFilesAction(params: Params): Promise<SearchResult> {
|
||||
}
|
||||
|
||||
const child = spawn('rg', args, {
|
||||
cwd: path || getProjectRoot(),
|
||||
cwd: scope.workingDirectory || getProjectRoot(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
@@ -2485,11 +2592,20 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
|
||||
return { success: false, error: `Invalid params: ${parsed.error.message}` };
|
||||
}
|
||||
|
||||
parsed.data.query = sanitizeSearchQuery(parsed.data.query);
|
||||
parsed.data.pattern = sanitizeSearchPath(parsed.data.pattern);
|
||||
parsed.data.path = sanitizeSearchPath(parsed.data.path);
|
||||
parsed.data.paths = parsed.data.paths.map((item) => sanitizeSearchPath(item) || item);
|
||||
|
||||
const { action, mode, output_mode, offset = 0 } = parsed.data;
|
||||
|
||||
// Sync limit and maxResults - use the larger of the two if both provided
|
||||
// This ensures user-provided values take precedence over defaults
|
||||
const effectiveLimit = Math.max(parsed.data.limit || 20, parsed.data.maxResults || 20);
|
||||
// Sync limit and maxResults while preserving explicit small values.
|
||||
// If both are provided, use the larger one. If only one is provided, honor it.
|
||||
const rawLimit = typeof params.limit === 'number' ? params.limit : undefined;
|
||||
const rawMaxResults = typeof params.maxResults === 'number' ? params.maxResults : undefined;
|
||||
const effectiveLimit = rawLimit !== undefined && rawMaxResults !== undefined
|
||||
? Math.max(rawLimit, rawMaxResults)
|
||||
: rawMaxResults ?? rawLimit ?? parsed.data.maxResults ?? parsed.data.limit ?? 5;
|
||||
parsed.data.maxResults = effectiveLimit;
|
||||
parsed.data.limit = effectiveLimit;
|
||||
|
||||
@@ -2613,7 +2729,7 @@ export async function executeInitWithProgress(
|
||||
args.push('--force'); // Force full rebuild
|
||||
}
|
||||
if (languages && languages.length > 0) {
|
||||
args.push('--language', languages.join(','));
|
||||
args.push(...languages.flatMap((language) => ['--language', language]));
|
||||
}
|
||||
|
||||
// Track progress updates
|
||||
|
||||
@@ -765,6 +765,37 @@ export class UvManager {
|
||||
}
|
||||
}
|
||||
|
||||
export function getPreferredCodexLensPythonSpec(): string {
|
||||
const override = process.env.CCW_PYTHON?.trim();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
|
||||
if (!IS_WINDOWS) {
|
||||
return '>=3.10,<3.13';
|
||||
}
|
||||
|
||||
// Prefer 3.11/3.10 on Windows because current CodexLens semantic GPU extras
|
||||
// depend on onnxruntime 1.15.x wheels, which are not consistently available for cp312.
|
||||
const preferredVersions = ['3.11', '3.10', '3.12'];
|
||||
for (const version of preferredVersions) {
|
||||
try {
|
||||
const output = execSync(`py -${version} --version`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUTS.PYTHON_VERSION,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
if (output.includes(`Python ${version}`)) {
|
||||
return version;
|
||||
}
|
||||
} catch {
|
||||
// Try next installed version
|
||||
}
|
||||
}
|
||||
|
||||
return '>=3.10,<3.13';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a UvManager with default settings for CodexLens
|
||||
* @param dataDir - Base data directory (defaults to ~/.codexlens)
|
||||
@@ -772,9 +803,10 @@ export class UvManager {
|
||||
*/
|
||||
export function createCodexLensUvManager(dataDir?: string): UvManager {
|
||||
const baseDir = dataDir ?? getCodexLensDataDir();
|
||||
void baseDir;
|
||||
return new UvManager({
|
||||
venvPath: getCodexLensVenvDir(),
|
||||
pythonVersion: '>=3.10,<3.13', // onnxruntime compatibility
|
||||
pythonVersion: getPreferredCodexLensPythonSpec(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user