diff --git a/skills/baoyu-image-gen/SKILL.md b/skills/baoyu-image-gen/SKILL.md index dbedea6..8acc071 100644 --- a/skills/baoyu-image-gen/SKILL.md +++ b/skills/baoyu-image-gen/SKILL.md @@ -1,7 +1,7 @@ --- name: baoyu-image-gen description: AI image generation with OpenAI GPT Image 2, Azure OpenAI, Google, OpenRouter, DashScope, Z.AI GLM-Image, MiniMax, Jimeng, Seedream and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images. -version: 2.0.0 +version: 2.1.0 metadata: openclaw: homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-gen @@ -81,6 +81,9 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider d # OpenAI GPT Image 2 ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openai --model gpt-image-2 +# Codex CLI (uses logged-in Codex subscription — no OPENAI_API_KEY required; requires `codex` on PATH) +${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider codex-cli --ar 16:9 + # Batch mode ${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4 ``` @@ -104,7 +107,7 @@ When the user wants a person/object preserved from reference images: | `--image ` | Output image path (required in single-image mode) | | `--batchfile ` | JSON batch file for multi-image generation | | `--jobs ` | Worker count for batch mode (default: auto, max from config, built-in default 10) | -| `--provider google\|openai\|azure\|openrouter\|dashscope\|zai\|minimax\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) | +| `--provider google\|openai\|azure\|openrouter\|dashscope\|zai\|minimax\|jimeng\|seedream\|replicate\|codex-cli` | Force provider (default: auto-detect; `codex-cli` is never auto-selected — must be pinned via CLI or EXTEND.md) | | `--model `, `-m` | Model ID — see provider references for defaults and allowed values | | `--ar ` | Aspect ratio (`16:9`, `1:1`, `4:3`, …) | | `--size ` | Explicit size (e.g., `1024x1024`; for `gpt-image-2`, width/height must be multiples of 16, max edge 3840px, ratio no wider than 3:1) | @@ -137,8 +140,13 @@ When the user wants a person/object preserved from reference images: | `OPENAI_IMAGE_API_DIALECT` | `openai-native` \| `ratio-metadata` | | `OPENROUTER_HTTP_REFERER`, `OPENROUTER_TITLE` | Optional OpenRouter attribution | | `BAOYU_IMAGE_GEN_MAX_WORKERS` | Override batch worker cap | -| `BAOYU_IMAGE_GEN__CONCURRENCY` | Per-provider concurrency (e.g., `BAOYU_IMAGE_GEN_REPLICATE_CONCURRENCY`) | +| `BAOYU_IMAGE_GEN__CONCURRENCY` | Per-provider concurrency (e.g., `BAOYU_IMAGE_GEN_REPLICATE_CONCURRENCY`; for codex-cli use `BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY`) | | `BAOYU_IMAGE_GEN__START_INTERVAL_MS` | Per-provider start-gap | +| `BAOYU_CODEX_IMAGEGEN_BIN` | Override the codex-imagegen wrapper path for the `codex-cli` provider (default: bundled `scripts/codex-imagegen/main.ts`; accepts `.ts` or legacy `.sh`/binary) | +| `BAOYU_CODEX_IMAGEGEN_CACHE_DIR` | Enable idempotency cache for the `codex-cli` provider (off by default) | +| `BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS` | Per-attempt `codex exec` timeout for the `codex-cli` provider (default: 300000 ms) | +| `BAOYU_CODEX_IMAGEGEN_RETRIES` | Wrapper-side retry attempts on retryable errors for the `codex-cli` provider (default: 2) | +| `BAOYU_CODEX_IMAGEGEN_LOG_FILE` | Append JSONL diagnostic log for the `codex-cli` provider | **Load priority**: CLI args > EXTEND.md > env vars > `/.baoyu-skills/.env` > `~/.baoyu-skills/.env` @@ -149,10 +157,10 @@ When the user wants a person/object preserved from reference images: If the user wants to use their Codex subscription / GPT Image 2 entitlement without an OpenAI API key, route through a Codex-native backend instead of this skill's `openai` provider: - In Codex runtime: use the native `imagegen` skill/tool. -- In non-Codex runtimes with `codex` CLI installed and logged in: use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it (for example `baoyu-cover-image`). Resolve it from the plugin/repo root and pass absolute prompt/output/reference paths. +- In non-Codex runtimes with `codex` CLI installed and logged in: use `baoyu-image-gen --provider codex-cli` (preferred — it gives you the same retry / cache / batch flow as every other provider). The provider spawns the bundled `scripts/codex-imagegen/main.ts`; the same code lives upstream at `packages/baoyu-codex-imagegen/src/main.ts` for standalone callers. - In Hermes runtimes with a native `image_generate` tool: use that tool as a fallback, and state whether reference images were passed directly or reconstructed from extracted traits. -Do not modify the existing `openai` provider to silently consume Codex OAuth. If first-class Codex OAuth support is added to `baoyu-image-gen`, implement it as a distinct provider (for example `openai-codex`) with its own auth, route, request shape, docs, and tests. See `references/codex-oauth-vs-openai-api-key.md`. +Do not modify the existing `openai` provider to silently consume Codex OAuth. The first-class Codex-CLI path is the dedicated `codex-cli` provider, which has its own auth (Codex login), route (`codex exec`), request shape, and tests. See `references/codex-oauth-vs-openai-api-key.md`. ## Model Resolution @@ -194,13 +202,15 @@ Each provider has its own quirks (model families, size rules, ref support, limit | MiniMax (image-01, subject-reference) | `references/providers/minimax.md` | | OpenRouter (multimodal models, `/chat/completions` flow) | `references/providers/openrouter.md` | | Replicate (nano-banana, Seedream, Wan) | `references/providers/replicate.md` | +| Codex CLI (wraps bundled `scripts/codex-imagegen/`; Codex login, no `OPENAI_API_KEY`) | `references/providers/codex-cli.md` | ## Provider Selection 1. `--ref` provided + no `--provider` → auto-select Google → OpenAI → Azure → OpenRouter → Replicate → Seedream → MiniMax (MiniMax's subject reference is more specialized toward character/portrait consistency) -2. `--provider` specified → use it (if `--ref`, must be google/openai/azure/openrouter/replicate/seedream/minimax) +2. `--provider` specified → use it (if `--ref`, must be google/openai/azure/openrouter/replicate/seedream/minimax/codex-cli) 3. Only one API key present → use that provider 4. Multiple keys → default priority: Google → OpenAI → Azure → OpenRouter → DashScope → Z.AI → MiniMax → Replicate → Jimeng → Seedream +5. `codex-cli` is **never auto-selected** — set `default_provider: codex-cli` in EXTEND.md or pass `--provider codex-cli`. It spawns `codex exec` via the bundled `scripts/codex-imagegen/main.ts` TS entrypoint (run with `bun`) and uses the user's Codex subscription (no `OPENAI_API_KEY`). Requires `codex` on `PATH` with an active `codex login`. ## Quality Presets diff --git a/skills/baoyu-image-gen/references/codex-image2-fallback.md b/skills/baoyu-image-gen/references/codex-image2-fallback.md index bbeff16..bfab238 100644 --- a/skills/baoyu-image-gen/references/codex-image2-fallback.md +++ b/skills/baoyu-image-gen/references/codex-image2-fallback.md @@ -19,7 +19,7 @@ This is expected. The `openai` provider uses the public OpenAI Images API and ne 2. If it fails only because `OPENAI_API_KEY` is missing, do not leave the user waiting. 3. Prefer a Codex/native raster backend in this order: - Codex runtime native `imagegen` skill/tool, if available. - - Repo-level `scripts/codex-imagegen.sh`, if `codex` CLI is installed/logged in and the calling skill supports the wrapper. + - `baoyu-image-gen --provider codex-cli` (preferred — wraps the bundled `scripts/codex-imagegen/main.ts`; the underlying repo-level package lives at `packages/baoyu-codex-imagegen/src/main.ts` for standalone callers), if `codex` CLI is installed/logged in. - Hermes native `image_generate`, if available. 4. Be transparent about reference-image behavior: - If the fallback backend accepts references, pass the reference images. diff --git a/skills/baoyu-image-gen/references/codex-oauth-vs-openai-api-key.md b/skills/baoyu-image-gen/references/codex-oauth-vs-openai-api-key.md index f5fb470..97088b3 100644 --- a/skills/baoyu-image-gen/references/codex-oauth-vs-openai-api-key.md +++ b/skills/baoyu-image-gen/references/codex-oauth-vs-openai-api-key.md @@ -7,9 +7,9 @@ Codex / ChatGPT login is different. Codex image generation is driven by Codex OA ## What to use instead - If running inside Codex and the native `imagegen` skill/tool is available, use it directly. -- If running outside Codex but the `codex` CLI is installed and logged in, use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it. The wrapper invokes `codex exec` and the Codex `image_gen` tool; no `OPENAI_API_KEY` is required. +- If running outside Codex but the `codex` CLI is installed and logged in, call `baoyu-image-gen --provider codex-cli` (preferred). It spawns the bundled `scripts/codex-imagegen/main.ts` and surfaces its retry/cache/log machinery through baoyu-image-gen's standard CLI + batch flow. Standalone callers outside this skill can run the same code at `packages/baoyu-codex-imagegen/src/main.ts`. Both invoke `codex exec` and the Codex `image_gen` tool; no `OPENAI_API_KEY` is required. - If running inside Hermes and a native `image_generate` tool is available, use that as a runtime-native fallback. Be explicit about whether reference images are passed directly or only reconstructed from extracted traits. -- If the user wants `baoyu-image-gen` itself to support Codex OAuth, add a distinct provider such as `openai-codex` rather than modifying the existing `openai` provider. +- `baoyu-image-gen` already exposes a distinct `codex-cli` provider (wraps the bundled `scripts/codex-imagegen/`); do not modify the existing `openai` provider to add Codex OAuth. ## Reference-image prompting note diff --git a/skills/baoyu-image-gen/references/config/preferences-schema.md b/skills/baoyu-image-gen/references/config/preferences-schema.md index b0859b6..6c2e893 100644 --- a/skills/baoyu-image-gen/references/config/preferences-schema.md +++ b/skills/baoyu-image-gen/references/config/preferences-schema.md @@ -11,7 +11,7 @@ description: EXTEND.md YAML schema for baoyu-image-gen user preferences --- version: 1 -default_provider: null # google|openai|azure|openrouter|dashscope|zai|minimax|replicate|null (null = auto-detect) +default_provider: null # google|openai|azure|openrouter|dashscope|zai|minimax|replicate|jimeng|seedream|codex-cli|null (null = auto-detect; codex-cli is never auto-detected — pin it here or via --provider) default_quality: null # normal|2k|null (null = use default: 2k) @@ -30,6 +30,7 @@ default_model: zai: null # e.g., "glm-image" minimax: null # e.g., "image-01" replicate: null # e.g., "google/nano-banana-2" + codex-cli: null # Logical label only — Codex image_gen has no user-selectable model. Default: "codex-image-gen" batch: max_workers: 10 @@ -58,6 +59,9 @@ batch: minimax: concurrency: 3 start_interval_ms: 1100 + codex-cli: + concurrency: 1 + start_interval_ms: 2000 --- ``` @@ -79,6 +83,7 @@ batch: | `default_model.zai` | string\|null | null | Z.AI default model | | `default_model.minimax` | string\|null | null | MiniMax default model | | `default_model.replicate` | string\|null | null | Replicate default model | +| `default_model.codex-cli` | string\|null | null | Codex-CLI logical label (Codex image_gen has no user-selectable model) | | `batch.max_workers` | int\|null | 10 | Batch worker cap | | `batch.provider_limits..concurrency` | int\|null | provider default | Max simultaneous requests per provider | | `batch.provider_limits..start_interval_ms` | int\|null | provider default | Minimum gap between request starts per provider | diff --git a/skills/baoyu-image-gen/references/providers/codex-cli.md b/skills/baoyu-image-gen/references/providers/codex-cli.md new file mode 100644 index 0000000..a815171 --- /dev/null +++ b/skills/baoyu-image-gen/references/providers/codex-cli.md @@ -0,0 +1,81 @@ +# Codex CLI (`--provider codex-cli`) + +Read when the user picks `--provider codex-cli`, sets `default_provider: codex-cli`, or asks for "Codex image generation without an OpenAI API key". This provider is a thin baoyu-image-gen wrapper around the bundled `scripts/codex-imagegen/main.ts` (synced from `packages/baoyu-codex-imagegen`), which spawns `codex exec --json --sandbox danger-full-access` and routes the request to Codex CLI's built-in `image_gen` tool. The Codex CLI uses the **user's Codex / ChatGPT subscription** — no `OPENAI_API_KEY` is read or sent. + +## Prerequisites + +```bash +npm install -g @openai/codex +codex login # signs in with the user's OpenAI / Codex account +codex --version # confirm >= 0.130 +``` + +`bun` is required for running the underlying wrapper (`scripts/codex-imagegen/main.ts`, carrying `#!/usr/bin/env bun`). If `bun` is missing from the runtime, `npx -y bun` works as a fallback. + +## Selection + +- **Never auto-selected.** `detectProvider` only picks `codex-cli` when it is pinned explicitly: pass `--provider codex-cli` or set `default_provider: codex-cli` in EXTEND.md. +- Choose this provider when: + - The user has a Codex subscription and explicitly does **not** want to manage an OpenAI API key. + - You need Codex's specific `image_gen` behavior or quality. +- Avoid this provider when latency matters — Codex CLI is typically 5–10× slower than direct OpenAI / Google API calls (except on cache hits). + +## Supported flags + +| Flag | Behavior | +|------|----------| +| `--prompt ` / `--promptfiles ` | Required. Written to a temp file and passed to the wrapper as `--prompt-file`. | +| `--image ` | Required. Final output PNG location. | +| `--ar ` | Mapped to wrapper's `--aspect`. Supported by Codex: `1:1` (default), `16:9`, `9:16`, `4:3`, `2.35:1`. | +| `--ref ` | Mapped to wrapper's repeated `--ref`. Codex's `image_gen` accepts reference images for style/composition guidance. | +| `--n` | Must be `1`. `validateArgs` throws if `n > 1` because Codex `image_gen` returns a single image per call. | +| `--imageApiDialect` | Not applicable. Throws if set to a non-default value. | +| `--size`, `--imageSize`, `--quality` | Silently ignored — Codex picks pixel dimensions from the aspect ratio. | +| `--model`, `-m` | Logical label only. The wrapper does not forward a model selector to Codex; the underlying engine is whichever model Codex's `image_gen` currently uses. Default label: `codex-image-gen`. | + +## Environment variables + +| Variable | Effect | +|----------|--------| +| `BAOYU_CODEX_IMAGEGEN_BIN` | Override the wrapper path. Default: bundled `scripts/codex-imagegen/main.ts` resolved relative to this skill's installed location. Accepts a `.ts` file (spawned with `bun`) or a legacy `.sh`/binary (spawned directly). | +| `BAOYU_CODEX_IMAGEGEN_CACHE_DIR` | Enable the wrapper's idempotency cache. Disabled by default; set to e.g. `~/.cache/baoyu-codex-imagegen` for high-value reuse. | +| `BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS` | Per-attempt `codex exec` timeout in ms. Default: `300000` (5 min). Raise for slow networks or large prompts. | +| `BAOYU_CODEX_IMAGEGEN_RETRIES` | Wrapper-side retry attempts on retryable errors. Default: `2` (3 total attempts). | +| `BAOYU_CODEX_IMAGEGEN_LOG_FILE` | Append a structured JSONL diagnostic log. Useful when triaging timeouts or `agent_refused` errors. | +| `BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY` | Batch-mode concurrency for the `codex-cli` provider. Default: `1` — Codex exec is a heavy single-process workflow; raising this rarely helps. | +| `BAOYU_IMAGE_GEN_CODEX_CLI_START_INTERVAL_MS` | Batch-mode minimum start-gap. Default: `2000` ms. | + +## Error model + +The wrapper emits a single JSON line on stdout. On failure: + +```json +{"status":"error","path":"...","bytes":0,"error":"...","error_kind":"..."} +``` + +The provider re-throws each wrapper error as `Invalid codex-cli result (): `. The `"Invalid "` prefix triggers `isRetryableGenerationError` to mark it **non-retryable** in baoyu-image-gen's outer retry loop — the wrapper has already retried internally per `BAOYU_CODEX_IMAGEGEN_RETRIES`, so re-spawning Codex from main.ts would only multiply latency without changing the outcome. + +`error_kind` values to expect: + +| Kind | Cause | Action | +|------|-------|--------| +| `codex_not_installed` | `codex` not on `PATH` or unreadable | `npm install -g @openai/codex`, then `codex login`. | +| `invalid_args` | Programmer error in the spawn invocation | Inspect provider source; usually a path-injection guard fired. | +| `prompt_file_missing` | Temp prompt file vanished mid-call | Retry once; check `$TMPDIR` permissions. | +| `spawn_failed` | OS / process-launch failure | Verify `bun` or `npx` is installed; check filesystem permissions. | +| `timeout` | `codex exec` exceeded `--timeout` | Raise `BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS`; check network. | +| `no_image_gen_tool_use` | Codex agent answered without calling `image_gen` | Often transient — retry. If persistent, refine the prompt. | +| `output_missing` / `invalid_png` | Agent reported success but file is absent or not a valid PNG | Retry; check disk space. | +| `agent_refused` | Codex agent refused (policy or content) | Adjust the prompt; surface the refusal to the user. | +| `lock_busy` | Another `codex-imagegen` invocation holds the file lock | Wait or set a distinct `--cache-dir` per concurrent caller. | + +## Trade-offs + +- Slow: 5–10× direct OpenAI API latency (except cache hits). +- Subject to the same TOS as interactive `codex exec` use — programmatic invocation from baoyu-image-gen is the same usage class. +- Stateful: requires `codex login` to be live; an expired session manifests as `codex_not_installed` or `agent_refused`. + +## See also + +- `references/codex-oauth-vs-openai-api-key.md` — why Codex OAuth is not interchangeable with `OPENAI_API_KEY`. +- `references/codex-image2-fallback.md` — when to fall back to `codex-cli` from a failed `openai` provider call. diff --git a/skills/baoyu-image-gen/references/usage-examples.md b/skills/baoyu-image-gen/references/usage-examples.md index d148bc0..cbdd796 100644 --- a/skills/baoyu-image-gen/references/usage-examples.md +++ b/skills/baoyu-image-gen/references/usage-examples.md @@ -77,8 +77,19 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic portrait" --image out.p # Replicate Wan 2.7 Image Pro ${BUN_X} {baseDir}/scripts/main.ts --prompt "A concept frame" --image out.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152 + +# Codex CLI (uses Codex / ChatGPT subscription — no OPENAI_API_KEY; requires `codex` on PATH and `codex login`) +${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic portrait" --image out.png --provider codex-cli --ar 16:9 + +# Codex CLI with reference images (style/composition guidance) +${BUN_X} {baseDir}/scripts/main.ts --prompt "Match this color palette" --image out.png --provider codex-cli --ref source.png --ar 1:1 ``` +Notes on `codex-cli`: +- Never auto-selected — pin via `--provider codex-cli` or `default_provider: codex-cli` in EXTEND.md. +- Only `n=1` supported (Codex `image_gen` returns one image per call); `--size`, `--imageSize`, `--quality`, and `--imageApiDialect` are ignored or rejected. +- Typically 5–10× slower than direct OpenAI / Google API calls (except on cache hits). Tune via `BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS`, `BAOYU_CODEX_IMAGEGEN_RETRIES`, and `BAOYU_CODEX_IMAGEGEN_CACHE_DIR`. + ## Batch Mode ```bash diff --git a/skills/baoyu-image-gen/scripts/codex-imagegen/cache.ts b/skills/baoyu-image-gen/scripts/codex-imagegen/cache.ts new file mode 100644 index 0000000..063bf35 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/codex-imagegen/cache.ts @@ -0,0 +1,80 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile, copyFile, stat } from "node:fs/promises"; +import { existsSync, openSync, closeSync } from "node:fs"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +export function cacheKey(prompt: string, aspect: string, refs: string[]): string { + const h = createHash("sha256"); + h.update(prompt); + h.update("|"); + h.update(aspect); + h.update("|"); + for (const r of [...refs].sort()) h.update(r); + return h.digest("hex").slice(0, 16); +} + +export async function lookupCache(cacheDir: string, key: string): Promise { + const entry = path.join(cacheDir, `${key}.png`); + try { + const s = await stat(entry); + if (s.size > 1000) return entry; + } catch {} + return null; +} + +export async function storeCache(cacheDir: string, key: string, sourcePath: string): Promise { + await mkdir(cacheDir, { recursive: true }); + const entry = path.join(cacheDir, `${key}.png`); + await copyFile(sourcePath, entry); +} + +export class FileLock { + private fd: number | null = null; + constructor(private lockPath: string) {} + + async acquire(timeoutMs = 30_000): Promise { + const start = Date.now(); + await mkdir(path.dirname(this.lockPath), { recursive: true }); + while (Date.now() - start < timeoutMs) { + try { + this.fd = openSync(this.lockPath, "wx"); + return; + } catch (e: any) { + if (e.code !== "EEXIST") throw e; + if (await this.isStale()) { + try { + await this.release(true); + } catch {} + continue; + } + await delay(200); + } + } + throw new Error(`Failed to acquire lock at ${this.lockPath} within ${timeoutMs}ms`); + } + + private async isStale(): Promise { + try { + const s = await stat(this.lockPath); + return Date.now() - s.mtimeMs > 10 * 60 * 1000; + } catch { + return true; + } + } + + async release(force = false): Promise { + if (this.fd != null) { + try { + closeSync(this.fd); + } catch {} + this.fd = null; + } + if (existsSync(this.lockPath) || force) { + const { unlink } = await import("node:fs/promises"); + try { + await unlink(this.lockPath); + } catch {} + } + } +} diff --git a/skills/baoyu-image-gen/scripts/codex-imagegen/logger.ts b/skills/baoyu-image-gen/scripts/codex-imagegen/logger.ts new file mode 100644 index 0000000..81f9716 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/codex-imagegen/logger.ts @@ -0,0 +1,39 @@ +import { appendFile, mkdir } from "node:fs/promises"; +import path from "node:path"; + +export interface LogEntry { + ts: string; + level: "info" | "warn" | "error"; + event: string; + [k: string]: unknown; +} + +export class JsonLogger { + constructor(private logFile: string | null, public verbose: boolean) {} + + async log(level: LogEntry["level"], event: string, extra: Record = {}): Promise { + const entry: LogEntry = { ts: new Date().toISOString(), level, event, ...extra }; + const line = JSON.stringify(entry); + if (this.verbose) process.stderr.write(`[${level}] ${event} ${jsonExtras(extra)}\n`); + if (this.logFile) { + await mkdir(path.dirname(this.logFile), { recursive: true }); + await appendFile(this.logFile, line + "\n", "utf-8"); + } + } + + info(event: string, extra?: Record) { + return this.log("info", event, extra); + } + warn(event: string, extra?: Record) { + return this.log("warn", event, extra); + } + error(event: string, extra?: Record) { + return this.log("error", event, extra); + } +} + +function jsonExtras(extra: Record): string { + const entries = Object.entries(extra); + if (entries.length === 0) return ""; + return entries.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" "); +} diff --git a/skills/baoyu-image-gen/scripts/codex-imagegen/main.ts b/skills/baoyu-image-gen/scripts/codex-imagegen/main.ts new file mode 100755 index 0000000..ac66035 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/codex-imagegen/main.ts @@ -0,0 +1,326 @@ +#!/usr/bin/env bun +import { readFile, mkdir, copyFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { setTimeout as delay } from "node:timers/promises"; +import { GenError, type CliOptions, type GenerateResult } from "./types.ts"; +import { runCodexExec } from "./spawn.ts"; +import { findCpToTarget, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts"; +import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts"; +import { JsonLogger } from "./logger.ts"; + +const HELP = `codex-imagegen — generate images via Codex CLI's image_gen tool + +Usage: + codex-imagegen --image [--prompt | --prompt-file ] [options] + +Required: + --image Output PNG path + --prompt Prompt text (or use --prompt-file) + --prompt-file Read prompt from file + +Options: + --aspect Aspect ratio (1:1, 16:9, 9:16, 4:3, 2.35:1). Default: 1:1 + --ref Reference image (repeatable) + --timeout Codex exec timeout in ms. Default: 300000 + --retries Retry attempts on retryable errors. Default: 2 + --retry-delay Base retry delay (exponential). Default: 1500 + --cache-dir Enable idempotency cache. Disabled by default. + --log-file Append JSONL log + -v, --verbose Verbose stderr logging + -h, --help Show this help + +Stdout: single JSON line on success or failure. +`; + +const SHELL_METACHAR = /[;|&`$<>\n\r()'"]/; + +function assertSafePath(label: string, value: string): void { + if (SHELL_METACHAR.test(value)) { + throw new GenError( + "invalid_args", + `${label} contains shell metacharacters and would be unsafe to interpolate into the codex instruction: ${value}`, + false, + ); + } +} + +function parseArgs(argv: string[]): CliOptions { + const opts: CliOptions = { + prompt: "", + promptFile: null, + outputPath: "", + aspect: "1:1", + refImages: [], + timeoutMs: 300_000, + retries: 2, + retryDelayMs: 1500, + cacheDir: null, + logFile: null, + verbose: false, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const next = () => argv[++i]; + switch (a) { + case "--prompt": opts.prompt = next(); break; + case "--prompt-file": opts.promptFile = next(); break; + case "--image": opts.outputPath = next(); break; + case "--aspect": opts.aspect = next(); break; + case "--ref": opts.refImages.push(next()); break; + case "--timeout": opts.timeoutMs = Number(next()); break; + case "--retries": opts.retries = Number(next()); break; + case "--retry-delay": opts.retryDelayMs = Number(next()); break; + case "--cache-dir": opts.cacheDir = next(); break; + case "--log-file": opts.logFile = next(); break; + case "-v": + case "--verbose": opts.verbose = true; break; + case "-h": + case "--help": process.stdout.write(HELP); process.exit(0); + default: throw new GenError("invalid_args", `Unknown argument: ${a}`, false); + } + } + if (!opts.outputPath) throw new GenError("invalid_args", "--image is required", false); + if (opts.prompt && opts.promptFile) { + throw new GenError("invalid_args", "--prompt and --prompt-file are mutually exclusive", false); + } + if (!opts.prompt && !opts.promptFile) { + throw new GenError("invalid_args", "--prompt or --prompt-file required", false); + } + + // Resolve every filesystem path to absolute up front, so behavior is + // independent of the caller's cwd. This matters when the wrapper is + // invoked from a skill running in an arbitrary working directory. + const cwd = process.cwd(); + const toAbs = (p: string) => (path.isAbsolute(p) ? p : path.resolve(cwd, p)); + + opts.outputPath = toAbs(opts.outputPath); + if (opts.promptFile) opts.promptFile = toAbs(opts.promptFile); + opts.refImages = opts.refImages.map(toAbs); + if (opts.cacheDir) opts.cacheDir = toAbs(opts.cacheDir); + if (opts.logFile) opts.logFile = toAbs(opts.logFile); + + // The output and ref paths are interpolated raw into the agent instruction + // sent to `codex exec --sandbox danger-full-access`. A path containing shell + // metacharacters could be misread by the agent's shell when it cp's the + // result into place. Reject upfront rather than trusting the agent to quote. + assertSafePath("--image path", opts.outputPath); + for (const ref of opts.refImages) assertSafePath("--ref path", ref); + + return opts; +} + +async function loadPrompt(opts: CliOptions): Promise { + if (opts.prompt) return opts.prompt; + const file = opts.promptFile!; + try { + return await readFile(file, "utf-8"); + } catch { + throw new GenError("prompt_file_missing", `Prompt file not found: ${file}`, false); + } +} + +function buildInstruction(prompt: string, opts: CliOptions): string { + const refHint = opts.refImages.length > 0 + ? `\nREFERENCE IMAGES (attached above): ${opts.refImages.length} image(s) provided for style/composition guidance.\n` + : ""; + return `You have an internal tool called image_gen for image generation. Use it. + +TASK: Generate an image with the spec below, then save to disk. + +PROMPT: +${prompt} + +ASPECT RATIO: ${opts.aspect} +OUTPUT PATH: ${opts.outputPath} +${refHint} +STEPS: +1. Call image_gen with the prompt and aspect ratio above${opts.refImages.length > 0 ? " (using the attached reference images for guidance)" : ""}. +2. Move or copy the resulting image from Codex default location ($CODEX_HOME/generated_images/...) to: ${opts.outputPath} +3. Verify with: ls -la ${opts.outputPath} +4. Reply with ONLY this JSON line (no markdown fences, no other text): + {"status":"ok","path":"${opts.outputPath}","bytes":} + +HARD CONSTRAINTS: +- Do NOT use curl, wget, Python, or any external API. +- Do NOT use bash to fabricate an image; only image_gen produces real pixels. +- Use ONLY the image_gen internal tool.`; +} + +async function attemptGenerate( + opts: CliOptions, + instruction: string, + attempt: number, + log: JsonLogger, +): Promise<{ bytes: number; threadId: string | null; usage: any; toolCalls: any[] }> { + await log.info("attempt.start", { attempt, output: opts.outputPath, aspect: opts.aspect }); + + const run = await runCodexExec({ + instruction, + timeoutMs: opts.timeoutMs, + refImages: opts.refImages, + }); + + await log.info("codex.completed", { + duration_ms: run.durationMs, + thread_id: run.threadId, + tool_calls: run.toolCalls.length, + usage: run.usage, + raw_log: run.rawLogPath, + }); + + // verify: thread id must be present + if (!run.threadId) { + throw new GenError("agent_refused", "No thread id in event stream"); + } + + // verify: image_gen was actually invoked (check $CODEX_HOME/generated_images/{threadId}/) + const ver = await verifyImageGenWasInvoked(run.threadId); + if (!ver.ok) { + // secondary verify: did tool_calls include cp/mv from generated_images to our target + if (!findCpToTarget(run.toolCalls, opts.outputPath)) { + throw new GenError("no_image_gen_tool_use", `image_gen was not invoked: ${ver.reason}`); + } + } + + // verify output + const { bytes } = await verifyOutput(opts.outputPath); + + return { + bytes, + threadId: run.threadId, + usage: run.usage, + toolCalls: run.toolCalls.map((tc) => ({ tool: tc.tool, status: tc.status })), + }; +} + +async function generate(opts: CliOptions, log: JsonLogger): Promise { + const startEpoch = Date.now(); + const prompt = await loadPrompt(opts); + + // Cache lookup + if (opts.cacheDir) { + const key = cacheKey(prompt, opts.aspect, opts.refImages); + const cached = await lookupCache(opts.cacheDir, key); + if (cached) { + await mkdir(path.dirname(opts.outputPath), { recursive: true }); + await copyFile(cached, opts.outputPath); + const s = await stat(opts.outputPath); + await log.info("cache.hit", { key, source: cached }); + return { + status: "ok", + path: opts.outputPath, + bytes: s.size, + elapsed_seconds: 0, + thread_id: null, + attempts: 0, + cached: true, + usage: null, + tool_calls: [], + }; + } + await log.info("cache.miss", { key }); + } + + // lock to prevent concurrent codex exec + const lockDir = opts.cacheDir ?? path.join(homedir(), ".cache", "baoyu-codex-imagegen"); + const lock = new FileLock(path.join(lockDir, "codex-exec.lock")); + try { + await lock.acquire(60_000); + } catch (e) { + throw new GenError("lock_busy", String(e), false); + } + + await mkdir(path.dirname(opts.outputPath), { recursive: true }); + const instruction = buildInstruction(prompt, opts); + + let lastErr: GenError | null = null; + let lastAttempt = 0; + try { + for (let attempt = 1; attempt <= opts.retries + 1; attempt++) { + lastAttempt = attempt; + try { + const result = await attemptGenerate(opts, instruction, attempt, log); + + // write to cache + if (opts.cacheDir) { + const key = cacheKey(prompt, opts.aspect, opts.refImages); + await storeCache(opts.cacheDir, key, opts.outputPath); + await log.info("cache.stored", { key }); + } + + return { + status: "ok", + path: opts.outputPath, + bytes: result.bytes, + elapsed_seconds: Math.round((Date.now() - startEpoch) / 1000), + thread_id: result.threadId, + attempts: attempt, + cached: false, + usage: result.usage, + tool_calls: result.toolCalls, + }; + } catch (e) { + lastErr = e instanceof GenError ? e : new GenError("spawn_failed", String(e)); + await log.warn("attempt.failed", { + attempt, + kind: lastErr.kind, + retryable: lastErr.retryable, + error: lastErr.message, + }); + if (!lastErr.retryable || attempt > opts.retries) break; + const wait = opts.retryDelayMs * Math.pow(2, attempt - 1); + await log.info("retry.wait", { wait_ms: wait, next_attempt: attempt + 1 }); + await delay(wait); + } + } + } finally { + await lock.release(); + } + + const err = lastErr ?? new GenError("spawn_failed", "Unknown failure"); + err.attempts = lastAttempt; + throw err; +} + +async function main() { + let opts: CliOptions; + try { + opts = parseArgs(process.argv.slice(2)); + } catch (e) { + const err = e instanceof GenError ? e : new GenError("invalid_args", String(e), false); + process.stderr.write(`Error: ${err.message}\n`); + process.exit(2); + } + + const log = new JsonLogger(opts.logFile, opts.verbose); + await log.info("start", { output: opts.outputPath, aspect: opts.aspect, refs: opts.refImages.length }); + + try { + const result = await generate(opts, log); + await log.info("done", { bytes: result.bytes, attempts: result.attempts, cached: result.cached }); + process.stdout.write(JSON.stringify(result) + "\n"); + process.exit(0); + } catch (e) { + const err = e instanceof GenError ? e : new GenError("spawn_failed", String(e)); + await log.error("failed", { kind: err.kind, error: err.message, attempts: err.attempts ?? 0 }); + const out: GenerateResult = { + status: "error", + path: opts.outputPath, + bytes: 0, + elapsed_seconds: 0, + thread_id: null, + attempts: err.attempts ?? 0, + cached: false, + usage: null, + tool_calls: [], + error: err.message, + error_kind: err.kind, + }; + process.stdout.write(JSON.stringify(out) + "\n"); + process.exit(1); + } +} + +main(); diff --git a/skills/baoyu-image-gen/scripts/codex-imagegen/parser.ts b/skills/baoyu-image-gen/scripts/codex-imagegen/parser.ts new file mode 100644 index 0000000..0953d45 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/codex-imagegen/parser.ts @@ -0,0 +1,64 @@ +import type { CodexRunResult, ToolCall, TokenUsage } from "./types.ts"; + +export function parseEventStream(raw: string): Omit { + const lines = raw.split("\n").filter((l) => l.trim().length > 0); + let threadId: string | null = null; + let agentMessage: string | null = null; + let usage: TokenUsage | null = null; + const toolCallsById = new Map(); + + for (const line of lines) { + let event: any; + try { + event = JSON.parse(line); + } catch { + continue; + } + const type = event?.type; + if (type === "thread.started") { + threadId = event.thread_id ?? null; + } else if (type === "item.started" || type === "item.completed") { + const item = event.item; + if (!item?.id) continue; + const tc: ToolCall = { + id: item.id, + tool: deriveToolName(item), + status: item.status ?? (type === "item.completed" ? "completed" : "in_progress"), + command: item.command, + }; + toolCallsById.set(item.id, tc); + if (item.type === "agent_message" && type === "item.completed") { + agentMessage = String(item.text ?? ""); + } + } else if (type === "turn.completed") { + const u = event.usage; + if (u) { + usage = { + input: u.input_tokens ?? 0, + cached_input: u.cached_input_tokens ?? 0, + output: u.output_tokens ?? 0, + reasoning: u.reasoning_output_tokens ?? 0, + }; + } + } + } + + return { + threadId, + toolCalls: Array.from(toolCallsById.values()), + agentMessage, + usage, + }; +} + +function deriveToolName(item: any): string { + if (item.type === "command_execution") return "shell"; + if (item.type === "agent_message") return "agent_message"; + if (item.type === "image_gen" || item.type === "image_generation") return "image_gen"; + if (typeof item.tool === "string") return item.tool; + return item.type ?? "unknown"; +} + +export function hasImageGenInvocation(toolCalls: ToolCall[]): boolean { + return toolCalls.some((tc) => tc.tool === "image_gen"); +} diff --git a/skills/baoyu-image-gen/scripts/codex-imagegen/spawn.ts b/skills/baoyu-image-gen/scripts/codex-imagegen/spawn.ts new file mode 100644 index 0000000..f422c54 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/codex-imagegen/spawn.ts @@ -0,0 +1,81 @@ +import { spawn } from "node:child_process"; +import { writeFile, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { GenError, type CodexRunResult } from "./types.ts"; +import { parseEventStream } from "./parser.ts"; + +export interface SpawnInput { + instruction: string; + timeoutMs: number; + refImages?: string[]; +} + +export async function runCodexExec(input: SpawnInput): Promise { + const start = Date.now(); + const logDir = await mkdtemp(path.join(tmpdir(), "codex-imggen-")); + const rawLogPath = path.join(logDir, "stream.jsonl"); + + // --skip-git-repo-check: lets the wrapper run from non-git cwds + // (e.g. /tmp, or a skill installed under ~/.claude/plugins/...). + // Without it, codex refuses with "Not inside a trusted directory". + const args = [ + "exec", + "--json", + "--sandbox", + "danger-full-access", + "--skip-git-repo-check", + ]; + for (const img of input.refImages ?? []) { + args.push("--image", img); + } + args.push("-"); + + let timedOut = false; + const child = spawn("codex", args, { stdio: ["pipe", "pipe", "pipe"] }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + child.stdin.write(input.instruction); + child.stdin.end(); + + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 2000); + }, input.timeoutMs); + + const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + child.on("close", (code, signal) => resolve({ code, signal })); + }); + clearTimeout(timer); + + await writeFile(rawLogPath, stdout + (stderr ? `\n--- stderr ---\n${stderr}` : "")); + + if (timedOut) { + throw new GenError("timeout", `codex exec exceeded ${input.timeoutMs}ms (log: ${rawLogPath})`); + } + if (exit.code !== 0) { + if (stderr.includes("command not found") || stderr.includes("not found: codex")) { + throw new GenError("codex_not_installed", "codex CLI not installed", false); + } + throw new GenError( + "spawn_failed", + `codex exec exited ${exit.code} signal=${exit.signal} (log: ${rawLogPath})`, + ); + } + + const parsed = parseEventStream(stdout); + return { + ...parsed, + rawLogPath, + durationMs: Date.now() - start, + }; +} diff --git a/skills/baoyu-image-gen/scripts/codex-imagegen/types.ts b/skills/baoyu-image-gen/scripts/codex-imagegen/types.ts new file mode 100644 index 0000000..671b5d2 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/codex-imagegen/types.ts @@ -0,0 +1,79 @@ +export interface CliOptions { + prompt: string; + promptFile: string | null; + outputPath: string; + aspect: string; + refImages: string[]; + timeoutMs: number; + retries: number; + retryDelayMs: number; + cacheDir: string | null; + logFile: string | null; + verbose: boolean; +} + +export interface ToolCall { + id: string; + tool: string; + status: string; + command?: string; +} + +export interface TokenUsage { + input: number; + cached_input: number; + output: number; + reasoning: number; +} + +export interface CodexRunResult { + threadId: string | null; + toolCalls: ToolCall[]; + agentMessage: string | null; + usage: TokenUsage | null; + rawLogPath: string; + durationMs: number; +} + +export interface GenerateResult { + status: "ok" | "error"; + path: string; + bytes: number; + elapsed_seconds: number; + thread_id: string | null; + attempts: number; + cached: boolean; + usage: TokenUsage | null; + tool_calls: { tool: string; status: string }[]; + error?: string; + error_kind?: ErrorKind; +} + +export type ErrorKind = + | "codex_not_installed" + | "invalid_args" + | "prompt_file_missing" + | "spawn_failed" + | "timeout" + | "no_image_gen_tool_use" + | "output_missing" + | "invalid_png" + | "agent_refused" + | "lock_busy"; + +export const RETRYABLE: ReadonlySet = new Set([ + "spawn_failed", + "timeout", + "no_image_gen_tool_use", + "output_missing", + "invalid_png", + "agent_refused", +]); + +export class GenError extends Error { + attempts?: number; + constructor(public kind: ErrorKind, message: string, public retryable?: boolean) { + super(message); + this.retryable = retryable ?? RETRYABLE.has(kind); + } +} diff --git a/skills/baoyu-image-gen/scripts/codex-imagegen/validator.ts b/skills/baoyu-image-gen/scripts/codex-imagegen/validator.ts new file mode 100644 index 0000000..487ad81 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/codex-imagegen/validator.ts @@ -0,0 +1,55 @@ +import { stat, readdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { GenError } from "./types.ts"; +import type { ToolCall } from "./types.ts"; + +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +export function codexHome(): string { + return process.env.CODEX_HOME ?? path.join(homedir(), ".codex"); +} + +export async function verifyImageGenWasInvoked(threadId: string | null): Promise<{ ok: boolean; reason?: string }> { + if (!threadId) return { ok: false, reason: "no thread id" }; + const dir = path.join(codexHome(), "generated_images", threadId); + try { + const entries = await readdir(dir); + const pngs = entries.filter((e) => e.toLowerCase().endsWith(".png")); + if (pngs.length === 0) return { ok: false, reason: `no PNG in ${dir}` }; + return { ok: true }; + } catch (e: any) { + return { ok: false, reason: `cannot read ${dir}: ${e?.code ?? e?.message}` }; + } +} + +export function findCpToTarget(toolCalls: ToolCall[], target: string): boolean { + return toolCalls.some( + (tc) => + tc.tool === "shell" && + typeof tc.command === "string" && + (tc.command.includes(target) || tc.command.includes(path.basename(target))) && + /\b(cp|mv|cat)\b/.test(tc.command) && + tc.command.includes("generated_images"), + ); +} + +export async function verifyOutput(outputPath: string): Promise<{ bytes: number }> { + let s; + try { + s = await stat(outputPath); + } catch { + throw new GenError("output_missing", `Output file not created: ${outputPath}`); + } + if (s.size < 1000) { + throw new GenError("invalid_png", `Output file too small (${s.size} bytes)`); + } + const file = Bun.file(outputPath); + const head = new Uint8Array(await file.slice(0, 8).arrayBuffer()); + for (let i = 0; i < PNG_MAGIC.length; i++) { + if (head[i] !== PNG_MAGIC[i]) { + throw new GenError("invalid_png", `Output is not a valid PNG (magic mismatch)`); + } + } + return { bytes: s.size }; +} diff --git a/skills/baoyu-image-gen/scripts/main.ts b/skills/baoyu-image-gen/scripts/main.ts index e3aade1..036f6b2 100644 --- a/skills/baoyu-image-gen/scripts/main.ts +++ b/skills/baoyu-image-gen/scripts/main.ts @@ -64,6 +64,7 @@ const DEFAULT_PROVIDER_RATE_LIMITS: Record = { jimeng: { concurrency: 3, startIntervalMs: 1100 }, seedream: { concurrency: 3, startIntervalMs: 1100 }, azure: { concurrency: 3, startIntervalMs: 1100 }, + "codex-cli": { concurrency: 1, startIntervalMs: 2000 }, }; function printUsage(): void { @@ -78,7 +79,7 @@ Options: --image Output image path (required in single-image mode) --batchfile JSON batch file for multi-image generation --jobs Worker count for batch mode (default: auto, max from config, built-in default 10) - --provider google|openai|openrouter|dashscope|zai|minimax|replicate|jimeng|seedream|azure Force provider (auto-detect by default) + --provider google|openai|openrouter|dashscope|zai|minimax|replicate|jimeng|seedream|azure|codex-cli Force provider (auto-detect by default) -m, --model Model ID --ar Aspect ratio (e.g., 16:9, 1:1, 4:3) --size Size (e.g., 1024x1024) @@ -154,8 +155,13 @@ Environment variables: AZURE_OPENAI_IMAGE_MODEL Backward-compatible Azure deployment/model alias (defaults to gpt-image-2) SEEDREAM_BASE_URL Custom Seedream endpoint BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap - BAOYU_IMAGE_GEN__CONCURRENCY Override provider concurrency + BAOYU_IMAGE_GEN__CONCURRENCY Override provider concurrency (use underscores: BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY) BAOYU_IMAGE_GEN__START_INTERVAL_MS Override provider start gap in ms + BAOYU_CODEX_IMAGEGEN_BIN Path to codex-imagegen wrapper (default: bundled scripts/codex-imagegen/main.ts; accepts .ts or legacy .sh/binary) + BAOYU_CODEX_IMAGEGEN_CACHE_DIR Enable idempotency cache for codex-cli provider (default: disabled) + BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS Per-attempt codex exec timeout for codex-cli provider (default: 300000) + BAOYU_CODEX_IMAGEGEN_RETRIES Codex-side retry attempts on retryable errors (default: 2) + BAOYU_CODEX_IMAGEGEN_LOG_FILE Append JSONL diagnostic log for codex-cli provider Env file load order: CLI args > EXTEND.md > process.env > /.baoyu-skills/.env > ~/.baoyu-skills/.env`); } @@ -258,7 +264,8 @@ export function parseArgs(argv: string[]): CliArgs { v !== "replicate" && v !== "jimeng" && v !== "seedream" && - v !== "azure" + v !== "azure" && + v !== "codex-cli" ) { throw new Error(`Invalid provider: ${v}`); } @@ -430,6 +437,7 @@ export function parseSimpleYaml(yaml: string): Partial { jimeng: null, seedream: null, azure: null, + "codex-cli": null, }; currentKey = "default_model"; currentProvider = null; @@ -458,7 +466,8 @@ export function parseSimpleYaml(yaml: string): Partial { key === "replicate" || key === "jimeng" || key === "seedream" || - key === "azure" + key === "azure" || + key === "codex-cli" ) ) { config.batch ??= {}; @@ -477,7 +486,8 @@ export function parseSimpleYaml(yaml: string): Partial { key === "replicate" || key === "jimeng" || key === "seedream" || - key === "azure" + key === "azure" || + key === "codex-cli" ) ) { const cleaned = value.replace(/['"]/g, ""); @@ -630,10 +640,11 @@ export function getConfiguredProviderRateLimits( jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng }, seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream }, azure: { ...DEFAULT_PROVIDER_RATE_LIMITS.azure }, + "codex-cli": { ...DEFAULT_PROVIDER_RATE_LIMITS["codex-cli"] }, }; - for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure"] as Provider[]) { - const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`; + for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure", "codex-cli"] as Provider[]) { + const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase().replace(/-/g, "_")}`; const extendLimit = extendConfig.batch?.provider_limits?.[provider]; configured[provider] = { concurrency: @@ -699,10 +710,11 @@ export function detectProvider(args: CliArgs): Provider { args.provider !== "replicate" && args.provider !== "seedream" && args.provider !== "minimax" && - args.provider !== "dashscope" + args.provider !== "dashscope" && + args.provider !== "codex-cli" ) { throw new Error( - "Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider azure (Azure OpenAI), --provider openrouter (OpenRouter multimodal), --provider replicate, --provider dashscope with a wan2.7 image model, --provider seedream for supported Seedream models, or --provider minimax for MiniMax subject-reference workflows." + "Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider azure (Azure OpenAI), --provider openrouter (OpenRouter multimodal), --provider replicate, --provider dashscope with a wan2.7 image model, --provider seedream for supported Seedream models, --provider minimax for MiniMax subject-reference workflows, or --provider codex-cli (Codex image_gen with references)." ); } @@ -839,6 +851,7 @@ async function loadProviderModule(provider: Provider): Promise { if (provider === "jimeng") return (await import("./providers/jimeng")) as ProviderModule; if (provider === "seedream") return (await import("./providers/seedream")) as ProviderModule; if (provider === "azure") return (await import("./providers/azure")) as ProviderModule; + if (provider === "codex-cli") return (await import("./providers/codex-cli")) as ProviderModule; return (await import("./providers/openai")) as ProviderModule; } @@ -870,6 +883,7 @@ function getModelForProvider( if (provider === "jimeng" && extendConfig.default_model.jimeng) return extendConfig.default_model.jimeng; if (provider === "seedream" && extendConfig.default_model.seedream) return extendConfig.default_model.seedream; if (provider === "azure" && extendConfig.default_model.azure) return extendConfig.default_model.azure; + if (provider === "codex-cli" && extendConfig.default_model["codex-cli"]) return extendConfig.default_model["codex-cli"]; } return providerModule.getDefaultModel(); } @@ -1104,7 +1118,7 @@ async function runBatchTasks( const acquireProvider = createProviderGate(providerRateLimits); const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers); console.error(`Batch mode: ${tasks.length} tasks, ${workerCount} workers, parallel mode enabled.`); - for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure"] as Provider[]) { + for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure", "codex-cli"] as Provider[]) { const limit = providerRateLimits[provider]; console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`); } diff --git a/skills/baoyu-image-gen/scripts/providers/codex-cli.test.ts b/skills/baoyu-image-gen/scripts/providers/codex-cli.test.ts new file mode 100644 index 0000000..27b81e5 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/codex-cli.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { CliArgs } from "../types.ts"; +import { + getDefaultModel, + getDefaultOutputExtension, + validateArgs, +} from "./codex-cli.ts"; + +function makeArgs(overrides: Partial = {}): CliArgs { + return { + prompt: null, + promptFiles: [], + imagePath: null, + provider: "codex-cli", + model: null, + aspectRatio: null, + aspectRatioSource: null, + size: null, + quality: "2k", + imageSize: null, + imageSizeSource: null, + imageApiDialect: null, + referenceImages: [], + n: 1, + batchFile: null, + jobs: null, + json: false, + help: false, + ...overrides, + }; +} + +test("codex-cli defaults to codex-image-gen model and PNG output", () => { + assert.equal(getDefaultModel(), "codex-image-gen"); + assert.equal(getDefaultOutputExtension(), ".png"); +}); + +test("codex-cli validateArgs rejects n>1 with a non-retryable message", () => { + assert.throws( + () => validateArgs("codex-image-gen", makeArgs({ n: 2 })), + /supports only n=1/, + ); +}); + +test("codex-cli validateArgs rejects ratio-metadata dialect", () => { + assert.throws( + () => validateArgs("codex-image-gen", makeArgs({ imageApiDialect: "ratio-metadata" })), + /Invalid imageApiDialect/, + ); +}); + +test("codex-cli validateArgs accepts default n=1 with no dialect", () => { + assert.doesNotThrow(() => validateArgs("codex-image-gen", makeArgs())); +}); + +test("codex-cli validateArgs accepts reference images (Codex image_gen supports refs)", () => { + assert.doesNotThrow(() => + validateArgs("codex-image-gen", makeArgs({ referenceImages: ["/tmp/a.png", "/tmp/b.png"] })), + ); +}); diff --git a/skills/baoyu-image-gen/scripts/providers/codex-cli.ts b/skills/baoyu-image-gen/scripts/providers/codex-cli.ts new file mode 100644 index 0000000..ea4a4c3 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/codex-cli.ts @@ -0,0 +1,197 @@ +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; +import { mkdir, readFile, rm, writeFile, access } from "node:fs/promises"; +import { randomBytes } from "node:crypto"; +import type { CliArgs } from "../types"; + +const PROVIDER_FILE = fileURLToPath(import.meta.url); +const SCRIPTS_DIR = path.resolve(path.dirname(PROVIDER_FILE), ".."); +const BUNDLED_WRAPPER = path.join(SCRIPTS_DIR, "codex-imagegen", "main.ts"); + +type WrapperOkResult = { + status: "ok"; + path: string; + bytes: number; + elapsed_seconds: number; + thread_id: string | null; + attempts: number; + cached: boolean; +}; + +type WrapperErrorResult = { + status: "error"; + path: string; + bytes: number; + error: string; + error_kind: string; +}; + +type WrapperResult = WrapperOkResult | WrapperErrorResult; + +export function getDefaultModel(): string { + return "codex-image-gen"; +} + +export function getDefaultOutputExtension(): string { + return ".png"; +} + +export function validateArgs(_model: string, args: CliArgs): void { + if (args.n > 1) { + throw new Error( + "codex-cli provider supports only n=1 (Codex image_gen returns a single image per call).", + ); + } + if (args.imageApiDialect && args.imageApiDialect !== "openai-native") { + throw new Error( + `Invalid imageApiDialect for codex-cli: ${args.imageApiDialect}. codex-cli does not use OpenAI Images API dialects.`, + ); + } +} + +async function exists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +async function resolveWrapperPath(): Promise { + const override = process.env.BAOYU_CODEX_IMAGEGEN_BIN; + if (override) { + if (!(await exists(override))) { + throw new Error( + `Invalid BAOYU_CODEX_IMAGEGEN_BIN: ${override} does not exist.`, + ); + } + return override; + } + if (await exists(BUNDLED_WRAPPER)) return BUNDLED_WRAPPER; + throw new Error( + `codex-cli wrapper not found at ${BUNDLED_WRAPPER}. ` + + `Reinstall baoyu-image-gen, or set BAOYU_CODEX_IMAGEGEN_BIN to a codex-imagegen main.ts (or .sh) path.`, + ); +} + +type SpawnResult = { + stdout: string; + stderr: string; + code: number; +}; + +async function spawnWrapper(wrapperPath: string, cliArgs: string[]): Promise { + return new Promise((resolve, reject) => { + const isTs = wrapperPath.endsWith(".ts"); + const command = isTs ? "bun" : wrapperPath; + const args = isTs ? [wrapperPath, ...cliArgs] : cliArgs; + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8"); + stderr += text; + process.stderr.write(text); + }); + child.on("error", (err) => reject(err)); + child.on("close", (code) => resolve({ stdout, stderr, code: code ?? 1 })); + }); +} + +function parseWrapperJson(stdout: string): WrapperResult { + const trimmed = stdout.trim(); + if (!trimmed) { + throw new Error("Invalid codex-cli response: empty stdout from wrapper."); + } + const lastLine = trimmed.split(/\r?\n/).pop() ?? trimmed; + try { + return JSON.parse(lastLine) as WrapperResult; + } catch (parseErr) { + throw new Error( + `Invalid codex-cli response: could not parse JSON from wrapper stdout (${(parseErr as Error).message}).`, + ); + } +} + +function parsePositiveInt(value: string | undefined): number | null { + if (!value) return null; + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function getEnvOverride(name: string): string | null { + const value = process.env[name]; + return value && value.length > 0 ? value : null; +} + +export async function generateImage( + prompt: string, + _model: string, + args: CliArgs, +): Promise { + const wrapperPath = await resolveWrapperPath(); + + const sessionDir = path.join(tmpdir(), "baoyu-image-gen-codex-cli"); + await mkdir(sessionDir, { recursive: true }); + const token = randomBytes(8).toString("hex"); + const tmpOutput = path.join(sessionDir, `out-${token}.png`); + const tmpPrompt = path.join(sessionDir, `prompt-${token}.md`); + await writeFile(tmpPrompt, prompt, "utf8"); + + const aspect = args.aspectRatio ?? "1:1"; + const cliArgs: string[] = [ + "--image", + tmpOutput, + "--prompt-file", + tmpPrompt, + "--aspect", + aspect, + ]; + + for (const ref of args.referenceImages) { + cliArgs.push("--ref", path.resolve(ref)); + } + + const cacheDir = getEnvOverride("BAOYU_CODEX_IMAGEGEN_CACHE_DIR"); + if (cacheDir) cliArgs.push("--cache-dir", cacheDir); + + const timeoutMs = parsePositiveInt(process.env.BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS); + if (timeoutMs) cliArgs.push("--timeout", String(timeoutMs)); + + const retries = parsePositiveInt(process.env.BAOYU_CODEX_IMAGEGEN_RETRIES); + if (retries !== null) cliArgs.push("--retries", String(retries)); + + const logFile = getEnvOverride("BAOYU_CODEX_IMAGEGEN_LOG_FILE"); + if (logFile) cliArgs.push("--log-file", logFile); + + try { + const spawnResult = await spawnWrapper(wrapperPath, cliArgs); + const parsed = parseWrapperJson(spawnResult.stdout); + + if (parsed.status === "error") { + throw new Error( + `Invalid codex-cli result (${parsed.error_kind}): ${parsed.error}`, + ); + } + + if (spawnResult.code !== 0) { + throw new Error( + `Invalid codex-cli result: wrapper exited with code ${spawnResult.code} despite reporting status=ok.`, + ); + } + + const bytes = await readFile(parsed.path ?? tmpOutput); + return new Uint8Array(bytes); + } finally { + await Promise.allSettled([ + rm(tmpOutput, { force: true }), + rm(tmpPrompt, { force: true }), + ]); + } +} diff --git a/skills/baoyu-image-gen/scripts/types.ts b/skills/baoyu-image-gen/scripts/types.ts index ead86e6..b3d1723 100644 --- a/skills/baoyu-image-gen/scripts/types.ts +++ b/skills/baoyu-image-gen/scripts/types.ts @@ -8,7 +8,8 @@ export type Provider = | "replicate" | "jimeng" | "seedream" - | "azure"; + | "azure" + | "codex-cli"; export type Quality = "normal" | "2k"; export type OpenAIImageApiDialect = "openai-native" | "ratio-metadata"; @@ -74,6 +75,7 @@ export type ExtendConfig = { jimeng: string | null; seedream: string | null; azure: string | null; + "codex-cli": string | null; }; batch?: { max_workers?: number | null;