/** * `dench image generate|edit` — gpt-image-2 backed image creation through * the Dench Cloud Gateway. * * Mirrors `dench search`: bypasses Convex entirely and talks straight HTTP * to the gateway, authenticating with either `DENCH_API_KEY` or a * paid-workspace gateway key resolved from the active agent session by the * top-level dispatcher. * * Persistence model: the gateway already writes the bytes to Convex Storage * + the org's fileTree on success (so the workspace UI sees the file * immediately without any client-side orchestration). When the CLI is * running inside a sandbox, we ALSO write the decoded bytes to disk at * `--output` (default: `/workspace/image-generations/...`) so the very * next bash command (e.g. `convert image.png -resize 50% small.png`) can * see it without waiting on the daemon's downward sync — the same * defense-in-depth pattern the chat-turn workflow tool uses. * * Subcommands: * image generate "" [--size 1024x1024] [--quality auto|low|medium|high] * [--format png|jpeg|webp] [--save-path /workspace/...] * [--output ] [--no-write] [--json] * image edit "" --input [--input ...] [--mask ] * [--size ...] [--quality ...] [--format ...] [--save-path ...] * [--output ...] [--no-write] [--json] */ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; import { getFlag, hasFlag } from "./lib/cli-args"; type ImageCliContext = { args: string[]; jsonOutput: boolean; bearerToken?: string; gatewayBaseUrl?: string; }; class ImageCliError extends Error {} const ALLOWED_QUALITIES = new Set(["auto", "low", "medium", "high"]); const ALLOWED_FORMATS = new Set(["png", "jpeg", "webp"]); function normalizeGatewayBaseUrl(value: string): string { return value.replace(/\/+$/, "").replace(/\/v1$/, ""); } function resolveGatewayBaseUrl( ctx?: Pick, ): string { if (ctx?.gatewayBaseUrl?.trim()) { return normalizeGatewayBaseUrl(ctx.gatewayBaseUrl.trim()); } const explicit = process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim(); if (explicit) return normalizeGatewayBaseUrl(explicit); const host = process.env.DENCH_HOST?.trim(); if (host && /localhost|127\.0\.0\.1/.test(host)) { return "http://localhost:8787"; } return "https://gateway.merseoriginals.com"; } function requireBearerToken( ctx?: Pick, ): string { const bearerToken = ctx?.bearerToken?.trim() || process.env.DENCH_API_KEY?.trim(); if (!bearerToken) { throw new ImageCliError( "No Dench gateway credential found. Set DENCH_API_KEY or run `dench signin` for a paid workspace.", ); } return bearerToken; } function resolveDefaultVolumeRoot(): string { // Inside a sandbox the volume is mounted at `/workspace` and the // gateway emits paths like `/image-generations/...png`. Outside a // sandbox, write to `./image-generations/...png` so things still // work in dev / CI. return process.env.DENCH_VOLUME_PATH?.trim() || "/workspace"; } function resolveOutputPath( explicit: string | undefined, gatewayPath: string, ): string { const root = resolveDefaultVolumeRoot(); if (explicit && explicit.length > 0) { return isAbsolute(explicit) ? explicit : resolve(process.cwd(), explicit); } // gatewayPath is `/image-generations/.png` (POSIX, leading slash). return resolve(root, gatewayPath.replace(/^\/+/, "")); } async function readBase64FromPath(path: string): Promise { const absolute = isAbsolute(path) ? path : resolve(process.cwd(), path); const bytes = await readFile(absolute); return bytes.toString("base64"); } type GatewayImageResponse = { ok: boolean; model?: string; endpoint?: string; prompt?: string; revisedPrompt?: string; quality?: string; requestedSize?: string; format?: string; mimeType?: string; estimatedCostUsd?: number; b64_json?: string; storageId?: string; path?: string; signedUrl?: string | null; contentHash?: string; sizeBytes?: number; usage?: Record; }; async function callGateway( ctx: ImageCliContext, path: "/v1/images/generations" | "/v1/images/edits", body: Record, ): Promise { const bearerToken = requireBearerToken(ctx); const baseUrl = resolveGatewayBaseUrl(ctx); const response = await fetch(`${baseUrl}${path}`, { method: "POST", headers: { "content-type": "application/json", Authorization: `Bearer ${bearerToken}`, }, body: JSON.stringify(body), }); if (!response.ok) { let detail = ""; try { detail = await response.text(); } catch { // ignore } const truncated = detail.length > 500 ? `${detail.slice(0, 500)}…` : detail; throw new ImageCliError( `Gateway ${path} returned ${response.status}: ${truncated || response.statusText}`, ); } return (await response.json()) as GatewayImageResponse; } async function writeBytesToDisk( outputPath: string, base64: string, ): Promise<{ path: string; size: number }> { await mkdir(dirname(outputPath), { recursive: true }); const bytes = Buffer.from(base64, "base64"); await writeFile(outputPath, bytes); return { path: outputPath, size: bytes.byteLength }; } function out( ctx: ImageCliContext, payload: Record, formatted: string, ): void { if (ctx.jsonOutput) { console.log(JSON.stringify(payload, null, 2)); return; } console.log(formatted); } function formatGenerationResult( res: GatewayImageResponse, localPath: string | null, ): string { const lines: string[] = []; lines.push("Generated image"); if (res.prompt) { lines.push(` Prompt: ${res.prompt}`); } if (res.revisedPrompt && res.revisedPrompt !== res.prompt) { lines.push(` Revised: ${res.revisedPrompt}`); } if (res.requestedSize) lines.push(` Size: ${res.requestedSize}`); if (res.quality) lines.push(` Quality: ${res.quality}`); if (res.format) lines.push(` Format: ${res.format}`); if (typeof res.estimatedCostUsd === "number") { lines.push(` Cost (USD): $${res.estimatedCostUsd.toFixed(4)}`); } if (res.path) lines.push(` Workspace: ${res.path}`); if (res.signedUrl) lines.push(` Signed URL: ${res.signedUrl}`); if (localPath) lines.push(` Local file: ${localPath}`); return lines.join("\n"); } // ──────────────────────────────────────────────────────────────────────────── // Subcommands // ──────────────────────────────────────────────────────────────────────────── type CommonImageOpts = { size?: string; quality?: string; format?: string; savePath?: string; output?: string; noWrite: boolean; background?: string; moderation?: string; }; function parseCommonOpts(args: string[]): CommonImageOpts { const size = getFlag(args, "--size"); const quality = getFlag(args, "--quality"); if (quality !== undefined && !ALLOWED_QUALITIES.has(quality)) { throw new ImageCliError( `Invalid --quality: ${quality}. Allowed: ${[...ALLOWED_QUALITIES].join(", ")}`, ); } const format = getFlag(args, "--format"); if (format !== undefined && !ALLOWED_FORMATS.has(format)) { throw new ImageCliError( `Invalid --format: ${format}. Allowed: ${[...ALLOWED_FORMATS].join(", ")}`, ); } const savePath = getFlag(args, "--save-path"); const output = getFlag(args, "--output") ?? getFlag(args, "-o"); const noWrite = hasFlag(args, "--no-write"); const background = getFlag(args, "--background"); if ( background !== undefined && !["auto", "opaque", "transparent"].includes(background) ) { throw new ImageCliError( `Invalid --background: ${background}. Allowed: auto, opaque, transparent`, ); } const moderation = getFlag(args, "--moderation"); if (moderation !== undefined && !["auto", "low"].includes(moderation)) { throw new ImageCliError( `Invalid --moderation: ${moderation}. Allowed: auto, low`, ); } return { size, quality, format, savePath, output, noWrite, background, moderation, }; } function buildGenerationBody( prompt: string, opts: CommonImageOpts, ): Record { const body: Record = { prompt }; if (opts.size) body.size = opts.size; if (opts.quality) body.quality = opts.quality; if (opts.format) body.format = opts.format; if (opts.savePath) body.savePath = opts.savePath; if (opts.background) body.background = opts.background; if (opts.moderation) body.moderation = opts.moderation; // Allow callers to pin organizationId when running with a wildcard // key (rare; org-bound keys take precedence on the gateway side). const orgId = process.env.DENCH_ORG_ID?.trim(); if (orgId) body.organizationId = orgId; const runId = process.env.DENCH_RUN_ID?.trim(); if (runId) body.runId = runId; return body; } async function runGenerateSubcommand(ctx: ImageCliContext): Promise { const opts = parseCommonOpts(ctx.args); // Consume optional --print-b64 BEFORE we collapse the remainder into // the prompt, otherwise it would be glued onto the prompt string. const printB64 = hasFlag(ctx.args, "--print-b64"); const promptParts = ctx.args.slice(); ctx.args.length = 0; const prompt = promptParts.join(" ").trim(); if (!prompt) { throw new ImageCliError( 'Usage: dench image generate "" [--size 1024x1024] [--quality auto|low|medium|high] [--format png|jpeg|webp] [--save-path /workspace/...] [--output ] [--no-write] [--json]', ); } const body = buildGenerationBody(prompt, opts); const result = await callGateway(ctx, "/v1/images/generations", body); if (!result.b64_json) { throw new ImageCliError("Gateway response did not include image bytes."); } let localPath: string | null = null; if (!opts.noWrite) { const target = resolveOutputPath( opts.output, result.path ?? `/image-generations/output.${result.format ?? "png"}`, ); const written = await writeBytesToDisk(target, result.b64_json); localPath = written.path; } out( ctx, { ...result, ok: true, localPath, // Strip the bytes from JSON output by default — they're already // on disk + Convex. Re-include with --print-b64. b64_json: ctx.jsonOutput && printB64 ? result.b64_json : undefined, }, formatGenerationResult(result, localPath), ); } async function runEditSubcommand(ctx: ImageCliContext): Promise { const opts = parseCommonOpts(ctx.args); const printB64 = hasFlag(ctx.args, "--print-b64"); const inputs: string[] = []; // Repeatable --input flag. while (true) { const input = getFlag(ctx.args, "--input") ?? getFlag(ctx.args, "-i"); if (!input) break; inputs.push(input); } const mask = getFlag(ctx.args, "--mask"); const promptParts = ctx.args.slice(); ctx.args.length = 0; const prompt = promptParts.join(" ").trim(); if (!prompt) { throw new ImageCliError( 'Usage: dench image edit "" --input [--input ...] [--mask ] [--size ...] [--quality ...] [--format ...] [--save-path ...] [--output ...] [--no-write] [--json]', ); } if (inputs.length === 0) { throw new ImageCliError( "At least one --input is required for image edits.", ); } if (inputs.length > 8) { throw new ImageCliError( `Too many --input paths (${inputs.length}); the gateway accepts at most 8.`, ); } const inputBase64 = await Promise.all( inputs.map((p) => readBase64FromPath(p)), ); const maskBase64 = mask ? await readBase64FromPath(mask) : undefined; const body = { ...buildGenerationBody(prompt, opts), input_images_base64: inputBase64, ...(maskBase64 ? { mask_base64: maskBase64 } : {}), }; const result = await callGateway(ctx, "/v1/images/edits", body); if (!result.b64_json) { throw new ImageCliError("Gateway response did not include image bytes."); } let localPath: string | null = null; if (!opts.noWrite) { const target = resolveOutputPath( opts.output, result.path ?? `/image-generations/edit.${result.format ?? "png"}`, ); const written = await writeBytesToDisk(target, result.b64_json); localPath = written.path; } out( ctx, { ...result, ok: true, localPath, b64_json: ctx.jsonOutput && printB64 ? result.b64_json : undefined, }, formatGenerationResult(result, localPath), ); } function imageHelp(): void { console.log(imageHelpText()); } export function imageHelpText(): string { return `Usage: dench image Generate or edit images via the Dench Cloud Gateway. Auths with DENCH_API_KEY or the active \`dench signin\` agent session. Agent sessions require a paid workspace. Each call writes the bytes to workspace storage (workspace tree updates immediately) and, when running inside a sandbox, also writes the decoded bytes to local disk so subsequent bash commands see the file without waiting on the daemon's downward sync. Generate from a text prompt: dench image generate "" [--size 1024x1024] [--quality auto|low|medium|high] [--format png|jpeg|webp] [--save-path /workspace/path/file.png] [--output ] [--no-write] [--json] Edit / remix one or more existing images (image-to-image): dench image edit "" --input [--input ...] [--mask ] [--size ...] [--quality ...] [--format ...] [--save-path ...] [--output ...] [--no-write] [--json] Environment variables: DENCH_API_KEY Optional when a dench signin agent session is active. DENCH_GATEWAY_URL Override the gateway base URL. GATEWAY_URL Same, used when DENCH_GATEWAY_URL is unset. DENCH_HOST If it points at localhost, defaults to http://localhost:8787 instead of production. DENCH_VOLUME_PATH Sandbox volume mount root (default: /workspace). DENCH_ORG_ID Forwarded so wildcard keys can target an org. DENCH_RUN_ID Forwarded for telemetry correlation. Exit codes: 0 success, non-zero on gateway / network errors.`; } // ──────────────────────────────────────────────────────────────────────────── // Public entry point // ──────────────────────────────────────────────────────────────────────────── export async function runImageCommand(ctx: ImageCliContext): Promise { const sub = ctx.args[0]; if (!sub || sub === "help" || sub === "--help" || sub === "-h") { imageHelp(); return; } if (sub === "generate" || sub === "gen" || sub === "g") { ctx.args.shift(); await runGenerateSubcommand(ctx); return; } if (sub === "edit" || sub === "e") { ctx.args.shift(); await runEditSubcommand(ctx); return; } throw new ImageCliError( `Unknown subcommand: ${sub}. Run \`dench image help\` for usage.`, ); }