Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
FROM node:24-slim
ENV KULALA_CLI_VERSION=0.14.1
ENV KULALA_CLI_VERSION=0.15.0

WORKDIR /app

Expand Down
19 changes: 15 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
{
"name": "@mistweaverco/kulala-cli",
"version": "0.14.2",
"version": "0.15.0",
"repository": {
"type": "git",
"url": "https://github.com/mistweaverco/kulala-cli"
},
"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"
Expand All @@ -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"
}
21 changes: 0 additions & 21 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 55 additions & 0 deletions src/lib/kulala-core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
40 changes: 13 additions & 27 deletions src/lib/output/binary.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -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<RenderedInlineImage | null> {
const protocol = detectTerminalImageProtocol();
if (!protocol) return null;
if (body.encoding !== 'base64') return null;
Expand All @@ -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 };
Expand Down
37 changes: 23 additions & 14 deletions src/lib/output/human.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ function formatHeaders(headers: Record<string, string>): string {
.join('\n');
}

function formatBody(body: KulalaResponseBody | undefined): string {
async function formatBody(body: KulalaResponseBody | undefined): Promise<string> {
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') {
Expand Down Expand Up @@ -191,20 +191,20 @@ function formatRequestHeader(
return lines.join('\n');
}

function appendHttpResponseDetails(
async function appendHttpResponseDetails(
parts: string[],
item: {
headers?: Record<string, string>;
body?: KulalaResponseBody;
filteredBody?: KulalaResponseBody;
},
): void {
): Promise<void> {
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));
Expand All @@ -230,7 +230,7 @@ function appendScriptSections(
}
}

function formatItem(item: KulalaResponseItem, requestFile?: string): string {
async function formatItem(item: KulalaResponseItem, requestFile?: string): Promise<string> {
const header = requestFile ? `${formatRunHeader(requestFile, itemDisplayName(item))}\n` : '';

if (isPromptResponse(item)) {
Expand Down Expand Up @@ -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');
}
Expand All @@ -292,28 +292,37 @@ function formatItem(item: KulalaResponseItem, requestFile?: string): string {
),
];

appendHttpResponseDetails(parts, item);
await appendHttpResponseDetails(parts, item);
appendScriptSections(parts, item.scriptConsole, requestFile);
return parts.join('\n');
}

return header + pc.dim('Unknown response type');
}

function formatWrapper(wrapper: KulalaResponseWrapper, requestFile?: string): string {
async function formatWrapper(
wrapper: KulalaResponseWrapper,
requestFile?: string,
): Promise<string> {
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<void> {
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<void> {
const blocks = await Promise.all(
results.map((result) => formatWrapper(result.response, result.filepath)),
);
console.log(blocks.join('\n\n'));
}

Expand Down
4 changes: 2 additions & 2 deletions src/lib/output/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
7 changes: 5 additions & 2 deletions src/lib/output/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const blocks: string[] = [];

for (const result of results) {
Expand All @@ -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;
Expand Down
Loading