Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Generate images via OpenAI, Google, OpenRouter, DashScope, Jimeng, Seedream, and Replicate APIs with batch support.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/codex-imagegen/main.ts
1#!/usr/bin/env bun2import { readFile, mkdir, copyFile, stat } from "node:fs/promises";3import { homedir } from "node:os";4import path from "node:path";5import process from "node:process";6import { setTimeout as delay } from "node:timers/promises";7import { GenError, type CliOptions, type GenerateResult } from "./types.ts";8import { runCodexExec } from "./spawn.ts";9import { hasImageGenEvidence, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts";10import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts";11import { JsonLogger } from "./logger.ts";1213const HELP = `codex-imagegen — generate images via Codex CLI's image_gen tool1415Usage:16codex-imagegen --image <output.png> [--prompt <text> | --prompt-file <path>] [options]1718Required:19--image <path> Output PNG path20--prompt <text> Prompt text (or use --prompt-file)21--prompt-file <path> Read prompt from file2223Options:24--aspect <ratio> Aspect ratio (1:1, 16:9, 9:16, 4:3, 2.35:1). Default: 1:125--ref <file> Reference image (repeatable)26--timeout <ms> Codex exec timeout in ms. Default: 30000027--retries <n> Retry attempts on retryable errors. Default: 228--retry-delay <ms> Base retry delay (exponential). Default: 150029--cache-dir <path> Enable idempotency cache. Disabled by default.30--log-file <path> Append JSONL log31-v, --verbose Verbose stderr logging32-h, --help Show this help3334Stdout: single JSON line on success or failure.35`;3637const SHELL_METACHAR = /[;|&`$<>\n\r()'"]/;3839function assertSafePath(label: string, value: string): void {40if (SHELL_METACHAR.test(value)) {41throw new GenError(42"invalid_args",43`${label} contains shell metacharacters and would be unsafe to interpolate into the codex instruction: ${value}`,44false,45);46}47}4849function parseArgs(argv: string[]): CliOptions {50const opts: CliOptions = {51prompt: "",52promptFile: null,53outputPath: "",54aspect: "1:1",55refImages: [],56timeoutMs: 300_000,57retries: 2,58retryDelayMs: 1500,59cacheDir: null,60logFile: null,61verbose: false,62};63for (let i = 0; i < argv.length; i++) {64const a = argv[i];65const next = () => argv[++i];66switch (a) {67case "--prompt": opts.prompt = next(); break;68case "--prompt-file": opts.promptFile = next(); break;69case "--image": opts.outputPath = next(); break;70case "--aspect": opts.aspect = next(); break;71case "--ref": opts.refImages.push(next()); break;72case "--timeout": opts.timeoutMs = Number(next()); break;73case "--retries": opts.retries = Number(next()); break;74case "--retry-delay": opts.retryDelayMs = Number(next()); break;75case "--cache-dir": opts.cacheDir = next(); break;76case "--log-file": opts.logFile = next(); break;77case "-v":78case "--verbose": opts.verbose = true; break;79case "-h":80case "--help": process.stdout.write(HELP); process.exit(0);81default: throw new GenError("invalid_args", `Unknown argument: ${a}`, false);82}83}84if (!opts.outputPath) throw new GenError("invalid_args", "--image is required", false);85if (opts.prompt && opts.promptFile) {86throw new GenError("invalid_args", "--prompt and --prompt-file are mutually exclusive", false);87}88if (!opts.prompt && !opts.promptFile) {89throw new GenError("invalid_args", "--prompt or --prompt-file required", false);90}9192// Resolve every filesystem path to absolute up front, so behavior is93// independent of the caller's cwd. This matters when the wrapper is94// invoked from a skill running in an arbitrary working directory.95const cwd = process.cwd();96const toAbs = (p: string) => (path.isAbsolute(p) ? p : path.resolve(cwd, p));9798opts.outputPath = toAbs(opts.outputPath);99if (opts.promptFile) opts.promptFile = toAbs(opts.promptFile);100opts.refImages = opts.refImages.map(toAbs);101if (opts.cacheDir) opts.cacheDir = toAbs(opts.cacheDir);102if (opts.logFile) opts.logFile = toAbs(opts.logFile);103104// The output and ref paths are interpolated raw into the agent instruction105// sent to `codex exec --sandbox danger-full-access`. A path containing shell106// metacharacters could be misread by the agent's shell when it cp's the107// result into place. Reject upfront rather than trusting the agent to quote.108assertSafePath("--image path", opts.outputPath);109for (const ref of opts.refImages) assertSafePath("--ref path", ref);110111return opts;112}113114async function loadPrompt(opts: CliOptions): Promise<string> {115if (opts.prompt) return opts.prompt;116const file = opts.promptFile!;117try {118return await readFile(file, "utf-8");119} catch {120throw new GenError("prompt_file_missing", `Prompt file not found: ${file}`, false);121}122}123124function buildInstruction(prompt: string, opts: CliOptions): string {125const refHint = opts.refImages.length > 0126? `\nREFERENCE IMAGES (attached above): ${opts.refImages.length} image(s) provided for style/composition guidance.\n`127: "";128return `You have an internal tool called image_gen for image generation. You MUST call it before doing anything else.129130TASK: Generate an image with the spec below, then save to disk.131132PROMPT:133${prompt}134135ASPECT RATIO: ${opts.aspect}136OUTPUT PATH: ${opts.outputPath}137${refHint}138STEPS:1391. Call image_gen with the prompt and aspect ratio above${opts.refImages.length > 0 ? " (using the attached reference images for guidance)" : ""}.1402. Move or copy ONLY the image produced by that image_gen call from Codex default location ($CODEX_HOME/generated_images/...) to: ${opts.outputPath}1413. Verify with: ls -la ${opts.outputPath}1424. Reply with ONLY this JSON line (no markdown fences, no other text):143{"status":"ok","path":"${opts.outputPath}","bytes":<file_size_in_bytes>}144145HARD CONSTRAINTS:146- Do NOT search for, find, inspect, reuse, or copy any pre-existing files from $CODEX_HOME/generated_images/ or any other directory.147- Do NOT run ls/find/rg/grep/glob over $CODEX_HOME/generated_images/ before image_gen has been called.148- You MUST call image_gen first. Only after image_gen completes may you copy the newly created file from this turn.149- Do NOT use curl, wget, Python, or any external API.150- Do NOT use bash to fabricate an image; only image_gen produces real pixels.151- Use ONLY the image_gen internal tool.`;152}153154async function attemptGenerate(155opts: CliOptions,156instruction: string,157attempt: number,158log: JsonLogger,159): Promise<{ bytes: number; threadId: string | null; usage: any; toolCalls: any[] }> {160await log.info("attempt.start", { attempt, output: opts.outputPath, aspect: opts.aspect });161162const run = await runCodexExec({163instruction,164timeoutMs: opts.timeoutMs,165refImages: opts.refImages,166});167168await log.info("codex.completed", {169duration_ms: run.durationMs,170thread_id: run.threadId,171tool_calls: run.toolCalls.length,172usage: run.usage,173raw_log: run.rawLogPath,174});175176// verify: thread id must be present177if (!run.threadId) {178throw new GenError("agent_refused", "No thread id in event stream");179}180181// verify image_gen ran in THIS thread. A PNG in this thread's182// generated_images dir is the real signal (image_gen does not surface as a183// stream item); the stream check is a forward-compatible fallback. The #185184// shortcut (copying an unrelated history image) yields neither.185const ver = await verifyImageGenWasInvoked(run.threadId);186if (!hasImageGenEvidence(run.toolCalls, ver.ok)) {187throw new GenError(188"no_image_gen_tool_use",189`image_gen was not invoked (no image_gen event in stream; ${ver.reason})`,190);191}192193// verify output194const { bytes } = await verifyOutput(opts.outputPath);195196return {197bytes,198threadId: run.threadId,199usage: run.usage,200toolCalls: run.toolCalls.map((tc) => ({ tool: tc.tool, status: tc.status })),201};202}203204async function generate(opts: CliOptions, log: JsonLogger): Promise<GenerateResult> {205const startEpoch = Date.now();206const prompt = await loadPrompt(opts);207208// Cache lookup209if (opts.cacheDir) {210const key = cacheKey(prompt, opts.aspect, opts.refImages);211const cached = await lookupCache(opts.cacheDir, key);212if (cached) {213await mkdir(path.dirname(opts.outputPath), { recursive: true });214await copyFile(cached, opts.outputPath);215const s = await stat(opts.outputPath);216await log.info("cache.hit", { key, source: cached });217return {218status: "ok",219path: opts.outputPath,220bytes: s.size,221elapsed_seconds: 0,222thread_id: null,223attempts: 0,224cached: true,225usage: null,226tool_calls: [],227};228}229await log.info("cache.miss", { key });230}231232// lock to prevent concurrent codex exec233const lockDir = opts.cacheDir ?? path.join(homedir(), ".cache", "baoyu-codex-imagegen");234const lock = new FileLock(path.join(lockDir, "codex-exec.lock"));235try {236await lock.acquire(60_000);237} catch (e) {238throw new GenError("lock_busy", String(e), false);239}240241await mkdir(path.dirname(opts.outputPath), { recursive: true });242const instruction = buildInstruction(prompt, opts);243244let lastErr: GenError | null = null;245let lastAttempt = 0;246try {247for (let attempt = 1; attempt <= opts.retries + 1; attempt++) {248lastAttempt = attempt;249try {250const result = await attemptGenerate(opts, instruction, attempt, log);251252// write to cache253if (opts.cacheDir) {254const key = cacheKey(prompt, opts.aspect, opts.refImages);255await storeCache(opts.cacheDir, key, opts.outputPath);256await log.info("cache.stored", { key });257}258259return {260status: "ok",261path: opts.outputPath,262bytes: result.bytes,263elapsed_seconds: Math.round((Date.now() - startEpoch) / 1000),264thread_id: result.threadId,265attempts: attempt,266cached: false,267usage: result.usage,268tool_calls: result.toolCalls,269};270} catch (e) {271lastErr = e instanceof GenError ? e : new GenError("spawn_failed", String(e));272await log.warn("attempt.failed", {273attempt,274kind: lastErr.kind,275retryable: lastErr.retryable,276error: lastErr.message,277});278if (!lastErr.retryable || attempt > opts.retries) break;279const wait = opts.retryDelayMs * Math.pow(2, attempt - 1);280await log.info("retry.wait", { wait_ms: wait, next_attempt: attempt + 1 });281await delay(wait);282}283}284} finally {285await lock.release();286}287288const err = lastErr ?? new GenError("spawn_failed", "Unknown failure");289err.attempts = lastAttempt;290throw err;291}292293async function main() {294let opts: CliOptions;295try {296opts = parseArgs(process.argv.slice(2));297} catch (e) {298const err = e instanceof GenError ? e : new GenError("invalid_args", String(e), false);299process.stderr.write(`Error: ${err.message}\n`);300process.exit(2);301}302303const log = new JsonLogger(opts.logFile, opts.verbose);304await log.info("start", { output: opts.outputPath, aspect: opts.aspect, refs: opts.refImages.length });305306try {307const result = await generate(opts, log);308await log.info("done", { bytes: result.bytes, attempts: result.attempts, cached: result.cached });309process.stdout.write(JSON.stringify(result) + "\n");310process.exit(0);311} catch (e) {312const err = e instanceof GenError ? e : new GenError("spawn_failed", String(e));313await log.error("failed", { kind: err.kind, error: err.message, attempts: err.attempts ?? 0 });314const out: GenerateResult = {315status: "error",316path: opts.outputPath,317bytes: 0,318elapsed_seconds: 0,319thread_id: null,320attempts: err.attempts ?? 0,321cached: false,322usage: null,323tool_calls: [],324error: err.message,325error_kind: err.kind,326};327process.stdout.write(JSON.stringify(out) + "\n");328process.exit(1);329}330}331332main();333