feat(cursor): Enhance Cursor tool handling with a registry and improved processing

Introduced a registry for Cursor tool handlers to streamline the processing of various tool calls, including read, write, edit, delete, grep, ls, glob, semantic search, and read lints. This refactor allows for better organization and normalization of tool inputs and outputs.

Additionally, updated the CursorToolCallEvent interface to accommodate new tool calls and their respective arguments. Enhanced logging for raw events and unrecognized tool call structures for improved debugging.

Affected files:
- cursor-provider.ts: Added CURSOR_TOOL_HANDLERS and refactored tool call processing.
- log-parser.ts: Updated tool categories and added summaries for new tools.
- cursor-cli.ts: Expanded CursorToolCallEvent interface to include new tool calls.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Kacper
2025-12-30 17:35:39 +01:00
parent dac916496c
commit 3c6736bc44
3 changed files with 542 additions and 35 deletions

View File

@@ -44,6 +44,189 @@ import { spawnJSONLProcess, execInWsl } from '@automaker/platform';
// Create logger for this module
const logger = createLogger('CursorProvider');
// =============================================================================
// Cursor Tool Handler Registry
// =============================================================================
/**
* Tool handler definition for mapping Cursor tool calls to normalized format
*/
interface CursorToolHandler<TArgs = unknown, TResult = unknown> {
/** The normalized tool name (e.g., 'Read', 'Write') */
name: string;
/** Extract and normalize input from Cursor's args format */
mapInput: (args: TArgs) => unknown;
/** Format the result content for display (optional) */
formatResult?: (result: TResult, args?: TArgs) => string;
/** Format rejected result (optional) */
formatRejected?: (reason: string) => string;
}
/**
* Registry of Cursor tool handlers
* Each handler knows how to normalize its specific tool call type
*/
const CURSOR_TOOL_HANDLERS: Record<string, CursorToolHandler<any, any>> = {
readToolCall: {
name: 'Read',
mapInput: (args: { path: string }) => ({ file_path: args.path }),
formatResult: (result: { content: string }) => result.content,
},
writeToolCall: {
name: 'Write',
mapInput: (args: { path: string; fileText: string }) => ({
file_path: args.path,
content: args.fileText,
}),
formatResult: (result: { linesCreated: number; path: string }) =>
`Wrote ${result.linesCreated} lines to ${result.path}`,
},
editToolCall: {
name: 'Edit',
mapInput: (args: { path: string; oldText?: string; newText?: string }) => ({
file_path: args.path,
old_string: args.oldText,
new_string: args.newText,
}),
formatResult: (_result: unknown, args?: { path: string }) => `Edited file: ${args?.path}`,
},
shellToolCall: {
name: 'Bash',
mapInput: (args: { command: string }) => ({ command: args.command }),
formatResult: (result: { exitCode: number; stdout?: string; stderr?: string }) => {
let content = `Exit code: ${result.exitCode}`;
if (result.stdout) content += `\n${result.stdout}`;
if (result.stderr) content += `\nStderr: ${result.stderr}`;
return content;
},
formatRejected: (reason: string) => `Rejected: ${reason}`,
},
deleteToolCall: {
name: 'Delete',
mapInput: (args: { path: string }) => ({ file_path: args.path }),
formatResult: (_result: unknown, args?: { path: string }) => `Deleted: ${args?.path}`,
formatRejected: (reason: string) => `Delete rejected: ${reason}`,
},
grepToolCall: {
name: 'Grep',
mapInput: (args: { pattern: string; path?: string }) => ({
pattern: args.pattern,
path: args.path,
}),
formatResult: (result: { matchedLines: number }) =>
`Found ${result.matchedLines} matching lines`,
},
lsToolCall: {
name: 'Ls',
mapInput: (args: { path: string }) => ({ path: args.path }),
formatResult: (result: { childrenFiles: number; childrenDirs: number }) =>
`Found ${result.childrenFiles} files, ${result.childrenDirs} directories`,
},
globToolCall: {
name: 'Glob',
mapInput: (args: { globPattern: string; targetDirectory?: string }) => ({
pattern: args.globPattern,
path: args.targetDirectory,
}),
formatResult: (result: { totalFiles: number }) => `Found ${result.totalFiles} matching files`,
},
semSearchToolCall: {
name: 'SemanticSearch',
mapInput: (args: { query: string; targetDirectories?: string[]; explanation?: string }) => ({
query: args.query,
targetDirectories: args.targetDirectories,
explanation: args.explanation,
}),
formatResult: (result: { results: string; codeResults?: unknown[] }) => {
const resultCount = result.codeResults?.length || 0;
return resultCount > 0
? `Found ${resultCount} semantic search result(s)`
: result.results || 'No results found';
},
},
readLintsToolCall: {
name: 'ReadLints',
mapInput: (args: { paths: string[] }) => ({ paths: args.paths }),
formatResult: (result: { totalDiagnostics: number; totalFiles: number }) =>
`Found ${result.totalDiagnostics} diagnostic(s) in ${result.totalFiles} file(s)`,
},
};
/**
* Process a Cursor tool call using the handler registry
* Returns { toolName, toolInput } or null if tool type is unknown
*/
function processCursorToolCall(
toolCall: CursorToolCallEvent['tool_call']
): { toolName: string; toolInput: unknown } | null {
// Check each registered handler
for (const [key, handler] of Object.entries(CURSOR_TOOL_HANDLERS)) {
const toolData = toolCall[key as keyof typeof toolCall] as { args?: unknown } | undefined;
if (toolData) {
// Skip if args not yet populated (partial streaming event)
if (!toolData.args) return null;
return {
toolName: handler.name,
toolInput: handler.mapInput(toolData.args),
};
}
}
// Handle generic function call (fallback)
if (toolCall.function) {
let toolInput: unknown;
try {
toolInput = JSON.parse(toolCall.function.arguments || '{}');
} catch {
toolInput = { raw: toolCall.function.arguments };
}
return {
toolName: toolCall.function.name,
toolInput,
};
}
return null;
}
/**
* Format the result content for a completed Cursor tool call
*/
function formatCursorToolResult(toolCall: CursorToolCallEvent['tool_call']): string {
for (const [key, handler] of Object.entries(CURSOR_TOOL_HANDLERS)) {
const toolData = toolCall[key as keyof typeof toolCall] as
| {
args?: unknown;
result?: { success?: unknown; rejected?: { reason: string } };
}
| undefined;
if (toolData?.result) {
if (toolData.result.success && handler.formatResult) {
return handler.formatResult(toolData.result.success, toolData.args);
}
if (toolData.result.rejected && handler.formatRejected) {
return handler.formatRejected(toolData.result.rejected.reason);
}
}
}
return '';
}
// =============================================================================
// Error Codes
// =============================================================================
/**
* Cursor-specific error codes for detailed error handling
*/
@@ -198,34 +381,20 @@ export class CursorProvider extends CliProvider {
const toolEvent = cursorEvent as CursorToolCallEvent;
const toolCall = toolEvent.tool_call;
// Determine tool name and input
let toolName: string;
let toolInput: unknown;
if (toolCall.readToolCall) {
// Skip if args not yet populated (partial streaming event)
if (!toolCall.readToolCall.args) return null;
toolName = 'Read';
toolInput = { file_path: toolCall.readToolCall.args.path };
} else if (toolCall.writeToolCall) {
// Skip if args not yet populated (partial streaming event)
if (!toolCall.writeToolCall.args) return null;
toolName = 'Write';
toolInput = {
file_path: toolCall.writeToolCall.args.path,
content: toolCall.writeToolCall.args.fileText,
};
} else if (toolCall.function) {
toolName = toolCall.function.name;
try {
toolInput = JSON.parse(toolCall.function.arguments || '{}');
} catch {
toolInput = { raw: toolCall.function.arguments };
}
} else {
// Use the tool handler registry to process the tool call
const processed = processCursorToolCall(toolCall);
if (!processed) {
// Log unrecognized tool call structure for debugging
const toolCallKeys = Object.keys(toolCall);
logger.warn(
`[UNHANDLED TOOL_CALL] Unknown tool call structure. Keys: ${toolCallKeys.join(', ')}. ` +
`Full tool_call: ${JSON.stringify(toolCall).substring(0, 500)}`
);
return null;
}
const { toolName, toolInput } = processed;
// For started events, emit tool_use
if (toolEvent.subtype === 'started') {
return {
@@ -247,13 +416,7 @@ export class CursorProvider extends CliProvider {
// For completed events, emit both tool_use and tool_result
if (toolEvent.subtype === 'completed') {
let resultContent = '';
if (toolCall.readToolCall?.result?.success) {
resultContent = toolCall.readToolCall.result.success.content;
} else if (toolCall.writeToolCall?.result?.success) {
resultContent = `Wrote ${toolCall.writeToolCall.result.success.linesCreated} lines to ${toolCall.writeToolCall.result.success.path}`;
}
const resultContent = formatCursorToolResult(toolCall);
return {
type: 'assistant',
@@ -469,10 +632,43 @@ export class CursorProvider extends CliProvider {
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
const debugRawEvents =
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' ||
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1';
try {
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
const event = rawEvent as CursorStreamEvent;
// Log raw event for debugging
if (debugRawEvents) {
logger.info(`[RAW EVENT] type=${event.type} subtype=${(event as any).subtype || 'none'}`);
if (event.type === 'tool_call') {
const toolEvent = event as CursorToolCallEvent;
const tc = toolEvent.tool_call;
const toolTypes =
[
tc.readToolCall && 'read',
tc.writeToolCall && 'write',
tc.editToolCall && 'edit',
tc.shellToolCall && 'shell',
tc.deleteToolCall && 'delete',
tc.grepToolCall && 'grep',
tc.lsToolCall && 'ls',
tc.globToolCall && 'glob',
tc.function && `function:${tc.function.name}`,
]
.filter(Boolean)
.join(',') || 'unknown';
logger.info(
`[RAW TOOL_CALL] call_id=${toolEvent.call_id} types=[${toolTypes}]` +
(tc.shellToolCall ? ` cmd="${tc.shellToolCall.args?.command}"` : '') +
(tc.writeToolCall ? ` path="${tc.writeToolCall.args?.path}"` : '')
);
}
}
// Capture session ID from system init
if (event.type === 'system' && (event as CursorSystemEvent).subtype === 'init') {
sessionId = event.session_id;
@@ -481,6 +677,9 @@ export class CursorProvider extends CliProvider {
// Normalize and yield the event
const normalized = this.normalizeEvent(event);
if (!normalized && debugRawEvents) {
logger.info(`[DROPPED EVENT] type=${event.type} - normalizeEvent returned null`);
}
if (normalized) {
// Ensure session_id is always set
if (!normalized.session_id && sessionId) {

View File

@@ -58,12 +58,16 @@ const TOOL_CATEGORIES: Record<string, ToolCategory> = {
Bash: 'bash',
Grep: 'search',
Glob: 'search',
Ls: 'read',
Delete: 'write',
WebSearch: 'search',
WebFetch: 'read',
TodoWrite: 'todo',
Task: 'task',
NotebookEdit: 'edit',
KillShell: 'bash',
SemanticSearch: 'search',
ReadLints: 'read',
};
/**
@@ -323,6 +327,15 @@ export function generateToolSummary(toolName: string, content: string): string |
case 'KillShell': {
return 'Terminating shell session';
}
case 'SemanticSearch': {
const query = parsed.query as string | undefined;
return `Semantic search: "${query?.slice(0, 30) || ''}"`;
}
case 'ReadLints': {
const paths = parsed.paths as string[] | undefined;
const pathCount = paths?.length || 0;
return `Reading lints for ${pathCount} file(s)`;
}
default:
return undefined;
}
@@ -363,7 +376,7 @@ function normalizeCursorToolCall(
// Read tool
if (toolCall.readToolCall) {
const path = toolCall.readToolCall.args.path;
const path = toolCall.readToolCall.args?.path || 'unknown';
const result = toolCall.readToolCall.result?.success;
return {
@@ -412,7 +425,218 @@ function normalizeCursorToolCall(
};
}
// Generic function tool
// Edit tool
if (toolCall.editToolCall) {
const path = toolCall.editToolCall.args?.path || 'unknown';
return {
...baseEntry,
id: `${baseEntry.id}-${event.call_id}`,
type: 'tool_call' as LogEntryType,
title: isStarted ? `Editing ${path}` : `Edited ${path}`,
content: `Path: ${path}`,
collapsed: true,
metadata: {
toolName: 'Edit',
toolCategory: 'edit' as ToolCategory,
filePath: path,
summary: isCompleted ? `Edited file` : `Editing file...`,
},
};
}
// Shell/Bash tool
if (toolCall.shellToolCall) {
const command = toolCall.shellToolCall.args?.command || '';
const result = toolCall.shellToolCall.result;
const shortCmd = command.length > 50 ? command.slice(0, 50) + '...' : command;
let content = `Command: ${command}`;
if (isCompleted && result?.success) {
content += `\nExit code: ${result.success.exitCode}`;
} else if (isCompleted && result?.rejected) {
content += `\nRejected: ${result.rejected.reason}`;
}
return {
...baseEntry,
id: `${baseEntry.id}-${event.call_id}`,
type: 'tool_call' as LogEntryType,
title: isStarted ? `Running: ${shortCmd}` : `Ran: ${shortCmd}`,
content,
collapsed: true,
metadata: {
toolName: 'Bash',
toolCategory: 'bash' as ToolCategory,
summary: isCompleted
? result?.success
? `Exit ${result.success.exitCode}`
: result?.rejected
? 'Rejected'
: 'Completed'
: `Running...`,
},
};
}
// Delete tool
if (toolCall.deleteToolCall) {
const path = toolCall.deleteToolCall.args?.path || 'unknown';
const result = toolCall.deleteToolCall.result;
let content = `Path: ${path}`;
if (isCompleted && result?.rejected) {
content += `\nRejected: ${result.rejected.reason}`;
}
return {
...baseEntry,
id: `${baseEntry.id}-${event.call_id}`,
type: 'tool_call' as LogEntryType,
title: isStarted ? `Deleting ${path}` : `Deleted ${path}`,
content,
collapsed: true,
metadata: {
toolName: 'Delete',
toolCategory: 'write' as ToolCategory,
filePath: path,
summary: isCompleted ? (result?.rejected ? 'Rejected' : 'Deleted') : `Deleting...`,
},
};
}
// Grep tool
if (toolCall.grepToolCall) {
const pattern = toolCall.grepToolCall.args?.pattern || '';
const searchPath = toolCall.grepToolCall.args?.path;
const result = toolCall.grepToolCall.result?.success;
return {
...baseEntry,
id: `${baseEntry.id}-${event.call_id}`,
type: 'tool_call' as LogEntryType,
title: isStarted ? `Searching: "${pattern}"` : `Searched: "${pattern}"`,
content: `Pattern: ${pattern}${searchPath ? `\nPath: ${searchPath}` : ''}${
isCompleted && result ? `\nMatched ${result.matchedLines} lines` : ''
}`,
collapsed: true,
metadata: {
toolName: 'Grep',
toolCategory: 'search' as ToolCategory,
summary: isCompleted ? `Found ${result?.matchedLines || 0} matches` : `Searching...`,
},
};
}
// Ls tool
if (toolCall.lsToolCall) {
const path = toolCall.lsToolCall.args?.path || '.';
const result = toolCall.lsToolCall.result?.success;
return {
...baseEntry,
id: `${baseEntry.id}-${event.call_id}`,
type: 'tool_call' as LogEntryType,
title: isStarted ? `Listing ${path}` : `Listed ${path}`,
content: `Path: ${path}${
isCompleted && result
? `\n${result.childrenFiles} files, ${result.childrenDirs} directories`
: ''
}`,
collapsed: true,
metadata: {
toolName: 'Ls',
toolCategory: 'read' as ToolCategory,
filePath: path,
summary: isCompleted
? `${result?.childrenFiles || 0} files, ${result?.childrenDirs || 0} dirs`
: `Listing...`,
},
};
}
// Glob tool
if (toolCall.globToolCall) {
const pattern = toolCall.globToolCall.args?.globPattern || '';
const targetDir = toolCall.globToolCall.args?.targetDirectory;
const result = toolCall.globToolCall.result?.success;
return {
...baseEntry,
id: `${baseEntry.id}-${event.call_id}`,
type: 'tool_call' as LogEntryType,
title: isStarted ? `Finding: ${pattern}` : `Found: ${pattern}`,
content: `Pattern: ${pattern}${targetDir ? `\nDirectory: ${targetDir}` : ''}${
isCompleted && result ? `\nFound ${result.totalFiles} files` : ''
}`,
collapsed: true,
metadata: {
toolName: 'Glob',
toolCategory: 'search' as ToolCategory,
summary: isCompleted ? `Found ${result?.totalFiles || 0} files` : `Finding...`,
},
};
}
// Semantic Search tool
if (toolCall.semSearchToolCall) {
const query = toolCall.semSearchToolCall.args?.query || '';
const targetDirs = toolCall.semSearchToolCall.args?.targetDirectories;
const result = toolCall.semSearchToolCall.result?.success;
const shortQuery = query.length > 40 ? query.slice(0, 40) + '...' : query;
const resultCount = result?.codeResults?.length || 0;
return {
...baseEntry,
id: `${baseEntry.id}-${event.call_id}`,
type: 'tool_call' as LogEntryType,
title: isStarted ? `Semantic search: "${shortQuery}"` : `Searched: "${shortQuery}"`,
content: `Query: ${query}${targetDirs?.length ? `\nDirectories: ${targetDirs.join(', ')}` : ''}${
isCompleted
? `\n${resultCount > 0 ? `Found ${resultCount} result(s)` : result?.results || 'No results'}`
: ''
}`,
collapsed: true,
metadata: {
toolName: 'SemanticSearch',
toolCategory: 'search' as ToolCategory,
summary: isCompleted
? resultCount > 0
? `Found ${resultCount} result(s)`
: 'No results'
: `Searching...`,
},
};
}
// Read Lints tool
if (toolCall.readLintsToolCall) {
const paths = toolCall.readLintsToolCall.args?.paths || [];
const result = toolCall.readLintsToolCall.result?.success;
const pathCount = paths.length;
return {
...baseEntry,
id: `${baseEntry.id}-${event.call_id}`,
type: 'tool_call' as LogEntryType,
title: isStarted ? `Reading lints for ${pathCount} file(s)` : `Read lints`,
content: `Paths: ${paths.join(', ')}${
isCompleted && result
? `\nFound ${result.totalDiagnostics} diagnostic(s) in ${result.totalFiles} file(s)`
: ''
}`,
collapsed: true,
metadata: {
toolName: 'ReadLints',
toolCategory: 'read' as ToolCategory,
summary: isCompleted
? `${result?.totalDiagnostics || 0} diagnostic(s)`
: `Reading lints...`,
},
};
}
// Generic function tool (fallback)
if (toolCall.function) {
const name = toolCall.function.name;
const args = toolCall.function.arguments;

View File

@@ -264,7 +264,7 @@ export interface CursorToolCallEvent {
call_id: string;
tool_call: {
readToolCall?: {
args: { path: string };
args: { path: string; offset?: number; limit?: number };
result?: {
success?: {
content: string;
@@ -285,6 +285,90 @@ export interface CursorToolCallEvent {
};
};
};
editToolCall?: {
args: { path: string; oldText?: string; newText?: string };
result?: {
success?: Record<string, unknown>;
};
};
shellToolCall?: {
args: { command: string };
result?: {
success?: {
exitCode: number;
stdout?: string;
stderr?: string;
};
rejected?: {
reason: string;
};
};
};
deleteToolCall?: {
args: { path: string };
result?: {
success?: Record<string, unknown>;
rejected?: {
reason: string;
};
};
};
grepToolCall?: {
args: { pattern: string; path?: string };
result?: {
success?: {
matchedLines: number;
};
};
};
lsToolCall?: {
args: { path: string; ignore?: string[] };
result?: {
success?: {
childrenFiles: number;
childrenDirs: number;
};
};
};
globToolCall?: {
args: { globPattern: string; targetDirectory?: string };
result?: {
success?: {
totalFiles: number;
};
};
};
semSearchToolCall?: {
args: { query: string; targetDirectories?: string[]; explanation?: string };
result?: {
success?: {
results: string;
codeResults?: Array<{
path: string;
content: string;
score?: number;
}>;
};
};
};
readLintsToolCall?: {
args: { paths: string[] };
result?: {
success?: {
fileDiagnostics: Array<{
path: string;
diagnostics: Array<{
message: string;
severity: string;
line?: number;
column?: number;
}>;
}>;
totalFiles: number;
totalDiagnostics: number;
};
};
};
function?: {
name: string;
arguments: string;