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
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,9 @@ Run interactively with a mounted `.http` file:
docker run -it \
-v ${PWD}/test.http:/app/test.http \
ghcr.io/mistweaverco/kulala-cli:latest \
run test.http
run test.http \
--name
```

```

### Run docker non-interactively, but with a pseudo-TTY:

Expand All @@ -175,9 +174,9 @@ a mounted `.http` file and pseudo-TTY:
docker run -t \
-v ${PWD}/test.http:/app/test.http \
ghcr.io/mistweaverco/kulala-cli:latest \
run test.http
run test.http \
--name "My Request Name"
```
```

### Run docker non-interactively and without a pseudo-TTY; all requests in a directory:

Expand All @@ -189,18 +188,26 @@ docker run \
-v ${PWD}/http-files-dir:/app/http-files-dir \
ghcr.io/mistweaverco/kulala-cli:latest \
run ./http-files-dir
```
```

### Build docker and push to GitHub Container Registry:

Build and push:
#### Build and push to GitHub Container Registry:

```sh
docker buildx build --push \
-t ghcr.io/mistweaverco/kulala-cli:latest \
-f Dockerfile .
```

#### Build and push to Docker Hub:

```sh
docker buildx build --push \
-t mistweaverco/kulala-cli:latest \
-f Dockerfile .
```

[logo]: https://raw.githubusercontent.com/mistweaverco/kulala-cli/main/assets/logo.svg
[discord]: https://mistweaverco.com/discord
[badge-discord]: https://mistweaverco.com/assets/badges/discord.svg
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mistweaverco/kulala-cli",
"version": "0.4.0",
"version": "0.5.0",
"repository": {
"type": "git",
"url": "https://github.com/mistweaverco/kulala-cli"
Expand Down
8 changes: 8 additions & 0 deletions src/lib/kulala-core/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
export type KulalaResponseBody =
| { type: 'text'; content: string; mediaType?: string }
| {
type: 'binary';
/** Base64-encoded bytes. */
content: string;
encoding: 'base64';
byteLength: number;
mediaType?: string;
}
| { type: 'json'; content: Record<string, unknown>; formatted?: string };

export type KulalaScriptConsoleOrigin = {
Expand Down
100 changes: 100 additions & 0 deletions src/lib/output/binary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { KulalaResponseBody } from '../kulala-core/types';

export type TerminalImageProtocol = 'kitty' | 'iterm2' | 'wezterm' | 'ghostty';

function isGhostty(): boolean {
if (process.env.GHOSTTY_RESOURCES_DIR) {
return true;
}
if ((process.env.TERM ?? '').includes('ghostty')) {
return true;
}
return (process.env.TERM_PROGRAM ?? '').toLowerCase().includes('ghostty');
}

function isWezTerm(): boolean {
if (process.env.WEZTERM_EXECUTABLE || process.env.WEZTERM_PANE) {
return true;
}
return (process.env.TERM_PROGRAM ?? '').toLowerCase().includes('wezterm');
}

export function detectTerminalImageProtocol(): TerminalImageProtocol | null {
if (process.env.KITTY_WINDOW_ID || (process.env.TERM ?? '').includes('xterm-kitty')) {
return 'kitty';
}
// Ghostty uses the Kitty graphics protocol.
if (isGhostty()) {
return 'ghostty';
}
// WezTerm supports the iTerm2 inline image protocol (and optionally Kitty graphics).
// iTerm2 OSC 1337 works without extra config on all platforms.
if (isWezTerm()) {
return 'wezterm';
}
// iTerm2 (macOS) – common env signals.
if (process.env.TERM_PROGRAM === 'iTerm.app' || process.env.ITERM_SESSION_ID) {
return 'iterm2';
}
return null;
}

export function isBinaryBody(
body: KulalaResponseBody | undefined,
): body is Extract<KulalaResponseBody, { type: 'binary' }> {
return body?.type === 'binary';
}

export function isImageBody(body: KulalaResponseBody | undefined): boolean {
if (!body || (body.type !== 'text' && body.type !== 'binary')) {
return false;
}
const mediaType = body.mediaType?.toLowerCase() ?? '';
return mediaType.startsWith('image/');
}

export function formatByteSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return `${bytes} B`;
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
const mb = kb / 1024;
return `${mb.toFixed(1)} MB`;
}

function kittyImageEscape(base64: string): string {
// Kitty graphics protocol: transmit base64 chunks.
// https://sw.kovidgoyal.net/kitty/graphics-protocol/
const CHUNK = 4096;
let out = '';
for (let i = 0; i < base64.length; i += CHUNK) {
const chunk = base64.slice(i, i + CHUNK);
const more = i + CHUNK < base64.length ? 1 : 0;
// f=100 => PNG; kitty will detect from bytes too, but we don't have raw bytes here.
// Use t=d (base64), a=T (transmit), m=1 for more chunks.
out += `\u001b_Ga=T,t=d,m=${more};${chunk}\u001b\\`;
}
return out;
}

function iterm2ImageEscape(base64: string, byteLength: number): string {
// iTerm2 inline images (OSC 1337).
// https://iterm2.com/documentation-images.html
return `\u001b]1337;File=inline=1;size=${byteLength};width=auto;height=auto;preserveAspectRatio=1:${base64}\u0007`;
}

export function renderImageInline(
body: Extract<KulalaResponseBody, { type: 'binary' }>,
): string | null {
const protocol = detectTerminalImageProtocol();
if (!protocol) return null;
if (body.encoding !== 'base64') return null;

if (protocol === 'kitty' || protocol === 'ghostty') {
return kittyImageEscape(body.content);
}
if (protocol === 'iterm2' || protocol === 'wezterm') {
return iterm2ImageEscape(body.content, body.byteLength);
}
return null;
}
13 changes: 13 additions & 0 deletions src/lib/output/human.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
RunFileResult,
} from '../kulala-core/types';
import { highlightCode } from './highlight';
import { formatByteSize, isBinaryBody, isImageBody, renderImageInline } from './binary';
import {
formatMs,
formatScriptOrigin,
Expand Down Expand Up @@ -51,6 +52,18 @@ function formatHeaders(headers: Record<string, string>): string {
}

function formatBody(body: KulalaResponseBody | undefined): string {
if (isBinaryBody(body)) {
const mediaType = body.mediaType ?? 'application/octet-stream';
if (isImageBody(body)) {
const rendered = renderImageInline(body);
if (rendered) {
return rendered;
}
}
return pc.dim(
`Binary response body omitted (${mediaType}, ${formatByteSize(body.byteLength)})`,
);
}
const text = responseBodyText(body);
if (!text) {
return '';
Expand Down
6 changes: 6 additions & 0 deletions src/lib/output/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export function responseBodyText(body: KulalaResponseBody | undefined): string {
if (body.type === 'json') {
return body.formatted ?? JSON.stringify(body.content, null, 2);
}
if (body.type === 'binary') {
return '';
}
return body.content;
}

Expand All @@ -51,6 +54,9 @@ export function responseBodyLanguage(body: KulalaResponseBody | undefined): stri
if (body.type === 'json') {
return 'json';
}
if (body.type === 'binary') {
return 'text';
}
const mediaType = body.mediaType?.toLowerCase() ?? '';
if (mediaType.includes('json')) {
return 'json';
Expand Down