diff --git a/agents/cursor.ts b/agents/cursor.ts index 33d9bbf..158e7d2 100644 --- a/agents/cursor.ts +++ b/agents/cursor.ts @@ -2,10 +2,136 @@ import { spawn } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { log } from "../utils/cli.ts"; +import { formatToolCallLog, log } from "../utils/cli.ts"; import { addInstructions } from "./instructions.ts"; import { agent, type ConfigureMcpServersParams, installFromCurl } from "./shared.ts"; +// cursor cli event types inferred from stream-json output +interface CursorSystemEvent { + type: "system"; + subtype?: string; + [key: string]: unknown; +} + +interface CursorUserEvent { + type: "user"; + message?: { + role: string; + content: Array<{ type: string; text?: string }>; + }; + [key: string]: unknown; +} + +interface CursorThinkingEvent { + type: "thinking"; + subtype: "delta" | "completed"; + text?: string; + [key: string]: unknown; +} + +interface CursorAssistantEvent { + type: "assistant"; + model_call_id?: string; + message?: { + role: string; + content: Array<{ type: string; text?: string }>; + }; + [key: string]: unknown; +} + +interface CursorToolCallEvent { + type: "tool_call"; + subtype: "started" | "completed"; + call_id?: string; + tool_call?: { + mcpToolCall?: { + args?: { + name?: string; + args?: unknown; + toolName?: string; + providerIdentifier?: string; + }; + result?: { + success?: { + content?: Array<{ text?: { text?: string } }>; + isError?: boolean; + }; + }; + }; + }; + [key: string]: unknown; +} + +interface CursorResultEvent { + type: "result"; + subtype: "success" | "error"; + result?: string; + duration_ms?: number; + [key: string]: unknown; +} + +type CursorEvent = + | CursorSystemEvent + | CursorUserEvent + | CursorThinkingEvent + | CursorAssistantEvent + | CursorToolCallEvent + | CursorResultEvent; + +const messageHandlers = { + system: (_event: CursorSystemEvent) => { + // system init events - no logging needed + }, + user: (_event: CursorUserEvent) => { + // user messages already logged in prompt box + }, + thinking: (_event: CursorThinkingEvent) => { + // thinking events are internal - no logging needed + }, + assistant: (event: CursorAssistantEvent) => { + // only log finalized messages (ones with model_call_id) + // cursor emits each message twice: once without model_call_id, then again with it + if (event.model_call_id) { + const text = event.message?.content?.[0]?.text; + if (text?.trim()) { + log.box(text.trim(), { title: "Cursor" }); + } + } + }, + tool_call: (event: CursorToolCallEvent) => { + if (event.subtype === "started") { + // handle both MCP tools and built-in tools (bash, WebFetch, etc) + const mcpToolCall = event.tool_call?.mcpToolCall; + const builtinToolCall = (event.tool_call as any)?.builtinToolCall; + + if (mcpToolCall?.args?.toolName && mcpToolCall?.args?.args) { + const formatted = formatToolCallLog({ + toolName: mcpToolCall.args.toolName, + input: mcpToolCall.args.args, + }); + log.info(formatted); + } else if (builtinToolCall?.args?.name && builtinToolCall?.args?.args) { + const formatted = formatToolCallLog({ + toolName: builtinToolCall.args.name, + input: builtinToolCall.args.args, + }); + log.info(formatted); + } + } else if (event.subtype === "completed") { + const isError = event.tool_call?.mcpToolCall?.result?.success?.isError; + if (isError) { + log.warning("Tool call failed"); + } + } + }, + result: async (event: CursorResultEvent) => { + if (event.subtype === "success" && event.duration_ms) { + const durationSec = (event.duration_ms / 1000).toFixed(1); + log.debug(`Cursor completed in ${durationSec}s`); + } + }, +}; + export const cursor = agent({ name: "cursor", install: async () => { @@ -42,9 +168,12 @@ export const cursor = agent({ { cwd: process.cwd(), env: { - ...process.env, CURSOR_API_KEY: apiKey, GITHUB_INSTALLATION_TOKEN: githubInstallationToken, + LOG_LEVEL: process.env.LOG_LEVEL, + NODE_ENV: process.env.NODE_ENV, + HOME: process.env.HOME, + PATH: process.env.PATH, // Don't override HOME - Cursor CLI needs access to macOS keychain // MCP config is written to tempDir/.cursor/mcp.json which Cursor will find }, @@ -54,7 +183,6 @@ export const cursor = agent({ let stdout = ""; let stderr = ""; - let jsonBuffer = ""; child.on("spawn", () => { log.debug("Cursor CLI process spawned"); @@ -63,26 +191,21 @@ export const cursor = agent({ child.stdout?.on("data", async (data) => { const text = data.toString(); stdout += text; - jsonBuffer += text; - // parse ndjson (newline-delimited json) - const lines = jsonBuffer.split("\n"); - // keep last incomplete line in buffer - jsonBuffer = lines.pop() || ""; + try { + const event = JSON.parse(text) as CursorEvent; - for (const line of lines) { - const trimmedLine = line.trim(); - if (!trimmedLine) continue; - - try { - const event = JSON.parse(trimmedLine); - // log everything for now - we'll infer types from real output - log.debug(`[cursor event] ${JSON.stringify(event, null, 2)}`); - } catch { - // if json parse fails, might be partial line or non-json output - // log debug info but don't crash - log.debug(`failed to parse json line: ${trimmedLine.substring(0, 100)}`); + // route to appropriate handler + const handler = messageHandlers[event.type as keyof typeof messageHandlers]; + if (handler) { + await handler(event as never); } + + // debug: log all events + log.debug(`[cursor event] ${JSON.stringify(event, null, 2)}`); + } catch { + // ignore parse errors - might be formatted tool call logs from cursor cli + // our handlers log tool calls instead, so we don't need to display these } }); @@ -94,16 +217,6 @@ export const cursor = agent({ }); child.on("close", async (code, signal) => { - // process any remaining buffered json - if (jsonBuffer.trim()) { - try { - const event = JSON.parse(jsonBuffer.trim()); - log.debug(`[cursor event] ${JSON.stringify(event, null, 2)}`); - } catch { - // ignore parse errors for final buffer - } - } - if (signal) { log.warning(`Cursor CLI terminated by signal: ${signal}`); } diff --git a/agents/gemini.ts b/agents/gemini.ts index ded54d9..1674a75 100644 --- a/agents/gemini.ts +++ b/agents/gemini.ts @@ -35,6 +35,8 @@ export const gemini = agent({ env: { GEMINI_API_KEY: apiKey, GITHUB_INSTALLATION_TOKEN: githubInstallationToken, + LOG_LEVEL: process.env.LOG_LEVEL!, + NODE_ENV: process.env.NODE_ENV!, }, onStdout: (chunk) => { const trimmed = chunk.trim(); diff --git a/agents/shared.ts b/agents/shared.ts index 683dc33..24620d8 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -302,8 +302,11 @@ export async function installFromCurl({ const installResult = spawnSync("bash", [installScriptPath], { cwd: tempDir, env: { - ...process.env, HOME: tempDir, // Cursor install script uses HOME for installation path + PATH: process.env.PATH || "", + SHELL: process.env.SHELL || "/bin/bash", + USER: process.env.USER || "", + TMPDIR: process.env.TMPDIR || "/tmp", }, stdio: "pipe", encoding: "utf-8", diff --git a/main.ts b/main.ts index c3f63f0..b3e3c87 100644 --- a/main.ts +++ b/main.ts @@ -3,6 +3,7 @@ import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { flatMorph } from "@ark/util"; +import { encode as toonEncode } from "@toon-format/toon"; import { type } from "arktype"; import { agents } from "./agents/index.ts"; import type { AgentResult } from "./agents/shared.ts"; @@ -316,7 +317,11 @@ function validateApiKey(ctx: MainContext): void { async function runAgent(ctx: MainContext): Promise { log.info(`Running ${ctx.agentName}...`); - log.box(ctx.payload.prompt, { title: "Prompt" }); + // strip context from event + const { context: _context, ...eventWithoutContext } = ctx.payload.event; + // format: prompt + two newlines + TOON encoded event + const promptContent = `${ctx.payload.prompt}\n\n${toonEncode(eventWithoutContext)}`; + log.box(promptContent, { title: "Prompt" }); return ctx.agent.run({ payload: ctx.payload, diff --git a/mcp/shared.ts b/mcp/shared.ts index 6a70251..f147daf 100644 --- a/mcp/shared.ts +++ b/mcp/shared.ts @@ -56,25 +56,28 @@ export const tool = (toolDef: Tool>) toolDef.execute = async (args: params, context: any) => { try { const result = await originalExecute(args, context); - // Check if result is a ToolResult with isError property - const isError = - result && typeof result === "object" && "isError" in result && result.isError === true; - const resultData = - result && typeof result === "object" && "content" in result - ? (result as ToolResult).content[0]?.text - : undefined; - if (isError && resultData) { - log.toolCall({ toolName, request: args, error: resultData }); - } else if (resultData) { - log.toolCall({ toolName, request: args, result: resultData }); - } else { - log.toolCall({ toolName, request: args }); - } + // TOOL CALL LOGGING DISABLED — now handled by each agent + + // Check if result is a ToolResult with isError property + // const isError = + // result && typeof result === "object" && "isError" in result && result.isError === true; + // const resultData = + // result && typeof result === "object" && "content" in result + // ? (result as ToolResult).content[0]?.text + // : undefined; + + // if (isError && resultData) { + // log.toolCall({ toolName, request: args, error: resultData }); + // } else if (resultData) { + // log.toolCall({ toolName, request: args, result: resultData }); + // } else { + // log.toolCall({ toolName, request: args }); + // } return result; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.toolCall({ toolName, request: args, error: errorMessage }); + // const errorMessage = error instanceof Error ? error.message : String(error); + // log.toolCall({ toolName, request: args, error: errorMessage }); throw error; } }; diff --git a/utils/cli.ts b/utils/cli.ts index 5b982f7..5eb39da 100644 --- a/utils/cli.ts +++ b/utils/cli.ts @@ -9,7 +9,7 @@ import * as core from "@actions/core"; import { table } from "table"; const isGitHubActions = !!process.env.GITHUB_ACTIONS; -const isDebugEnabled = process.env.LOG_LEVEL === "debug"; +const isDebugEnabled = () => process.env.LOG_LEVEL === "debug"; /** * Start a collapsed group (GitHub Actions) or regular group (local) @@ -258,7 +258,7 @@ export const log = { * Print debug message (only if LOG_LEVEL=debug) */ debug: (message: string): void => { - if (isDebugEnabled) { + if (isDebugEnabled()) { if (isGitHubActions) { core.debug(message); } else { @@ -345,7 +345,7 @@ function getMcpLogPath(): string { /** * Format a value as JSON, using compact format for simple values and pretty-printed for complex ones */ -function formatJsonValue(value: unknown): string { +export function formatJsonValue(value: unknown): string { const compact = JSON.stringify(value); return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value, null, 2) : compact; } @@ -354,7 +354,7 @@ function formatJsonValue(value: unknown): string { * Format a multi-line string with proper indentation for tool call output * First line has the label, subsequent lines are indented 4 spaces */ -function formatIndentedField(label: string, content: string): string { +export function formatIndentedField(label: string, content: string): string { if (!content.includes("\n")) { return ` ${label}: ${content}\n`; } @@ -420,6 +420,30 @@ function formatToolCall({ return logEntry; } +/** + * Format a tool call log entry with tool name and input + */ +export function formatToolCallLog({ + toolName, + input, + result: _result, +}: { + toolName: string; + input: unknown; + result?: unknown; +}): string { + let output = `→ ${toolName}\n`; + + const inputFormatted = formatJsonValue(input); + if (inputFormatted !== "{}") { + output += formatIndentedField("input", inputFormatted); + } + + // result parameter added for future use but not implemented yet + + return output.trimEnd(); +} + /** * Finds a CLI executable path by checking if it's installed globally * @param name The name of the CLI executable to find diff --git a/utils/subprocess.ts b/utils/subprocess.ts index b9ed639..04916c7 100644 --- a/utils/subprocess.ts +++ b/utils/subprocess.ts @@ -29,8 +29,12 @@ export async function spawn(options: SpawnOptions): Promise { let stderrBuffer = ""; return new Promise((resolve, reject) => { + // security: caller must provide complete env object, not merged with process.env const child = nodeSpawn(cmd, args, { - env: env ? { ...process.env, ...env } : process.env, + env: env || { + PATH: process.env.PATH || "", + HOME: process.env.HOME || "", + }, stdio: ["pipe", "pipe", "pipe"], cwd: cwd || process.cwd(), });