/** * CLI output utilities that work well in both local and GitHub Actions environments */ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import * as core from "@actions/core"; import { table } from "table"; const isGitHubActions = !!process.env.GITHUB_ACTIONS; const isDebugEnabled = () => process.env.LOG_LEVEL === "debug"; /** * Start a collapsed group (GitHub Actions) or regular group (local) */ function startGroup(name: string): void { if (isGitHubActions) { core.startGroup(name); } else { console.group(name); } } /** * End a collapsed group */ function endGroup(): void { if (isGitHubActions) { core.endGroup(); } else { console.groupEnd(); } } /** * Run a callback within a collapsed group */ function group(name: string, fn: () => void): void { startGroup(name); fn(); endGroup(); } /** * Print a formatted box with text (for console output) */ function boxString( text: string, options?: { title?: string; maxWidth?: number; indent?: string; padding?: number; } ): string { const { title, maxWidth = 80, indent = "", padding = 1 } = options || {}; const lines = text.trim().split("\n"); const wrappedLines: string[] = []; for (const line of lines) { if (line.length <= maxWidth - padding * 2) { wrappedLines.push(line); } else { const words = line.split(" "); let currentLine = ""; for (const word of words) { const testLine = currentLine ? `${currentLine} ${word}` : word; if (testLine.length <= maxWidth - padding * 2) { currentLine = testLine; } else { if (currentLine) { wrappedLines.push(currentLine); currentLine = ""; } // wrap long words by breaking them into chunks const maxLineLength = maxWidth - padding * 2; let remainingWord = word; while (remainingWord.length > maxLineLength) { wrappedLines.push(remainingWord.substring(0, maxLineLength)); remainingWord = remainingWord.substring(maxLineLength); } currentLine = remainingWord; } } if (currentLine) { wrappedLines.push(currentLine); } } } const maxLineLength = Math.max(...wrappedLines.map((line) => line.length)); const contentBoxWidth = maxLineLength + padding * 2; // ensure box width is at least as wide as the title line when title exists const titleLineLength = title ? ` ${title} `.length : 0; const boxWidth = Math.max(contentBoxWidth, titleLineLength); let result = ""; if (title) { const titleLine = ` ${title} `; const titlePadding = Math.max(0, boxWidth - titleLine.length); result += `${indent}┌${titleLine}${"─".repeat(titlePadding)}┐\n`; } if (!title) { result += `${indent}┌${"─".repeat(boxWidth)}┐\n`; } for (const line of wrappedLines) { const paddedLine = line.padEnd(maxLineLength); result += `${indent}│${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}│\n`; } result += `${indent}└${"─".repeat(boxWidth)}┘`; return result; } /** * Print a formatted box with text * Works well in both local and GitHub Actions environments */ function box( text: string, options?: { title?: string; maxWidth?: number; } ): void { const boxContent = boxString(text, options); core.info(boxContent); if (isGitHubActions) { // Add as markdown code block for summary (no headers) core.summary.addRaw(`\`\`\`\n${text}\n\`\`\`\n`); } } /** * Add a table to GitHub Actions job summary (rich formatting) * Also logs to console. Only use this once at the end of execution. */ async function summaryTable( rows: Array>, options?: { title?: string; } ): Promise { const { title } = options || {}; // Convert rows to format expected by Job Summaries API const formattedRows = rows.map((row) => row.map((cell) => { if (typeof cell === "string") { return { data: cell }; } return cell; }) ); if (isGitHubActions) { const summary = core.summary; if (title) { summary.addRaw(`**${title}**\n\n`); } summary.addTable(formattedRows); // Note: Don't write immediately, let it accumulate with other summary content } // Also log to console for visibility if (title) { core.info(`\n${title}`); } const tableText = formattedRows.map((row) => row.map((cell) => cell.data).join(" | ")).join("\n"); core.info(`\n${tableText}\n`); } /** * Print a formatted table using the table package * Also logs to console and GitHub Actions summary */ async function printTable( rows: Array>, options?: { title?: string; } ): Promise { const { title } = options || {}; // Convert rows to string arrays for the table package const tableData = rows.map((row) => row.map((cell) => { if (typeof cell === "string") { return cell; } return cell.data; }) ); const formatted = table(tableData); if (title) { core.info(`\n${title}`); } core.info(`\n${formatted}\n`); if (isGitHubActions) { if (title) { core.summary.addRaw(`**${title}**\n\n`); } core.summary.addRaw(`\`\`\`\n${formatted}\n\`\`\`\n`); } } /** * Print a separator line */ function separator(length: number = 50): void { const separatorText = "─".repeat(length); core.info(separatorText); if (isGitHubActions) { core.summary.addRaw(`---\n`); } } /** * Main logging utility object - import this once and access all utilities */ export const log = { /** * Print info message */ info: (message: string): void => { core.info(message); if (isGitHubActions) { core.summary.addRaw(`${message}\n`); } }, /** * Print warning message */ warning: (message: string): void => { core.warning(message); if (isGitHubActions) { core.summary.addRaw(`⚠️ ${message}\n`); } }, /** * Print error message */ error: (message: string): void => { core.error(message); if (isGitHubActions) { core.summary.addRaw(`❌ ${message}\n`); } }, /** * Print success message */ success: (message: string): void => { const successMessage = `✅ ${message}`; core.info(successMessage); if (isGitHubActions) { core.summary.addRaw(`${successMessage}\n`); } }, /** * Print debug message (only if LOG_LEVEL=debug) */ debug: (message: string): void => { if (isDebugEnabled()) { if (isGitHubActions) { // using this instead of core.debug // because core.debug only logs when ACTIONS_STEP_DEBUG is set to true // we are using LOG_LEVEL core.info(`[DEBUG] ${message}`); } else { core.info(`[DEBUG] ${message}`); } } }, /** * Print a formatted box with text */ box, /** * Add a table to GitHub Actions job summary (rich formatting) * Only use this once at the end of execution */ summaryTable, /** * Print a formatted table using the table package */ table: printTable, /** * Print a separator line */ separator, /** * Write all accumulated summary content to the job summary * Call this at the end of execution to finalize the summary */ writeSummary: async (): Promise => { if (isGitHubActions) { await core.summary.write(); } }, /** * Start a collapsed group (GitHub Actions) or regular group (local) */ startGroup, /** * End a collapsed group */ endGroup, /** * Run a callback within a collapsed group */ group, /** * Log tool call information to console with formatted output */ toolCall: ({ toolName, input }: { toolName: string; input: unknown }): void => { let output = `→ ${toolName}\n`; const inputFormatted = formatJsonValue(input); if (inputFormatted !== "{}") { output += formatIndentedField("input", inputFormatted); } log.info(output.trimEnd()); }, }; /** * Format a value as JSON, using compact format for simple values and pretty-printed for complex ones */ export function formatJsonValue(value: unknown): string { const compact = JSON.stringify(value); return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value, null, 2) : compact; } /** * Format a multi-line string with proper indentation for tool call output * First line has the label, subsequent lines are indented 4 spaces */ export function formatIndentedField(label: string, content: string): string { if (!content.includes("\n")) { return ` ${label}: ${content}\n`; } const lines = content.split("\n"); let formatted = ` ${label}: ${lines[0]}\n`; for (let i = 1; i < lines.length; i++) { formatted += ` ${lines[i]}\n`; } return formatted; } /** * Finds a CLI executable path by checking if it's installed globally * @param name The name of the CLI executable to find * @returns The path to the CLI executable, or null if not found */ export function findCliPath(name: string): string | null { const result = spawnSync("which", [name], { encoding: "utf-8" }); if (result.status === 0 && result.stdout) { const cliPath = result.stdout.trim(); if (cliPath && existsSync(cliPath)) { return cliPath; } } return null; }