From 206fbe08a84bd5865efc891662c23df2a23ab6c1 Mon Sep 17 00:00:00 2001 From: Marco Kellershoff Date: Tue, 11 Aug 2026 12:28:29 +0200 Subject: [PATCH] feat(image): use kulala-core for image transformation --- Dockerfile | 2 +- package.json | 19 ++++++++++--- pnpm-lock.yaml | 21 -------------- src/lib/kulala-core/index.ts | 55 ++++++++++++++++++++++++++++++++++++ src/lib/output/binary.ts | 40 +++++++++----------------- src/lib/output/human.ts | 37 +++++++++++++++--------- src/lib/output/shared.ts | 4 +-- src/lib/output/tests.ts | 7 +++-- src/lib/runner/curl.ts | 2 +- src/lib/runner/index.ts | 14 ++++----- src/versions/backend.ts | 2 +- 11 files changed, 123 insertions(+), 80 deletions(-) diff --git a/Dockerfile b/Dockerfile index c0b7afe..ed7a0e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ FROM node:24-slim -ENV KULALA_CLI_VERSION=0.14.1 +ENV KULALA_CLI_VERSION=0.15.0 WORKDIR /app diff --git a/package.json b/package.json index db44a90..51fe577 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mistweaverco/kulala-cli", - "version": "0.14.2", + "version": "0.15.0", "repository": { "type": "git", "url": "https://github.com/mistweaverco/kulala-cli" @@ -8,6 +8,16 @@ "bin": { "kulala": "dist/cli.cjs" }, + "workspaces": { + "packages": [ + "." + ], + "catalog": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest", + "vite-plus": "0.1.24" + } + }, "files": [ "dist/cli.cjs", "dist/install-backend.cjs" @@ -27,19 +37,20 @@ "@inquirer/select": "4.3.4", "@inquirer/type": "4.0.7", "@types/node": "25.9.1", - "@types/pngjs": "6.0.5", "chalk": "5.6.2", "cli-highlight": "2.1.11", "commander": "15.0.0", "eslint-plugin-prettier": "5.5.6", "globals": "17.6.0", - "jpeg-js": "0.4.4", "picocolors": "1.1.1", - "pngjs": "7.0.0", "prettier": "3.8.4", "tsx": "4.22.4", "typescript": "5.9.3", "vite-plus": "catalog:" }, + "overrides": { + "vite": "catalog:", + "vitest": "catalog:" + }, "packageManager": "pnpm@11.5.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef184a8..712cb4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,9 +30,6 @@ importers: '@types/node': specifier: 25.9.1 version: 25.9.1 - '@types/pngjs': - specifier: 6.0.5 - version: 6.0.5 chalk: specifier: 5.6.2 version: 5.6.2 @@ -48,15 +45,9 @@ importers: globals: specifier: 17.6.0 version: 17.6.0 - jpeg-js: - specifier: 0.4.4 - version: 0.4.4 picocolors: specifier: 1.1.1 version: 1.1.1 - pngjs: - specifier: 7.0.0 - version: 7.0.0 prettier: specifier: 3.8.4 version: 3.8.4 @@ -768,9 +759,6 @@ packages: '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} - '@types/pngjs@6.0.5': - resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} - '@voidzero-dev/vite-plus-core@0.1.24': resolution: {integrity: sha512-iXPGBABnQnrDMx89H6MOCGcTZp+QW+3rY4YMVKdE6ydchSvPk2O3MI2vgaRVfOtWJ2IjnxSnf1n2yjP67ZBRFQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1171,9 +1159,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jpeg-js@0.4.4: - resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} - json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -2012,10 +1997,6 @@ snapshots: dependencies: undici-types: 7.24.6 - '@types/pngjs@6.0.5': - dependencies: - '@types/node': 25.9.1 - '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.1)(esbuild@0.28.1)(tsx@4.22.4)(typescript@5.9.3)': dependencies: '@oxc-project/runtime': 0.133.0 @@ -2343,8 +2324,6 @@ snapshots: isexe@2.0.0: {} - jpeg-js@0.4.4: {} - json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} diff --git a/src/lib/kulala-core/index.ts b/src/lib/kulala-core/index.ts index d17e3bc..cdd5c9a 100644 --- a/src/lib/kulala-core/index.ts +++ b/src/lib/kulala-core/index.ts @@ -186,9 +186,64 @@ export async function curl( return parseInvokeResponse(job); } +export async function convertImage( + options: { content: string; mediaType?: string; target: 'png' }, + invokeOptions: InvokeOptions = {}, +): Promise<{ + content: string; + mediaType: string; + byteLength: number; + convertedFrom?: 'jpeg'; +} | null> { + await executablePath(); + + const job = await invokeRaw( + { + action: 'convert_image', + content: options.content, + mediaType: options.mediaType, + target: options.target, + }, + invokeOptions, + ); + + if (job.status !== 0) { + return null; + } + + try { + const parsed = JSON.parse(job.stdout.trim()) as { + type?: string; + success?: boolean; + content?: string; + mediaType?: string; + byteLength?: number; + convertedFrom?: 'jpeg'; + }; + if ( + parsed.type !== 'convert_image' || + parsed.success !== true || + typeof parsed.content !== 'string' || + typeof parsed.mediaType !== 'string' || + typeof parsed.byteLength !== 'number' + ) { + return null; + } + return { + content: parsed.content, + mediaType: parsed.mediaType, + byteLength: parsed.byteLength, + ...(parsed.convertedFrom ? { convertedFrom: parsed.convertedFrom } : {}), + }; + } catch { + return null; + } +} + export const kulalaCore = { runHttp, continueHttp, environments, curl, + convertImage, }; diff --git a/src/lib/output/binary.ts b/src/lib/output/binary.ts index 13c487a..cd61698 100644 --- a/src/lib/output/binary.ts +++ b/src/lib/output/binary.ts @@ -1,6 +1,5 @@ -import jpeg from 'jpeg-js'; -import { PNG } from 'pngjs'; import type { KulalaResponseBody } from '../kulala-core/types'; +import { kulalaCore } from '../kulala-core'; export type TerminalImageProtocol = 'kitty' | 'iterm2' | 'wezterm' | 'ghostty'; @@ -85,29 +84,10 @@ function isPngImage(body: BinaryImageBody): boolean { return body.content.startsWith('iVBORw0KGgo'); } -function isJpegImage(body: BinaryImageBody): boolean { - const mediaType = body.mediaType?.toLowerCase() ?? ''; - if (mediaType === 'image/jpeg' || mediaType === 'image/jpg') { - return true; - } - return body.content.startsWith('/9j/'); -} - function usesKittyGraphicsProtocol(protocol: TerminalImageProtocol): boolean { return protocol === 'kitty' || protocol === 'ghostty'; } -function convertJpegBase64ToPngBase64(base64: string): string | null { - try { - const decoded = jpeg.decode(Buffer.from(base64, 'base64')); - const png = new PNG({ width: decoded.width, height: decoded.height }); - png.data = decoded.data; - return PNG.sync.write(png).toString('base64'); - } catch { - return null; - } -} - export function formatByteSize(bytes: number): string { if (!Number.isFinite(bytes) || bytes < 0) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`; @@ -145,7 +125,9 @@ function iterm2ImageEscape(base64: string, byteLength: number): string { return `\u001b]1337;File=inline=1;size=${byteLength};width=auto;height=auto;preserveAspectRatio=1:${base64}\u0007`; } -export function renderImageInline(body: BinaryImageBody): RenderedInlineImage | null { +export async function renderImageInline( + body: BinaryImageBody, +): Promise { const protocol = detectTerminalImageProtocol(); if (!protocol) return null; if (body.encoding !== 'base64') return null; @@ -154,13 +136,17 @@ export function renderImageInline(body: BinaryImageBody): RenderedInlineImage | let base64 = body.content; let convertedFrom: 'jpeg' | undefined; - if (!isPngImage(body) && isJpegImage(body)) { - const pngBase64 = convertJpegBase64ToPngBase64(body.content); - if (!pngBase64) { + if (!isPngImage(body)) { + const converted = await kulalaCore.convertImage({ + content: body.content, + mediaType: body.mediaType, + target: 'png', + }); + if (!converted) { return null; } - base64 = pngBase64; - convertedFrom = 'jpeg'; + base64 = converted.content; + convertedFrom = converted.convertedFrom; } return { content: kittyImageEscape(base64), convertedFrom }; diff --git a/src/lib/output/human.ts b/src/lib/output/human.ts index 1fb5287..dbee1f5 100644 --- a/src/lib/output/human.ts +++ b/src/lib/output/human.ts @@ -53,11 +53,11 @@ function formatHeaders(headers: Record): string { .join('\n'); } -function formatBody(body: KulalaResponseBody | undefined): string { +async function formatBody(body: KulalaResponseBody | undefined): Promise { if (isBinaryBody(body)) { const mediaType = body.mediaType ?? 'application/octet-stream'; if (isImageBody(body)) { - const rendered = renderImageInline(body); + const rendered = await renderImageInline(body); if (rendered) { const parts: string[] = []; if (rendered.convertedFrom === 'jpeg') { @@ -191,20 +191,20 @@ function formatRequestHeader( return lines.join('\n'); } -function appendHttpResponseDetails( +async function appendHttpResponseDetails( parts: string[], item: { headers?: Record; body?: KulalaResponseBody; filteredBody?: KulalaResponseBody; }, -): void { +): Promise { if (item.headers && Object.keys(item.headers).length > 0) { parts.push(''); parts.push(formatSection('Headers', formatHeaders(item.headers))); } - const bodySection = formatBody(item.filteredBody ?? item.body); + const bodySection = await formatBody(item.filteredBody ?? item.body); if (bodySection) { parts.push(''); parts.push(formatSection('Response body', bodySection)); @@ -230,7 +230,7 @@ function appendScriptSections( } } -function formatItem(item: KulalaResponseItem, requestFile?: string): string { +async function formatItem(item: KulalaResponseItem, requestFile?: string): Promise { const header = requestFile ? `${formatRunHeader(requestFile, itemDisplayName(item))}\n` : ''; if (isPromptResponse(item)) { @@ -273,7 +273,7 @@ function formatItem(item: KulalaResponseItem, requestFile?: string): string { parts.push(pc.red(`Error: ${item.error}`)); } - appendHttpResponseDetails(parts, item); + await appendHttpResponseDetails(parts, item); appendScriptSections(parts, item.scriptConsole, requestFile); return parts.join('\n'); } @@ -292,7 +292,7 @@ function formatItem(item: KulalaResponseItem, requestFile?: string): string { ), ]; - appendHttpResponseDetails(parts, item); + await appendHttpResponseDetails(parts, item); appendScriptSections(parts, item.scriptConsole, requestFile); return parts.join('\n'); } @@ -300,20 +300,29 @@ function formatItem(item: KulalaResponseItem, requestFile?: string): string { return header + pc.dim('Unknown response type'); } -function formatWrapper(wrapper: KulalaResponseWrapper, requestFile?: string): string { +async function formatWrapper( + wrapper: KulalaResponseWrapper, + requestFile?: string, +): Promise { const items = wrapper.type === 'error' ? wrapper.data : wrapper.data; - return items.map((entry) => formatItem(entry, requestFile)).join('\n\n'); + const formatted = await Promise.all(items.map((entry) => formatItem(entry, requestFile))); + return formatted.join('\n\n'); } -export function printResponseItems(filepath: string, items: KulalaResponseItem[]): void { +export async function printResponseItems( + filepath: string, + items: KulalaResponseItem[], +): Promise { if (items.length === 0) { return; } - console.log(formatWrapper({ type: 'responses', data: items }, filepath)); + console.log(await formatWrapper({ type: 'responses', data: items }, filepath)); } -export function printHumanReadable(results: RunFileResult[]): void { - const blocks = results.map((result) => formatWrapper(result.response, result.filepath)); +export async function printHumanReadable(results: RunFileResult[]): Promise { + const blocks = await Promise.all( + results.map((result) => formatWrapper(result.response, result.filepath)), + ); console.log(blocks.join('\n\n')); } diff --git a/src/lib/output/shared.ts b/src/lib/output/shared.ts index 288d60a..77a55cf 100644 --- a/src/lib/output/shared.ts +++ b/src/lib/output/shared.ts @@ -357,10 +357,10 @@ export function itemTitle(item: KulalaResponseItem): string { return `Prompt: ${item.promptType}`; } if (isSkippedResponse(item)) { - return item.blockName ? `Skipped — ${item.blockName}` : 'Skipped'; + return item.blockName ? `Skipped - ${item.blockName}` : 'Skipped'; } if (isWebSocketResponse(item)) { - return `WebSocket — ${item.url}`; + return `WebSocket - ${item.url}`; } if (isErrorResponse(item)) { const method = item.request?.method ?? 'REQUEST'; diff --git a/src/lib/output/tests.ts b/src/lib/output/tests.ts index 06a10a2..e3604ae 100644 --- a/src/lib/output/tests.ts +++ b/src/lib/output/tests.ts @@ -67,7 +67,10 @@ function formatTestsOnly(item: KulalaResponseItem): string { return lines.join('\n').trim(); } -export function printTests(results: RunFileResult[], options: { quiet: boolean }): void { +export async function printTests( + results: RunFileResult[], + options: { quiet: boolean }, +): Promise { const blocks: string[] = []; for (const result of results) { @@ -83,7 +86,7 @@ export function printTests(results: RunFileResult[], options: { quiet: boolean } // Failures: show normal human readable output, but always include file header. if (requestFailed || testsFailed) { - printHumanReadable([ + await printHumanReadable([ { filepath: result.filepath, response: { type: 'responses', data: [item] } }, ]); continue; diff --git a/src/lib/runner/curl.ts b/src/lib/runner/curl.ts index 5fbede1..cd30088 100644 --- a/src/lib/runner/curl.ts +++ b/src/lib/runner/curl.ts @@ -71,7 +71,7 @@ export async function curl(argv: string[], options: CurlCommandOptions = {}): Pr if (mergedOptions.json) { printJson([result]); } else { - printHumanReadable([result]); + await printHumanReadable([result]); } if (countResults(response).failed > 0) { diff --git a/src/lib/runner/index.ts b/src/lib/runner/index.ts index 64eba38..4af47ac 100644 --- a/src/lib/runner/index.ts +++ b/src/lib/runner/index.ts @@ -88,16 +88,16 @@ function itemsToStream(items: KulalaResponseItem[], quiet: boolean): KulalaRespo return items.filter((item) => !isResponseSuccessful(item)); } -function streamResponseItems( +async function streamResponseItems( filepath: string, items: KulalaResponseItem[], ctx: OutputContext, -): void { +): Promise { if (!shouldStreamOutput(ctx)) { return; } const toPrint = itemsToStream(items, ctx.quiet ?? false); - printResponseItems(filepath, toPrint); + await printResponseItems(filepath, toPrint); } function continueSucceeded(response: KulalaResponseWrapper): boolean { @@ -173,7 +173,7 @@ async function runFileWithPromptRetry( if (!promptItem) { const final = mergeRunResponses(accumulated, response); - streamResponseItems(relativePath, newItemsSinceAccumulated(accumulated, final), output); + await streamResponseItems(relativePath, newItemsSinceAccumulated(accumulated, final), output); return { filepath: relativePath, response: final, @@ -189,7 +189,7 @@ async function runFileWithPromptRetry( const completedBefore = completedItemsBeforePrompt(response); const newAccumulated = [...accumulated, ...completedBefore]; - streamResponseItems(relativePath, completedBefore, output); + await streamResponseItems(relativePath, completedBefore, output); const inputs = await collectPromptInputs(promptItem); if (!inputs || !promptItem.promptId) { @@ -287,14 +287,14 @@ export async function run(inputPath: string, options: RunCommandOptions): Promis if (options.tests) { // Tests mode handles its own "quiet" filtering because failures include test failures. - printTests(results, { quiet: options.quiet ?? false }); + await printTests(results, { quiet: options.quiet ?? false }); } else if (!options.quiet || outputResults.length > 0) { if (options.json) { printJson(results); } else if (options.report) { printReport(pendingOutput); } else if (pendingOutput.length > 0) { - printHumanReadable(outputResults); + await printHumanReadable(outputResults); } } diff --git a/src/versions/backend.ts b/src/versions/backend.ts index 0ac7eb5..d74045d 100644 --- a/src/versions/backend.ts +++ b/src/versions/backend.ts @@ -1 +1 @@ -export const KULALA_CORE_VERSION = '0.31.1'; +export const KULALA_CORE_VERSION = '0.35.0';