/** * Human-readable CI output formatter for Claude Code SDK messages. * * When show_full_output is true, replaces raw JSON dumps with concise, * scannable log lines that show what Claude is doing in real time. */ import type { SDKMessage, SDKSystemMessage, SDKAssistantMessage, SDKUserMessage, SDKResultMessage, SDKPermissionDenial, } from "@anthropic-ai/claude-agent-sdk"; const MAX_LINE_LENGTH = 200; export function truncate( text: string, maxLen: number = MAX_LINE_LENGTH, ): string { const oneLine = text.replace(/\n/g, " ").trim(); if (oneLine.length <= maxLen) return oneLine; return oneLine.slice(0, maxLen - 3) + "..."; } function countLines(text: string): number { if (!text || !text.trim()) return 0; return text.split("\n").length; } /** * Format tool-specific parameters for concise display. */ export function formatToolParams( toolName: string, input: Record, ): string { switch (toolName) { case "Bash": { const cmd = String(input.command ?? ""); const desc = input.description ? ` — "${input.description}"` : ""; return `${truncate(cmd, 120)}${desc}`; } case "Read": case "Write": case "Edit": return String(input.file_path ?? ""); case "NotebookEdit": return String(input.notebook_path ?? ""); case "Grep": { const pattern = String(input.pattern ?? ""); const path = input.path ? ` in ${input.path}` : ""; return `"${pattern}"${path}`; } case "Glob": return String(input.pattern ?? ""); case "WebFetch": return String(input.url ?? ""); case "WebSearch": return String(input.query ?? ""); case "Task": { const desc = input.description ? ` — ${input.description}` : input.prompt ? ` — ${truncate(String(input.prompt), 80)}` : ""; return `${input.subagent_type ?? "agent"}${desc}`; } default: // MCP tools and others — just show the name return ""; } } /** * Format a tool result (from user message tool_result content blocks). */ export function formatToolResult(content: unknown): string { if (typeof content === "string") { if (!content.trim()) return "(empty)"; const lines = countLines(content); if (lines === 1 && content.length <= MAX_LINE_LENGTH) { return content.trim(); } const firstLine = truncate(content.split("\n")[0] ?? "", 150); return `${firstLine} (${lines} lines)`; } // Content blocks array (e.g., [{type: "tool_result", content: [{type: "text", text: "..."}]}]) if (Array.isArray(content)) { const texts = content .filter( (block: { type?: string }) => block && typeof block === "object" && block.type === "text", ) .map((block: { text?: string }) => String(block.text ?? "")); if (texts.length > 0) { return formatToolResult(texts.join("\n")); } // Image or other content return `(${content.length} content blocks)`; } return "(complex result)"; } export function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; if (minutes === 0) return `${seconds}s`; return `${minutes}m${seconds.toString().padStart(2, "0")}s`; } export function formatCost(usd: number): string { if (usd < 0.01) return `$${usd.toFixed(4)}`; return `$${usd.toFixed(2)}`; } export function formatPermissionDenials( denials: SDKPermissionDenial[], ): string { if (denials.length === 0) return ""; const tools = denials .map((d) => { const input = d.tool_input; if (d.tool_name === "Bash" && input.command) { const cmd = String(input.command).split(" ")[0]; return `Bash(${cmd})`; } return d.tool_name; }) .join(", "); const label = denials.length === 1 ? "1 permission denial" : `${denials.length} permission denials`; return ` — ⚠️ ${label}: ${tools}`; } /** * Creates a message handler that outputs human-readable CI logs. * Returns an (SDKMessage) => void callback to pass to runClaudeWithSdk. */ export function createCiOutputFormatter(): (message: SDKMessage) => void { // Track tool_use_id → tool name for correlating results back to their tool call const toolUseMap = new Map(); return (message: SDKMessage): void => { switch (message.type) { case "system": { if (message.subtype === "init") { const init = message as SDKSystemMessage; const toolCount = init.tools.length; const mcpCount = init.mcp_servers.filter( (s) => s.status === "connected", ).length; const mcpInfo = mcpCount > 0 ? `, ${mcpCount} MCP servers` : ""; console.log( `🚀 Claude Code v${init.claude_code_version} (${init.model}) — ${toolCount} tools${mcpInfo}`, ); } else if (message.subtype === "task_started") { const task = message as { subtype: "task_started"; description: string; }; console.log(` 📋 Task started: ${truncate(task.description)}`); } else if (message.subtype === "task_progress") { const task = message as { subtype: "task_progress"; description: string; usage: { tool_uses: number }; last_tool_name?: string; }; const lastTool = task.last_tool_name ? `, last: ${task.last_tool_name}` : ""; console.log( ` 📋 Task progress: ${task.usage.tool_uses} tool uses${lastTool}`, ); } else if (message.subtype === "task_notification") { const task = message as { subtype: "task_notification"; status: string; summary: string; }; console.log( ` 📋 Task ${task.status}: ${truncate(task.summary, 150)}`, ); } // Skip status, compact_boundary, hook_* subtypes break; } case "assistant": { const assistant = message as SDKAssistantMessage; const content = assistant.message?.content; if (!Array.isArray(content)) break; for (const block of content) { if (block.type === "thinking" || block.type === "redacted_thinking") { continue; } if (block.type === "text" && block.text) { const text = block.text.trim(); if (text) { console.log(`💬 ${truncate(text)}`); } } if (block.type === "tool_use") { const toolBlock = block as { type: "tool_use"; id: string; name: string; input: Record; }; // Track tool_use_id → display label for result correlation const params = formatToolParams(toolBlock.name, toolBlock.input); const paramStr = params ? `: ${params}` : ""; const displayLabel = `${toolBlock.name}${paramStr}`; toolUseMap.set(toolBlock.id, displayLabel); console.log(`🔧 ${displayLabel}`); } } break; } case "user": { const user = message as SDKUserMessage; const content = user.message?.content; if (!Array.isArray(content)) break; for (const block of content as Array<{ type: string; content?: unknown; is_error?: boolean; tool_use_id?: string; }>) { if (block.type === "tool_result") { const isError = block.is_error === true; const resultContent = block.content; // Look up which tool call this result belongs to const toolLabel = block.tool_use_id ? toolUseMap.get(block.tool_use_id) : undefined; const prefix = toolLabel ? `[${toolLabel}] ` : ""; if (isError) { const errorText = typeof resultContent === "string" ? resultContent : Array.isArray(resultContent) ? resultContent .filter( (b: { type?: string }) => b && typeof b === "object" && b.type === "text", ) .map((b: { text?: string }) => String(b.text ?? "")) .join(" ") : "unknown error"; console.log(` ↳ ❌ ${prefix}${truncate(errorText)}`); } else if (resultContent) { console.log(` ↳ ${prefix}${formatToolResult(resultContent)}`); } } } break; } case "result": { const result = message as SDKResultMessage; const duration = formatDuration(result.duration_ms); const cost = formatCost(result.total_cost_usd); const turns = result.num_turns; const denials = formatPermissionDenials(result.permission_denials); if (result.subtype === "success") { console.log( `✅ Done — ${turns} turns, ${cost}, ${duration}${denials}`, ); } else { const errors = "errors" in result && result.errors ? `: ${result.errors.join(", ")}` : ""; console.log( `❌ Failed (${result.subtype}) — ${turns} turns, ${cost}, ${duration}${errors}${denials}`, ); } break; } // Skip stream_event, tool_progress, tool_use_summary, rate_limit, etc. default: break; } }; }