Replace raw JSON dumps with concise, scannable log lines when show_full_output is enabled. Adds an onMessage callback to the SDK runner that formats each message type into 1-2 line summaries with emoji prefixes, tool-specific parameter display, result correlation for parallel tool calls, and subagent progress tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
304 lines
9.6 KiB
TypeScript
304 lines
9.6 KiB
TypeScript
/**
|
|
* 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, unknown>,
|
|
): 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<string, string>();
|
|
|
|
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<string, unknown>;
|
|
};
|
|
// 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;
|
|
}
|
|
};
|
|
}
|