diff --git a/src/cli/output-utils.ts b/src/cli/output-utils.ts index 5b12246..cc3f58e 100644 --- a/src/cli/output-utils.ts +++ b/src/cli/output-utils.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; +import path from 'node:path'; import { inspect } from 'node:util'; -import type { CallResult } from '../result-utils.js'; +import type { CallResult, ImageContent } from '../result-utils.js'; import { logWarn } from './logger-context.js'; export type OutputFormat = 'auto' | 'text' | 'markdown' | 'json' | 'raw'; @@ -120,6 +121,60 @@ export function tailLogIfRequested(result: unknown, enabled: boolean): void { } } +export function saveCallImagesIfRequested(wrapped: CallResult, outputDir: string | undefined): void { + if (!outputDir) { + return; + } + const images = wrapped.images(); + if (!images || images.length === 0) { + return; + } + const resolvedDir = path.resolve(outputDir); + try { + fs.mkdirSync(resolvedDir, { recursive: true }); + } catch (error) { + logWarn(`Unable to create image output directory ${resolvedDir}: ${(error as Error).message}`); + return; + } + writeImages(images, resolvedDir); +} + +function writeImages(images: ImageContent[], outputDir: string): void { + for (let i = 0; i < images.length; i++) { + const img = images[i]; + const ext = extensionFromMimeType(img.mimeType); + const outputPath = resolveImageOutputPath(outputDir, i + 1, ext); + try { + const buffer = Buffer.from(img.data, 'base64'); + fs.writeFileSync(outputPath, buffer); + console.error(`[mcporter] Saved image: ${outputPath} (${buffer.length} bytes, ${img.mimeType})`); + } catch (writeError) { + logWarn(`Failed to save image ${i + 1} (${img.mimeType}): ${(writeError as Error).message}`); + } + } +} + +function extensionFromMimeType(mimeType: string): string { + const subtype = mimeType.split('/')[1]?.split(';')[0]?.trim().toLowerCase(); + if (subtype && /^[a-z0-9.+-]+$/.test(subtype)) { + return subtype; + } + return 'png'; +} + +function resolveImageOutputPath(outputDir: string, imageIndex: number, extension: string): string { + const baseName = `image-${imageIndex}`; + let attempt = 0; + while (true) { + const suffix = attempt === 0 ? '' : `-${attempt}`; + const candidate = path.join(outputDir, `${baseName}${suffix}.${extension}`); + if (!fs.existsSync(candidate)) { + return candidate; + } + attempt += 1; + } +} + function attemptPrintJson(value: unknown): boolean { if (value === undefined) { return false; diff --git a/src/result-utils.ts b/src/result-utils.ts index 43850fd..7cc6d66 100644 --- a/src/result-utils.ts +++ b/src/result-utils.ts @@ -1,10 +1,16 @@ import { analyzeConnectionError, type ConnectionIssue } from './error-classifier.js'; +export interface ImageContent { + data: string; + mimeType: string; +} + export interface CallResult { raw: T; text(joiner?: string): string | null; markdown(joiner?: string): string | null; json(): J | null; + images(): ImageContent[] | null; content(): unknown[] | null; structuredContent(): unknown; } @@ -69,6 +75,24 @@ function asString(value: unknown): string | null { return null; } +// collectImages extracts all image content blocks. +function collectImages(content: unknown[]): ImageContent[] | null { + const images: ImageContent[] = []; + for (const entry of content) { + if (entry && typeof entry === 'object' && 'type' in entry) { + const typedEntry = entry as Record; + if (typedEntry.type === 'image') { + const data = typedEntry.data; + const mimeType = typedEntry.mimeType ?? 'image/png'; + if (typeof data === 'string' && typeof mimeType === 'string') { + images.push({ data, mimeType }); + } + } + } + } + return images.length > 0 ? images : null; +} + // collectText flattens all text/markdown entries into a joined string. function collectText(content: unknown[], joiner: string): string | null { const pieces: string[] = []; @@ -230,6 +254,13 @@ export function createCallResult(raw: T): CallResult { } return null; }, + images() { + const content = extractContentArray(raw); + if (!content) { + return null; + } + return collectImages(content); + }, content() { return extractContentArray(raw); },