mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-15 15:09:48 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec704c8afd | |||
| a85c81e8db | |||
| 77dd193b58 | |||
| 84d817ed52 | |||
| 639e0b4193 | |||
| 84cefc2784 | |||
| 876f01ac19 | |||
| e1c0ff7c02 | |||
| 860dd36bb6 | |||
| aab0e28823 | |||
| 95cdad2a2a | |||
| 8838161729 | |||
| 49e9c46ca4 | |||
| 23fac03691 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "2.1.0"
|
||||
"version": "2.4.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -3,13 +3,11 @@ name: codex-imagegen tests
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'scripts/codex-imagegen/**'
|
||||
- 'scripts/codex-imagegen.sh'
|
||||
- 'packages/baoyu-codex-imagegen/**'
|
||||
- '.github/workflows/codex-imagegen-tests.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'scripts/codex-imagegen/**'
|
||||
- 'scripts/codex-imagegen.sh'
|
||||
- 'packages/baoyu-codex-imagegen/**'
|
||||
- '.github/workflows/codex-imagegen-tests.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -30,11 +28,11 @@ jobs:
|
||||
run: bun --version
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: scripts/codex-imagegen
|
||||
working-directory: packages/baoyu-codex-imagegen
|
||||
run: bun test
|
||||
|
||||
- name: Bundle smoke test (catches import/syntax errors)
|
||||
run: bun build --target=node scripts/codex-imagegen/main.ts --outfile /tmp/main-build.js
|
||||
run: bun build --target=node packages/baoyu-codex-imagegen/src/main.ts --outfile /tmp/main-build.js
|
||||
|
||||
- name: Help output smoke test
|
||||
run: bun scripts/codex-imagegen/main.ts --help
|
||||
run: bun packages/baoyu-codex-imagegen/src/main.ts --help
|
||||
|
||||
@@ -2,6 +2,38 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 2.4.0 - 2026-05-29
|
||||
|
||||
### Features
|
||||
- `baoyu-wechat-summary`: add an `@bot` Q&A section to both the normal and roast digests. Messages mentioning `@bot` / `@精华bot` (customizable via the new `bot_aliases` preference) are detected during the skeleton pass and answered in a dedicated section — earnest and helpful in the normal version, snarky-but-substantive in the roast version. Answers draw only on the chat context and the model's own knowledge (no web access) and honestly flag anything that needs real-time data
|
||||
|
||||
## 2.3.0 - 2026-05-28
|
||||
|
||||
### Features
|
||||
- `baoyu-md`, `baoyu-markdown-to-html`, `baoyu-post-to-wechat`, `baoyu-post-to-x`: support Obsidian image wikilinks (`![[...]]`) alongside standard Markdown images, preserve mixed image order, and resolve Obsidian's default `Attachments/` paths (by @zcqqq)
|
||||
|
||||
### Fixes
|
||||
- `baoyu-md`, `baoyu-post-to-x`: decode URL-encoded local image paths before resolution so filenames with spaces or CJK characters are handled correctly (by @zcqqq)
|
||||
- `baoyu-md`, `baoyu-post-to-x`: harden decoded image path handling so malformed percent escapes fall back safely instead of breaking placeholder extraction (by @zcqqq)
|
||||
|
||||
## 2.2.1 - 2026-05-26
|
||||
|
||||
### Documentation
|
||||
- `baoyu-image-gen`: surface `scripts/build-batch.ts` in SKILL.md's Usage section so the `outline.md` + `prompts/` → `batch.json` workflow (e.g., `baoyu-article-illustrator` output) is discoverable next to `--batchfile`. Clarify that all `scripts/...` paths in SKILL.md are relative to `{baseDir}` and point the Generation Mode table at `{baseDir}/scripts/build-batch.ts`. Update `build-batch.ts --help` to print `bun` / `npx -y bun` invocations with the `{baseDir}/scripts/...` layout used by the rest of the skill
|
||||
|
||||
## 2.2.0 - 2026-05-25
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: new `codex-cli` provider that wraps the `codex-imagegen` backend so the Codex `image_gen` tool is reachable through the standard `--provider` / batch / EXTEND.md flow. Uses the user's Codex subscription — no `OPENAI_API_KEY` required. Adds env vars `BAOYU_CODEX_IMAGEGEN_{BIN,CACHE_DIR,TIMEOUT_MS,RETRIES,LOG_FILE}` and resolves hyphenated provider names for `BAOYU_IMAGE_GEN_<PROVIDER>_*` overrides (e.g., `BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY`). `codex-cli` is never auto-selected — pin via `--provider codex-cli` or `default_provider: codex-cli` in EXTEND.md
|
||||
|
||||
### Refactor
|
||||
- `codex-imagegen`: extracted from `scripts/codex-imagegen/` + `scripts/codex-imagegen.sh` to its own workspace package at `packages/baoyu-codex-imagegen/`. The bash shim is gone; `src/main.ts` now carries a `#!/usr/bin/env bun` shebang and is the sole entrypoint (`bun packages/baoyu-codex-imagegen/src/main.ts …` or, without bun on `PATH`, `npx -y bun …`). `scripts/sync-codex-imagegen.sh` keeps `skills/baoyu-image-gen/scripts/codex-imagegen/` in sync for skill self-containment
|
||||
|
||||
### Documentation
|
||||
- `baoyu-cover-image`: replace the long inline `scripts/codex-imagegen.sh` invocation block in `SKILL.md` with a pointer to `references/codex-imagegen.md`, documenting the preferred `baoyu-image-gen --provider codex-cli` path and keeping the direct-wrapper fallback
|
||||
- `baoyu-article-illustrator`, `baoyu-comic`, `baoyu-infographic`, `baoyu-slide-deck`, `baoyu-xhs-images`: add a `Codex via codex exec` branch to each skill's backend-selection ladder and ship a per-skill `references/codex-imagegen.md` with the invocation contract (preferred path, fallback, stdout schema, batch semantics)
|
||||
- `docs/codex-imagegen-backend.md` and `CLAUDE.md`: update path layout and invocation examples to reflect the new workspace package location
|
||||
|
||||
## 2.1.0 - 2026-05-24
|
||||
|
||||
### Features
|
||||
|
||||
@@ -2,6 +2,38 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 2.4.0 - 2026-05-29
|
||||
|
||||
### 新功能
|
||||
- `baoyu-wechat-summary`:为正常版和毒舌版简报新增「@bot 答疑」小节。识别群里提及 `@bot` / `@精华bot`(可通过新增的 `bot_aliases` 偏好自定义)的提问/请求,在专门小节中逐条回应——正常版真诚有用,毒舌版带刺但仍给干货。答复只依据群聊上下文和模型自有知识(不联网),需要实时数据的会如实注明
|
||||
|
||||
## 2.3.0 - 2026-05-28
|
||||
|
||||
### 新功能
|
||||
- `baoyu-md`、`baoyu-markdown-to-html`、`baoyu-post-to-wechat`、`baoyu-post-to-x`:支持 Obsidian 图片 wikilink(`![[...]]`),可与标准 Markdown 图片混用并保持图片顺序,同时解析 Obsidian 默认的 `Attachments/` 路径 (by @zcqqq)
|
||||
|
||||
### 修复
|
||||
- `baoyu-md`、`baoyu-post-to-x`:解析本地图片路径前先解码 URL 编码,正确处理包含空格或中文等字符的文件名 (by @zcqqq)
|
||||
- `baoyu-md`、`baoyu-post-to-x`:加固图片路径解码逻辑,遇到异常百分号转义时安全回退,避免中断占位符提取 (by @zcqqq)
|
||||
|
||||
## 2.2.1 - 2026-05-26
|
||||
|
||||
### 文档
|
||||
- `baoyu-image-gen`:在 SKILL.md 的 Usage 段落中显式展示 `scripts/build-batch.ts`,使 `outline.md` + `prompts/` → `batch.json` 流程(例如 `baoyu-article-illustrator` 的产物)能与 `--batchfile` 并列被发现。明确 SKILL.md 中所有 `scripts/...` 路径均相对 `{baseDir}`,并把 Generation Mode 表中的指引改为 `{baseDir}/scripts/build-batch.ts`。`build-batch.ts --help` 同步打印 `bun` / `npx -y bun` 调用,与 skill 其它脚本统一使用 `{baseDir}/scripts/...` 写法
|
||||
|
||||
## 2.2.0 - 2026-05-25
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增 `codex-cli` provider,封装 `codex-imagegen` 后端,让 Codex 内置的 `image_gen` 工具也能走标准的 `--provider` / 批量 / EXTEND.md 流程。使用用户自己的 Codex 订阅,无需 `OPENAI_API_KEY`。新增环境变量 `BAOYU_CODEX_IMAGEGEN_{BIN,CACHE_DIR,TIMEOUT_MS,RETRIES,LOG_FILE}`;`BAOYU_IMAGE_GEN_<PROVIDER>_*` 类覆盖项支持带连字符的 provider 名称(如 `BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY`)。`codex-cli` **不会**自动选用,需通过 `--provider codex-cli` 或 EXTEND.md 的 `default_provider: codex-cli` 显式指定
|
||||
|
||||
### 重构
|
||||
- `codex-imagegen`:从 `scripts/codex-imagegen/` + `scripts/codex-imagegen.sh` 拆分为独立的 workspace 包 `packages/baoyu-codex-imagegen/`。bash 包装器已移除,`src/main.ts` 自带 `#!/usr/bin/env bun` shebang,作为唯一入口(`bun packages/baoyu-codex-imagegen/src/main.ts …`,若 `PATH` 中无 bun 则 `npx -y bun …`)。新增 `scripts/sync-codex-imagegen.sh` 同步 `skills/baoyu-image-gen/scripts/codex-imagegen/`,保持 skill 自包含
|
||||
|
||||
### 文档
|
||||
- `baoyu-cover-image`:`SKILL.md` 中较长的 `scripts/codex-imagegen.sh` 内联调用块替换为指向 `references/codex-imagegen.md` 的链接,文档明确优先走 `baoyu-image-gen --provider codex-cli`、并保留直接调用 wrapper 的回退路径
|
||||
- `baoyu-article-illustrator`、`baoyu-comic`、`baoyu-infographic`、`baoyu-slide-deck`、`baoyu-xhs-images`:在各自 backend 选择阶梯中新增 `Codex via codex exec` 分支,并附带 per-skill 的 `references/codex-imagegen.md`(优先路径、回退、stdout schema、批量语义)
|
||||
- `docs/codex-imagegen-backend.md` 与 `CLAUDE.md`:同步新的 workspace 路径与调用示例
|
||||
|
||||
## 2.1.0 - 2026-05-24
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -68,19 +68,21 @@ Skills that render images MUST declare the backend-selection convention **inline
|
||||
|
||||
### `codex-imagegen` Backend
|
||||
|
||||
A backend for non-Codex runtimes (e.g., Claude Code) that generates images by spawning `codex exec --json --sandbox danger-full-access` and delegating to Codex CLI's built-in `image_gen` tool. Uses the user's Codex subscription — no `OPENAI_API_KEY` required.
|
||||
A backend for non-Codex runtimes (e.g., Claude Code) that generates images by spawning `codex exec --json --sandbox danger-full-access` and delegating to Codex CLI's built-in `image_gen` tool. Uses the user's Codex subscription — no `OPENAI_API_KEY` required. Lives under [packages/baoyu-codex-imagegen](packages/baoyu-codex-imagegen) so it follows the same workspace layout as other shared packages.
|
||||
|
||||
Invoke via:
|
||||
Invoke via (TS entrypoint with `#!/usr/bin/env bun` shebang):
|
||||
|
||||
```bash
|
||||
./scripts/codex-imagegen.sh \
|
||||
bun packages/baoyu-codex-imagegen/src/main.ts \
|
||||
--image <output.png> \
|
||||
--prompt-file prompts/01-cover.md \
|
||||
--aspect 16:9 \
|
||||
--cache-dir ~/.cache/baoyu-codex-imagegen
|
||||
```
|
||||
|
||||
Stdout emits a single JSON line: `{"status":"ok","path":...,"bytes":N,...}`. On failure, `{"status":"error","error_kind":...}`. Skills route here by setting `preferred_image_backend: codex-imagegen` in EXTEND.md. Full reference: [docs/codex-imagegen-backend.md](docs/codex-imagegen-backend.md).
|
||||
Without bun installed: `npx -y bun packages/baoyu-codex-imagegen/src/main.ts …`.
|
||||
|
||||
Stdout emits a single JSON line: `{"status":"ok","path":...,"bytes":N,...}`. On failure, `{"status":"error","error_kind":...}`. Skills route here by setting `preferred_image_backend: codex-imagegen` in EXTEND.md, or by running `baoyu-image-gen --provider codex-cli` (which spawns the same wrapper internally). Full reference: [docs/codex-imagegen-backend.md](docs/codex-imagegen-backend.md).
|
||||
|
||||
## Release Process
|
||||
|
||||
|
||||
@@ -1128,7 +1128,7 @@ Custom style descriptions are also accepted, e.g., `--style "poetic and lyrical"
|
||||
|
||||
#### baoyu-wechat-summary
|
||||
|
||||
Summarize WeChat group chat highlights into a structured digest. Extracts topics, quotes, and stats from group messages using [wx-cli](https://github.com/jackwener/wx-cli). Maintains per-group history and per-user profiles across runs. Supports normal and roast (毒舌) versions.
|
||||
Summarize WeChat group chat highlights into a structured digest. Extracts topics, quotes, and stats from group messages using [wx-cli](https://github.com/jackwener/wx-cli). Maintains per-group history and per-user profiles across runs. Supports normal and roast (毒舌) versions, and answers `@bot` questions raised in the chat.
|
||||
|
||||
```bash
|
||||
# Summarize a group's recent messages
|
||||
|
||||
+1
-1
@@ -1119,7 +1119,7 @@ AI 驱动的生成后端。
|
||||
|
||||
#### baoyu-wechat-summary
|
||||
|
||||
微信群聊精华提取。使用 [wx-cli](https://github.com/jackwener/wx-cli) 从群消息中提取话题、引言和统计数据,生成结构化简报。支持跨次运行的群聊历史和群友画像维护,可生成正常版和毒舌版。
|
||||
微信群聊精华提取。使用 [wx-cli](https://github.com/jackwener/wx-cli) 从群消息中提取话题、引言和统计数据,生成结构化简报。支持跨次运行的群聊历史和群友画像维护,可生成正常版和毒舌版,并在简报中回应群里向 `@bot` 提出的问题。
|
||||
|
||||
```bash
|
||||
# 总结群最近消息
|
||||
|
||||
@@ -35,13 +35,13 @@ codex login # signs in with your OpenAI account (subscription)
|
||||
codex --version # confirm >= 0.130
|
||||
```
|
||||
|
||||
`bun` is preferred for running the wrapper. On macOS:
|
||||
`bun` is required for running the wrapper. On macOS:
|
||||
|
||||
```bash
|
||||
brew install oven-sh/bun/bun
|
||||
```
|
||||
|
||||
If `bun` is missing, the shell entrypoint falls back to `npx -y bun`.
|
||||
If `bun` is not on `PATH`, fall back to `npx -y bun packages/baoyu-codex-imagegen/src/main.ts …`.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -49,20 +49,32 @@ If `bun` is missing, the shell entrypoint falls back to `npx -y bun`.
|
||||
|
||||
```bash
|
||||
# Inline prompt
|
||||
./scripts/codex-imagegen.sh \
|
||||
bun packages/baoyu-codex-imagegen/src/main.ts \
|
||||
--image /tmp/cat.png \
|
||||
--prompt "A friendly orange cat, watercolor"
|
||||
|
||||
# Prompt from file
|
||||
./scripts/codex-imagegen.sh \
|
||||
bun packages/baoyu-codex-imagegen/src/main.ts \
|
||||
--image cover.png \
|
||||
--prompt-file prompts/01-cover.md \
|
||||
--aspect 16:9
|
||||
|
||||
# Verbose mode for debugging
|
||||
./scripts/codex-imagegen.sh -v --image dog.png --prompt "A corgi" --aspect 1:1
|
||||
bun packages/baoyu-codex-imagegen/src/main.ts -v --image dog.png --prompt "A corgi" --aspect 1:1
|
||||
```
|
||||
|
||||
### Through `baoyu-image-gen`
|
||||
|
||||
```bash
|
||||
${BUN_X} skills/baoyu-image-gen/scripts/main.ts \
|
||||
--provider codex-cli \
|
||||
--prompt "A friendly orange cat, watercolor" \
|
||||
--image /tmp/cat.png \
|
||||
--ar 1:1
|
||||
```
|
||||
|
||||
The `codex-cli` provider spawns the bundled `codex-imagegen` TS entrypoint internally and surfaces its retry/cache machinery through baoyu-image-gen's standard CLI + batch flow.
|
||||
|
||||
On success, stdout emits a single JSON line:
|
||||
|
||||
```json
|
||||
@@ -80,7 +92,7 @@ Image-generating skills (e.g., `baoyu-cover-image`, `baoyu-article-illustrator`)
|
||||
preferred_image_backend: codex-imagegen
|
||||
```
|
||||
|
||||
When the LLM runs the skill, it reads the preference and — guided by the `### codex-imagegen Backend` section in `CLAUDE.md` — invokes `scripts/codex-imagegen.sh`.
|
||||
When the LLM runs the skill, it reads the preference and — guided by the `### codex-imagegen Backend` section in `CLAUDE.md` — invokes `bun packages/baoyu-codex-imagegen/src/main.ts`.
|
||||
|
||||
> **Note**: The integration is mediated by the LLM reading `CLAUDE.md`. It is not a hard binding. If a skill does not route to the backend automatically, mentioning it explicitly in the prompt works.
|
||||
|
||||
@@ -191,24 +203,26 @@ On failure, exit code is `1` and the JSON contains `error` and `error_kind`:
|
||||
## Architecture
|
||||
|
||||
```
|
||||
scripts/codex-imagegen.sh # thin bash entrypoint
|
||||
scripts/codex-imagegen/
|
||||
├── main.ts # parseArgs → cache → lock → retry loop → emit JSON
|
||||
├── types.ts # CliOptions, GenerateResult, GenError, ErrorKind
|
||||
├── spawn.ts # spawn codex exec --json --sandbox danger-full-access
|
||||
├── parser.ts # parse JSONL event stream → toolCalls, usage, thread_id
|
||||
├── validator.ts # verify image_gen invocation + PNG magic + file size
|
||||
├── cache.ts # cacheKey(sha256), FileLock, lookup/store
|
||||
├── logger.ts # JsonLogger (verbose stderr + JSONL file)
|
||||
├── parser.test.ts
|
||||
├── cache.test.ts
|
||||
└── validator.test.ts
|
||||
packages/baoyu-codex-imagegen/
|
||||
├── src/
|
||||
│ ├── main.ts # parseArgs → cache → lock → retry loop → emit JSON (`#!/usr/bin/env bun`)
|
||||
│ ├── types.ts # CliOptions, GenerateResult, GenError, ErrorKind
|
||||
│ ├── spawn.ts # spawn codex exec --json --sandbox danger-full-access
|
||||
│ ├── parser.ts # parse JSONL event stream → toolCalls, usage, thread_id
|
||||
│ ├── validator.ts # verify image_gen invocation + PNG magic + file size
|
||||
│ ├── cache.ts # cacheKey(sha256), FileLock, lookup/store
|
||||
│ ├── logger.ts # JsonLogger (verbose stderr + JSONL file)
|
||||
│ ├── parser.test.ts
|
||||
│ ├── cache.test.ts
|
||||
│ └── validator.test.ts
|
||||
├── package.json # workspace package: `bin` → `src/main.ts`, no build step
|
||||
└── README.md
|
||||
```
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
cd scripts/codex-imagegen && bun test
|
||||
cd packages/baoyu-codex-imagegen && bun test
|
||||
```
|
||||
|
||||
## Internal Flow
|
||||
@@ -216,7 +230,7 @@ cd scripts/codex-imagegen && bun test
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CC[Claude Code / any caller]
|
||||
WRAPPER[scripts/codex-imagegen.sh]
|
||||
WRAPPER[bun packages/baoyu-codex-imagegen/<br/>src/main.ts]
|
||||
CODEX["codex exec --json<br/>--sandbox danger-full-access"]
|
||||
AGENT[Codex agent]
|
||||
TOOL[image_gen built-in tool]
|
||||
@@ -239,18 +253,20 @@ flowchart LR
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **Bash entrypoint + TypeScript implementation** — the shell wrapper picks the runtime (`bun` preferred, falling back to `npx -y bun`); TypeScript handles the orchestration, parsing, retry, cache, and logging. This mirrors the project's existing `scripts/*.mjs` and `skills/<skill>/scripts/main.ts` pattern.
|
||||
1. **Pure TypeScript entrypoint** — `src/main.ts` carries a `#!/usr/bin/env bun` shebang and is the sole entry. There is no shell shim: callers either invoke `bun src/main.ts …` directly, run the file as an executable (when `bun` is on `PATH`), or fall back to `npx -y bun src/main.ts …`. This matches the project's `skills/<skill>/scripts/main.ts` convention.
|
||||
2. **`--sandbox danger-full-access`** — necessary so the spawned agent can `cp`/`mv` the rendered PNG out of `$CODEX_HOME/generated_images/` to the user-specified path. Standard sandboxes block this.
|
||||
3. **Parse the JSONL event stream** — the final `agent_message` and intermediate `command_execution` events let the wrapper verify what actually happened (was `image_gen` called? did `cp` reach the right destination?), which is far more reliable than scraping freeform stdout.
|
||||
4. **Infrastructure, not a skill** — this backend is a CLI utility that skills route to via `preferred_image_backend`. It belongs in `scripts/`, not `skills/`, because it has no `SKILL.md` and is never loaded directly by an agent.
|
||||
4. **Shared package, not a skill** — this backend is a CLI utility that skills route to via `preferred_image_backend` and that `baoyu-image-gen --provider codex-cli` spawns internally. It lives under `packages/` alongside the other shared workspaces (`baoyu-md`, `baoyu-chrome-cdp`, `baoyu-fetch`) because it has no `SKILL.md` and is never loaded directly by an agent.
|
||||
5. **File lock instead of internal queue** — keeps the implementation small and works across multiple shell sessions or processes invoking the same wrapper concurrently.
|
||||
|
||||
## Related Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `scripts/codex-imagegen.sh` | CLI entrypoint |
|
||||
| `scripts/codex-imagegen/` | TypeScript implementation |
|
||||
| `packages/baoyu-codex-imagegen/src/main.ts` | TypeScript CLI entrypoint (`#!/usr/bin/env bun`) |
|
||||
| `packages/baoyu-codex-imagegen/src/` | TypeScript implementation |
|
||||
| `packages/baoyu-codex-imagegen/package.json` | Workspace manifest |
|
||||
| `skills/baoyu-image-gen/scripts/providers/codex-cli.ts` | Provider adapter that lets `baoyu-image-gen --provider codex-cli` spawn this wrapper |
|
||||
| `docs/codex-imagegen-backend.md` | This document |
|
||||
| `CLAUDE.md` | Tells LLMs how to invoke this backend |
|
||||
| `.github/workflows/codex-imagegen-tests.yml` | CI unit tests |
|
||||
|
||||
Generated
+13
@@ -1787,6 +1787,10 @@
|
||||
"resolved": "packages/baoyu-chrome-cdp",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/baoyu-codex-imagegen": {
|
||||
"resolved": "packages/baoyu-codex-imagegen",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/baoyu-fetch": {
|
||||
"resolved": "packages/baoyu-fetch",
|
||||
"link": true
|
||||
@@ -5018,6 +5022,15 @@
|
||||
"bun": ">=1.2.0"
|
||||
}
|
||||
},
|
||||
"packages/baoyu-codex-imagegen": {
|
||||
"version": "0.1.0",
|
||||
"bin": {
|
||||
"codex-imagegen": "src/main.ts"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.2.0"
|
||||
}
|
||||
},
|
||||
"packages/baoyu-fetch": {
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# baoyu-codex-imagegen
|
||||
|
||||
Generate images via Codex CLI's built-in `image_gen` tool from non-Codex runtimes (e.g., Claude Code). The wrapper spawns `codex exec --json` and lets the user's existing Codex subscription drive image generation — **no `OPENAI_API_KEY` required**.
|
||||
|
||||
This package implements the `preferred_image_backend: codex-imagegen` config key referenced across the `baoyu-skills` plugin and is the engine behind `baoyu-image-gen --provider codex-cli`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
packages/baoyu-codex-imagegen/
|
||||
├── src/
|
||||
│ ├── main.ts # CLI orchestrator (executable via `#!/usr/bin/env bun`)
|
||||
│ ├── spawn.ts # codex exec child-process wrapper
|
||||
│ ├── parser.ts # JSONL event-stream parser
|
||||
│ ├── validator.ts # Output PNG / image_gen-invocation verification
|
||||
│ ├── cache.ts # SHA256 idempotency cache + file lock
|
||||
│ ├── logger.ts # Structured JSONL logging
|
||||
│ ├── types.ts # Shared types and `GenError`
|
||||
│ └── *.test.ts # Bun unit tests
|
||||
└── package.json # `bin` points to `src/main.ts`
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
npm install -g @openai/codex
|
||||
codex login # signs in with your OpenAI account (subscription)
|
||||
codex --version # confirm >= 0.130
|
||||
```
|
||||
|
||||
`bun` is required for running the wrapper:
|
||||
|
||||
```bash
|
||||
brew install oven-sh/bun/bun
|
||||
```
|
||||
|
||||
If `bun` is not on `PATH`, `npx -y bun src/main.ts …` works as a fallback.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Inline prompt (executes via shebang once bun is on PATH)
|
||||
./src/main.ts \
|
||||
--image /tmp/cat.png \
|
||||
--prompt "A friendly orange cat, watercolor"
|
||||
|
||||
# Or invoke bun explicitly
|
||||
bun src/main.ts \
|
||||
--image cover.png \
|
||||
--prompt-file prompts/01-cover.md \
|
||||
--aspect 16:9 \
|
||||
--cache-dir ~/.cache/baoyu-codex-imagegen
|
||||
|
||||
# Without bun installed
|
||||
npx -y bun src/main.ts --image cover.png --prompt "..."
|
||||
```
|
||||
|
||||
Stdout emits a single JSON line:
|
||||
|
||||
```json
|
||||
{"status":"ok","path":"…","bytes":1234567,"elapsed_seconds":62,"thread_id":"…","attempts":1,"cached":false,"usage":{…}}
|
||||
```
|
||||
|
||||
On failure:
|
||||
|
||||
```json
|
||||
{"status":"error","path":"…","bytes":0,"error":"…","error_kind":"timeout"}
|
||||
```
|
||||
|
||||
`error_kind` values: `codex_not_installed`, `invalid_args`, `prompt_file_missing`, `spawn_failed`, `timeout`, `no_image_gen_tool_use`, `output_missing`, `invalid_png`, `agent_refused`, `lock_busy`.
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--image <path>` | Output PNG path (required) |
|
||||
| `--prompt <text>` | Prompt text |
|
||||
| `--prompt-file <path>` | Read prompt from file (mutually exclusive with `--prompt`) |
|
||||
| `--aspect <ratio>` | Aspect ratio (`1:1`, `16:9`, `9:16`, `4:3`, `2.35:1`). Default: `1:1` |
|
||||
| `--ref <file>` | Reference image (repeatable) |
|
||||
| `--timeout <ms>` | Codex exec timeout in ms. Default: `300000` |
|
||||
| `--retries <n>` | Retry attempts on retryable errors. Default: `2` |
|
||||
| `--retry-delay <ms>` | Base retry delay (exponential). Default: `1500` |
|
||||
| `--cache-dir <path>` | Enable idempotency cache. Disabled by default. |
|
||||
| `--log-file <path>` | Append structured JSONL log |
|
||||
| `-v, --verbose` | Verbose stderr logging |
|
||||
| `-h, --help` | Show help |
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd packages/baoyu-codex-imagegen
|
||||
bun test
|
||||
```
|
||||
|
||||
## Trade-offs
|
||||
|
||||
- 5–10× slower than direct OpenAI API calls (except on cache hits)
|
||||
- Uses your Codex subscription — programmatic use of `codex exec` falls into the same terms as interactive use
|
||||
- Requires `codex` CLI and active login session
|
||||
|
||||
See [`docs/codex-imagegen-backend.md`](../../docs/codex-imagegen-backend.md) for the full background.
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "baoyu-codex-imagegen",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Generate images via Codex CLI's built-in image_gen tool from non-Codex runtimes (Claude Code, Hermes, …).",
|
||||
"bin": {
|
||||
"codex-imagegen": "./src/main.ts"
|
||||
},
|
||||
"files": [
|
||||
"src/**/*.ts",
|
||||
"!src/**/*.test.ts"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/main.ts",
|
||||
"default": "./src/main.ts"
|
||||
},
|
||||
"./src/*": "./src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"smoke": "bun src/main.ts --help"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/JimLiu/baoyu-skills.git",
|
||||
"directory": "packages/baoyu-codex-imagegen"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/JimLiu/baoyu-skills/issues"
|
||||
},
|
||||
"homepage": "https://github.com/JimLiu/baoyu-skills/tree/main/packages/baoyu-codex-imagegen#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.2.0"
|
||||
}
|
||||
}
|
||||
Regular → Executable
+1
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env bun
|
||||
import { readFile, mkdir, copyFile, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
Vendored
+83
-9
@@ -72694,15 +72694,32 @@ var import_node_path6 = __toESM(require("node:path"));
|
||||
function replaceMarkdownImagesWithPlaceholders(markdown2, placeholderPrefix) {
|
||||
const images = [];
|
||||
let imageCounter = 0;
|
||||
const rewritten = markdown2.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => {
|
||||
let lastIndex = 0;
|
||||
let rewritten = "";
|
||||
const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)|!\[\[([^\]\n]+)\]\]/g;
|
||||
for (const match of markdown2.matchAll(imagePattern)) {
|
||||
const fullMatch = match[0];
|
||||
const matchIndex = match.index ?? 0;
|
||||
const markdownAlt = match[1];
|
||||
const markdownSrc = match[2];
|
||||
const wikilinkTarget = match[3];
|
||||
const wikilinkImage = wikilinkTarget ? parseObsidianImageWikilink(wikilinkTarget) : null;
|
||||
if (wikilinkTarget && !wikilinkImage) {
|
||||
continue;
|
||||
}
|
||||
const originalPath = wikilinkImage?.originalPath ?? markdownSrc ?? "";
|
||||
const alt = wikilinkImage?.alt ?? markdownAlt ?? "";
|
||||
const placeholder = `${placeholderPrefix}${++imageCounter}`;
|
||||
rewritten += markdown2.slice(lastIndex, matchIndex);
|
||||
images.push({
|
||||
alt,
|
||||
originalPath: src,
|
||||
originalPath,
|
||||
placeholder
|
||||
});
|
||||
return placeholder;
|
||||
});
|
||||
rewritten += placeholder;
|
||||
lastIndex = matchIndex + fullMatch.length;
|
||||
}
|
||||
rewritten += markdown2.slice(lastIndex);
|
||||
return { images, markdown: rewritten };
|
||||
}
|
||||
function getImageExtension(urlOrPath) {
|
||||
@@ -72757,8 +72774,7 @@ async function resolveImagePath(imagePath, baseDir, tempDir, logLabel = "baoyu-m
|
||||
}
|
||||
return localPath;
|
||||
}
|
||||
const resolved = import_node_path6.default.isAbsolute(imagePath) ? imagePath : import_node_path6.default.resolve(baseDir, imagePath);
|
||||
return resolveLocalWithFallback(resolved, logLabel);
|
||||
return resolveLocalImagePath(imagePath, baseDir, logLabel);
|
||||
}
|
||||
async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") {
|
||||
const resolved = [];
|
||||
@@ -72770,10 +72786,50 @@ async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
function resolveLocalWithFallback(resolved, logLabel) {
|
||||
function parseObsidianImageWikilink(target) {
|
||||
const separatorIndex = target.indexOf("|");
|
||||
const originalPath = (separatorIndex === -1 ? target : target.slice(0, separatorIndex)).trim();
|
||||
const alt = separatorIndex === -1 ? "" : target.slice(separatorIndex + 1).trim();
|
||||
if (!hasExplicitImageExtension(originalPath)) {
|
||||
return null;
|
||||
}
|
||||
return { originalPath, alt };
|
||||
}
|
||||
function hasExplicitImageExtension(value2) {
|
||||
return /\.(?:jpe?g|png|gif|webp)(?:[?#].*)?$/i.test(value2);
|
||||
}
|
||||
function resolveLocalImagePath(imagePath, baseDir, logLabel) {
|
||||
const decoded = safeDecodeImagePath(imagePath);
|
||||
const decodedResolved = resolveAgainstBaseDir(decoded, baseDir);
|
||||
const decodedWithFallback = resolveLocalWithFallback(decodedResolved, logLabel, buildAttachmentFallbackPath(decoded, baseDir));
|
||||
if (decoded === imagePath || import_node_fs5.default.existsSync(decodedWithFallback)) {
|
||||
return decodedWithFallback;
|
||||
}
|
||||
return resolveLocalWithFallback(resolveAgainstBaseDir(imagePath, baseDir), logLabel, buildAttachmentFallbackPath(imagePath, baseDir));
|
||||
}
|
||||
function resolveLocalWithFallback(resolved, logLabel, attachmentResolved) {
|
||||
if (import_node_fs5.default.existsSync(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
if (attachmentResolved && import_node_fs5.default.existsSync(attachmentResolved)) {
|
||||
logImageFallback(resolved, attachmentResolved, logLabel);
|
||||
return attachmentResolved;
|
||||
}
|
||||
const originalAlternative = findExtensionFallback(resolved);
|
||||
if (originalAlternative) {
|
||||
logImageFallback(resolved, originalAlternative, logLabel);
|
||||
return originalAlternative;
|
||||
}
|
||||
if (attachmentResolved) {
|
||||
const attachmentAlternative = findExtensionFallback(attachmentResolved);
|
||||
if (attachmentAlternative) {
|
||||
logImageFallback(resolved, attachmentAlternative, logLabel);
|
||||
return attachmentAlternative;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
function findExtensionFallback(resolved) {
|
||||
const ext = import_node_path6.default.extname(resolved);
|
||||
const base = ext ? resolved.slice(0, -ext.length) : resolved;
|
||||
const alternatives = [
|
||||
@@ -72788,10 +72844,28 @@ function resolveLocalWithFallback(resolved, logLabel) {
|
||||
for (const alternative of alternatives) {
|
||||
if (!import_node_fs5.default.existsSync(alternative))
|
||||
continue;
|
||||
console.error(`[${logLabel}] Image fallback: ${import_node_path6.default.basename(resolved)} -> ${import_node_path6.default.basename(alternative)}`);
|
||||
return alternative;
|
||||
}
|
||||
return resolved;
|
||||
return null;
|
||||
}
|
||||
function logImageFallback(fromPath, toPath, logLabel) {
|
||||
console.error(`[${logLabel}] Image fallback: ${import_node_path6.default.basename(fromPath)} -> ${import_node_path6.default.basename(toPath)}`);
|
||||
}
|
||||
function safeDecodeImagePath(imagePath) {
|
||||
try {
|
||||
return decodeURIComponent(imagePath);
|
||||
} catch {
|
||||
return imagePath;
|
||||
}
|
||||
}
|
||||
function resolveAgainstBaseDir(imagePath, baseDir) {
|
||||
return import_node_path6.default.isAbsolute(imagePath) ? imagePath : import_node_path6.default.resolve(baseDir, imagePath);
|
||||
}
|
||||
function buildAttachmentFallbackPath(imagePath, baseDir) {
|
||||
if (import_node_path6.default.isAbsolute(imagePath)) {
|
||||
return;
|
||||
}
|
||||
return import_node_path6.default.resolve(baseDir, "Attachments", imagePath);
|
||||
}
|
||||
// src/mermaid-preprocess.ts
|
||||
var import_node_fs6 = __toESM(require("node:fs"));
|
||||
|
||||
Vendored
+83
-9
@@ -72620,15 +72620,32 @@ import path5 from "node:path";
|
||||
function replaceMarkdownImagesWithPlaceholders(markdown2, placeholderPrefix) {
|
||||
const images = [];
|
||||
let imageCounter = 0;
|
||||
const rewritten = markdown2.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => {
|
||||
let lastIndex = 0;
|
||||
let rewritten = "";
|
||||
const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)|!\[\[([^\]\n]+)\]\]/g;
|
||||
for (const match of markdown2.matchAll(imagePattern)) {
|
||||
const fullMatch = match[0];
|
||||
const matchIndex = match.index ?? 0;
|
||||
const markdownAlt = match[1];
|
||||
const markdownSrc = match[2];
|
||||
const wikilinkTarget = match[3];
|
||||
const wikilinkImage = wikilinkTarget ? parseObsidianImageWikilink(wikilinkTarget) : null;
|
||||
if (wikilinkTarget && !wikilinkImage) {
|
||||
continue;
|
||||
}
|
||||
const originalPath = wikilinkImage?.originalPath ?? markdownSrc ?? "";
|
||||
const alt = wikilinkImage?.alt ?? markdownAlt ?? "";
|
||||
const placeholder = `${placeholderPrefix}${++imageCounter}`;
|
||||
rewritten += markdown2.slice(lastIndex, matchIndex);
|
||||
images.push({
|
||||
alt,
|
||||
originalPath: src,
|
||||
originalPath,
|
||||
placeholder
|
||||
});
|
||||
return placeholder;
|
||||
});
|
||||
rewritten += placeholder;
|
||||
lastIndex = matchIndex + fullMatch.length;
|
||||
}
|
||||
rewritten += markdown2.slice(lastIndex);
|
||||
return { images, markdown: rewritten };
|
||||
}
|
||||
function getImageExtension(urlOrPath) {
|
||||
@@ -72683,8 +72700,7 @@ async function resolveImagePath(imagePath, baseDir, tempDir, logLabel = "baoyu-m
|
||||
}
|
||||
return localPath;
|
||||
}
|
||||
const resolved = path5.isAbsolute(imagePath) ? imagePath : path5.resolve(baseDir, imagePath);
|
||||
return resolveLocalWithFallback(resolved, logLabel);
|
||||
return resolveLocalImagePath(imagePath, baseDir, logLabel);
|
||||
}
|
||||
async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") {
|
||||
const resolved = [];
|
||||
@@ -72696,10 +72712,50 @@ async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
function resolveLocalWithFallback(resolved, logLabel) {
|
||||
function parseObsidianImageWikilink(target) {
|
||||
const separatorIndex = target.indexOf("|");
|
||||
const originalPath = (separatorIndex === -1 ? target : target.slice(0, separatorIndex)).trim();
|
||||
const alt = separatorIndex === -1 ? "" : target.slice(separatorIndex + 1).trim();
|
||||
if (!hasExplicitImageExtension(originalPath)) {
|
||||
return null;
|
||||
}
|
||||
return { originalPath, alt };
|
||||
}
|
||||
function hasExplicitImageExtension(value2) {
|
||||
return /\.(?:jpe?g|png|gif|webp)(?:[?#].*)?$/i.test(value2);
|
||||
}
|
||||
function resolveLocalImagePath(imagePath, baseDir, logLabel) {
|
||||
const decoded = safeDecodeImagePath(imagePath);
|
||||
const decodedResolved = resolveAgainstBaseDir(decoded, baseDir);
|
||||
const decodedWithFallback = resolveLocalWithFallback(decodedResolved, logLabel, buildAttachmentFallbackPath(decoded, baseDir));
|
||||
if (decoded === imagePath || fs5.existsSync(decodedWithFallback)) {
|
||||
return decodedWithFallback;
|
||||
}
|
||||
return resolveLocalWithFallback(resolveAgainstBaseDir(imagePath, baseDir), logLabel, buildAttachmentFallbackPath(imagePath, baseDir));
|
||||
}
|
||||
function resolveLocalWithFallback(resolved, logLabel, attachmentResolved) {
|
||||
if (fs5.existsSync(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
if (attachmentResolved && fs5.existsSync(attachmentResolved)) {
|
||||
logImageFallback(resolved, attachmentResolved, logLabel);
|
||||
return attachmentResolved;
|
||||
}
|
||||
const originalAlternative = findExtensionFallback(resolved);
|
||||
if (originalAlternative) {
|
||||
logImageFallback(resolved, originalAlternative, logLabel);
|
||||
return originalAlternative;
|
||||
}
|
||||
if (attachmentResolved) {
|
||||
const attachmentAlternative = findExtensionFallback(attachmentResolved);
|
||||
if (attachmentAlternative) {
|
||||
logImageFallback(resolved, attachmentAlternative, logLabel);
|
||||
return attachmentAlternative;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
function findExtensionFallback(resolved) {
|
||||
const ext = path5.extname(resolved);
|
||||
const base = ext ? resolved.slice(0, -ext.length) : resolved;
|
||||
const alternatives = [
|
||||
@@ -72714,10 +72770,28 @@ function resolveLocalWithFallback(resolved, logLabel) {
|
||||
for (const alternative of alternatives) {
|
||||
if (!fs5.existsSync(alternative))
|
||||
continue;
|
||||
console.error(`[${logLabel}] Image fallback: ${path5.basename(resolved)} -> ${path5.basename(alternative)}`);
|
||||
return alternative;
|
||||
}
|
||||
return resolved;
|
||||
return null;
|
||||
}
|
||||
function logImageFallback(fromPath, toPath, logLabel) {
|
||||
console.error(`[${logLabel}] Image fallback: ${path5.basename(fromPath)} -> ${path5.basename(toPath)}`);
|
||||
}
|
||||
function safeDecodeImagePath(imagePath) {
|
||||
try {
|
||||
return decodeURIComponent(imagePath);
|
||||
} catch {
|
||||
return imagePath;
|
||||
}
|
||||
}
|
||||
function resolveAgainstBaseDir(imagePath, baseDir) {
|
||||
return path5.isAbsolute(imagePath) ? imagePath : path5.resolve(baseDir, imagePath);
|
||||
}
|
||||
function buildAttachmentFallbackPath(imagePath, baseDir) {
|
||||
if (path5.isAbsolute(imagePath)) {
|
||||
return;
|
||||
}
|
||||
return path5.resolve(baseDir, "Attachments", imagePath);
|
||||
}
|
||||
// src/mermaid-preprocess.ts
|
||||
import fs6 from "node:fs";
|
||||
|
||||
@@ -28,6 +28,32 @@ test("replaceMarkdownImagesWithPlaceholders rewrites markdown and tracks image m
|
||||
]);
|
||||
});
|
||||
|
||||
test("replaceMarkdownImagesWithPlaceholders supports Obsidian image wikilinks in document order", () => {
|
||||
const result = replaceMarkdownImagesWithPlaceholders(
|
||||
`Intro\n\n![[a.png]]\n\n\n\n![[c.webp|C alt]]\n\n![[note]]`,
|
||||
"IMG_",
|
||||
);
|
||||
|
||||
assert.equal(result.markdown, `Intro\n\nIMG_1\n\nIMG_2\n\nIMG_3\n\n![[note]]`);
|
||||
assert.deepEqual(result.images, [
|
||||
{ alt: "", originalPath: "a.png", placeholder: "IMG_1" },
|
||||
{ alt: "B", originalPath: "b.jpg", placeholder: "IMG_2" },
|
||||
{ alt: "C alt", originalPath: "c.webp", placeholder: "IMG_3" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("replaceMarkdownImagesWithPlaceholders supports Obsidian image wikilinks with paths", () => {
|
||||
const result = replaceMarkdownImagesWithPlaceholders(
|
||||
`![[Attachments/screenshot.png]]`,
|
||||
"IMG_",
|
||||
);
|
||||
|
||||
assert.equal(result.markdown, `IMG_1`);
|
||||
assert.deepEqual(result.images, [
|
||||
{ alt: "", originalPath: "Attachments/screenshot.png", placeholder: "IMG_1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("image extension and local fallback resolution handle common path variants", async (t) => {
|
||||
assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg");
|
||||
assert.equal(getImageExtension("/tmp/figure"), "png");
|
||||
@@ -45,6 +71,70 @@ test("image extension and local fallback resolution handle common path variants"
|
||||
assert.equal(resolved, path.join(baseDir, "figure.webp"));
|
||||
});
|
||||
|
||||
test("resolveImagePath falls back to Attachments subdirectory before extension variants", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-attachments-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
const attachmentsDir = path.join(baseDir, "Attachments");
|
||||
await fs.mkdir(attachmentsDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "figure.webp"), "webp");
|
||||
await fs.writeFile(path.join(attachmentsDir, "figure.png"), "png");
|
||||
|
||||
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
|
||||
assert.equal(resolved, path.join(attachmentsDir, "figure.png"));
|
||||
});
|
||||
|
||||
test("resolveImagePath prefers original path before Attachments fallback", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-attachments-original-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
const attachmentsDir = path.join(baseDir, "Attachments");
|
||||
await fs.mkdir(attachmentsDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "figure.png"), "png");
|
||||
await fs.writeFile(path.join(attachmentsDir, "figure.png"), "attachment png");
|
||||
|
||||
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
|
||||
assert.equal(resolved, path.join(baseDir, "figure.png"));
|
||||
});
|
||||
|
||||
test("resolveImagePath decodes URL-encoded filenames with spaces", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-urlencoded-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "Pasted image 20260524.png"), "png");
|
||||
|
||||
const resolved = await resolveImagePath("Pasted%20image%2020260524.png", baseDir, tempDir, "test");
|
||||
assert.equal(resolved, path.join(baseDir, "Pasted image 20260524.png"));
|
||||
});
|
||||
|
||||
test("resolveImagePath keeps literal percent filenames usable", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-percent-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "100% complete.png"), "png");
|
||||
await fs.writeFile(path.join(baseDir, "diagram%23hash.png"), "png");
|
||||
|
||||
const malformedPercent = await resolveImagePath("100% complete.png", baseDir, tempDir, "test");
|
||||
assert.equal(malformedPercent, path.join(baseDir, "100% complete.png"));
|
||||
|
||||
const literalEncodedPercent = await resolveImagePath("diagram%23hash.png", baseDir, tempDir, "test");
|
||||
assert.equal(literalEncodedPercent, path.join(baseDir, "diagram%23hash.png"));
|
||||
});
|
||||
|
||||
test("resolveContentImages resolves image placeholders against the content directory", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-content-images-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
+123
-13
@@ -23,16 +23,39 @@ export function replaceMarkdownImagesWithPlaceholders(
|
||||
} {
|
||||
const images: ImagePlaceholder[] = [];
|
||||
let imageCounter = 0;
|
||||
let lastIndex = 0;
|
||||
let rewritten = "";
|
||||
|
||||
const rewritten = markdown.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => {
|
||||
const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)|!\[\[([^\]\n]+)\]\]/g;
|
||||
for (const match of markdown.matchAll(imagePattern)) {
|
||||
const fullMatch = match[0];
|
||||
const matchIndex = match.index ?? 0;
|
||||
const markdownAlt = match[1];
|
||||
const markdownSrc = match[2];
|
||||
const wikilinkTarget = match[3];
|
||||
const wikilinkImage = wikilinkTarget
|
||||
? parseObsidianImageWikilink(wikilinkTarget)
|
||||
: null;
|
||||
|
||||
if (wikilinkTarget && !wikilinkImage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalPath = wikilinkImage?.originalPath ?? markdownSrc ?? "";
|
||||
const alt = wikilinkImage?.alt ?? markdownAlt ?? "";
|
||||
const placeholder = `${placeholderPrefix}${++imageCounter}`;
|
||||
|
||||
rewritten += markdown.slice(lastIndex, matchIndex);
|
||||
images.push({
|
||||
alt,
|
||||
originalPath: src,
|
||||
originalPath,
|
||||
placeholder,
|
||||
});
|
||||
return placeholder;
|
||||
});
|
||||
rewritten += placeholder;
|
||||
lastIndex = matchIndex + fullMatch.length;
|
||||
}
|
||||
|
||||
rewritten += markdown.slice(lastIndex);
|
||||
|
||||
return { images, markdown: rewritten };
|
||||
}
|
||||
@@ -103,10 +126,7 @@ export async function resolveImagePath(
|
||||
return localPath;
|
||||
}
|
||||
|
||||
const resolved = path.isAbsolute(imagePath)
|
||||
? imagePath
|
||||
: path.resolve(baseDir, imagePath);
|
||||
return resolveLocalWithFallback(resolved, logLabel);
|
||||
return resolveLocalImagePath(imagePath, baseDir, logLabel);
|
||||
}
|
||||
|
||||
export async function resolveContentImages(
|
||||
@@ -127,11 +147,79 @@ export async function resolveContentImages(
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveLocalWithFallback(resolved: string, logLabel: string): string {
|
||||
function parseObsidianImageWikilink(target: string): {
|
||||
originalPath: string;
|
||||
alt: string;
|
||||
} | null {
|
||||
const separatorIndex = target.indexOf("|");
|
||||
const originalPath = (separatorIndex === -1
|
||||
? target
|
||||
: target.slice(0, separatorIndex)).trim();
|
||||
const alt = separatorIndex === -1 ? "" : target.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (!hasExplicitImageExtension(originalPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { originalPath, alt };
|
||||
}
|
||||
|
||||
function hasExplicitImageExtension(value: string): boolean {
|
||||
return /\.(?:jpe?g|png|gif|webp)(?:[?#].*)?$/i.test(value);
|
||||
}
|
||||
|
||||
function resolveLocalImagePath(imagePath: string, baseDir: string, logLabel: string): string {
|
||||
const decoded = safeDecodeImagePath(imagePath);
|
||||
const decodedResolved = resolveAgainstBaseDir(decoded, baseDir);
|
||||
const decodedWithFallback = resolveLocalWithFallback(
|
||||
decodedResolved,
|
||||
logLabel,
|
||||
buildAttachmentFallbackPath(decoded, baseDir),
|
||||
);
|
||||
|
||||
if (decoded === imagePath || fs.existsSync(decodedWithFallback)) {
|
||||
return decodedWithFallback;
|
||||
}
|
||||
|
||||
return resolveLocalWithFallback(
|
||||
resolveAgainstBaseDir(imagePath, baseDir),
|
||||
logLabel,
|
||||
buildAttachmentFallbackPath(imagePath, baseDir),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLocalWithFallback(
|
||||
resolved: string,
|
||||
logLabel: string,
|
||||
attachmentResolved?: string,
|
||||
): string {
|
||||
if (fs.existsSync(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
if (attachmentResolved && fs.existsSync(attachmentResolved)) {
|
||||
logImageFallback(resolved, attachmentResolved, logLabel);
|
||||
return attachmentResolved;
|
||||
}
|
||||
|
||||
const originalAlternative = findExtensionFallback(resolved);
|
||||
if (originalAlternative) {
|
||||
logImageFallback(resolved, originalAlternative, logLabel);
|
||||
return originalAlternative;
|
||||
}
|
||||
|
||||
if (attachmentResolved) {
|
||||
const attachmentAlternative = findExtensionFallback(attachmentResolved);
|
||||
if (attachmentAlternative) {
|
||||
logImageFallback(resolved, attachmentAlternative, logLabel);
|
||||
return attachmentAlternative;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function findExtensionFallback(resolved: string): string | null {
|
||||
const ext = path.extname(resolved);
|
||||
const base = ext ? resolved.slice(0, -ext.length) : resolved;
|
||||
const alternatives = [
|
||||
@@ -146,11 +234,33 @@ function resolveLocalWithFallback(resolved: string, logLabel: string): string {
|
||||
|
||||
for (const alternative of alternatives) {
|
||||
if (!fs.existsSync(alternative)) continue;
|
||||
console.error(
|
||||
`[${logLabel}] Image fallback: ${path.basename(resolved)} -> ${path.basename(alternative)}`,
|
||||
);
|
||||
return alternative;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
return null;
|
||||
}
|
||||
|
||||
function logImageFallback(fromPath: string, toPath: string, logLabel: string): void {
|
||||
console.error(
|
||||
`[${logLabel}] Image fallback: ${path.basename(fromPath)} -> ${path.basename(toPath)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function safeDecodeImagePath(imagePath: string): string {
|
||||
try {
|
||||
return decodeURIComponent(imagePath);
|
||||
} catch {
|
||||
return imagePath;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAgainstBaseDir(imagePath: string, baseDir: string): string {
|
||||
return path.isAbsolute(imagePath) ? imagePath : path.resolve(baseDir, imagePath);
|
||||
}
|
||||
|
||||
function buildAttachmentFallbackPath(imagePath: string, baseDir: string): string | undefined {
|
||||
if (path.isAbsolute(imagePath)) {
|
||||
return undefined;
|
||||
}
|
||||
return path.resolve(baseDir, "Attachments", imagePath);
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/bin/bash
|
||||
# codex-imagegen: generate images via Codex CLI's built-in image_gen tool
|
||||
# Thin shell wrapper — implementation in codex-imagegen/main.ts (Bun TypeScript)
|
||||
#
|
||||
# Usage: ./codex-imagegen.sh --help
|
||||
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if command -v bun &>/dev/null; then
|
||||
BUN_X="bun"
|
||||
elif command -v npx &>/dev/null; then
|
||||
BUN_X="npx -y bun"
|
||||
else
|
||||
echo "Error: bun or npx required. Install: brew install oven-sh/bun/bun" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec $BUN_X "$SCRIPT_DIR/codex-imagegen/main.ts" "$@"
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# Sync packages/baoyu-codex-imagegen/src/*.ts (excluding tests) into
|
||||
# skills/baoyu-image-gen/scripts/codex-imagegen/ so the skill stays
|
||||
# self-contained (no `../../../../packages/...` lookups at runtime).
|
||||
#
|
||||
# Run this whenever packages/baoyu-codex-imagegen/src/ changes,
|
||||
# and always before tagging a release.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SRC_DIR="$REPO_ROOT/packages/baoyu-codex-imagegen/src"
|
||||
DST_DIR="$REPO_ROOT/skills/baoyu-image-gen/scripts/codex-imagegen"
|
||||
|
||||
if [[ ! -d "$SRC_DIR" ]]; then
|
||||
echo "Error: source dir missing: $SRC_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DST_DIR"
|
||||
|
||||
changed=0
|
||||
for f in cache.ts logger.ts main.ts parser.ts spawn.ts types.ts validator.ts; do
|
||||
src="$SRC_DIR/$f"
|
||||
dst="$DST_DIR/$f"
|
||||
if [[ ! -f "$src" ]]; then
|
||||
echo "Error: missing source file: $src" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$dst" ]] || ! cmp -s "$src" "$dst"; then
|
||||
cp "$src" "$dst"
|
||||
echo "synced: $f"
|
||||
changed=$((changed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
chmod +x "$DST_DIR/main.ts"
|
||||
|
||||
# Drop any stale .ts files in DST that don't exist in SRC.
|
||||
for dst in "$DST_DIR"/*.ts; do
|
||||
[[ -e "$dst" ]] || continue
|
||||
name="$(basename "$dst")"
|
||||
case "$name" in
|
||||
*.test.ts) rm -f "$dst"; echo "removed test artifact: $name" ;;
|
||||
*)
|
||||
if [[ ! -f "$SRC_DIR/$name" ]]; then
|
||||
rm -f "$dst"
|
||||
echo "removed stale: $name"
|
||||
changed=$((changed + 1))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$changed" -eq 0 ]]; then
|
||||
echo "codex-imagegen sync: up to date"
|
||||
else
|
||||
echo "codex-imagegen sync: $changed file(s) updated"
|
||||
fi
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-article-illustrator
|
||||
description: Analyzes article structure, identifies positions requiring visual aids, generates illustrations with Type × Style × Palette three-dimension approach. Use when user asks to "illustrate article", "add images", "generate images for article", or "为文章配图".
|
||||
version: 1.117.3
|
||||
version: 1.117.4
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-article-illustrator
|
||||
@@ -29,6 +29,7 @@ When this skill needs to render an image, resolve the backend in this order:
|
||||
2. **Saved preference** — if `EXTEND.md` sets `preferred_image_backend` to a backend available right now, use it.
|
||||
3. **Auto-select** (when the preference is `auto`, unset, or the pinned backend isn't available):
|
||||
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-image-gen`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime exposes no native `imagegen` skill but the `codex` CLI is on `PATH` with an active `codex login`, route through `baoyu-image-gen --provider codex-cli` (preferred), or — if baoyu-image-gen is unavailable — invoke the bundled wrapper directly. Details, parameters, and the runtime-discovery procedure live in [references/codex-imagegen.md](references/codex-imagegen.md) — load that file only when this branch is selected.
|
||||
- **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way.
|
||||
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-image-gen`), use it.
|
||||
- Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions.
|
||||
@@ -185,6 +186,7 @@ Full template: [references/workflow.md](references/workflow.md#step-4-generate-o
|
||||
4. LABELS **MUST** include article-specific data: actual numbers, terms, metrics, quotes
|
||||
5. **DO NOT** pass ad-hoc inline prompts to `--prompt` without saving prompt files first
|
||||
6. Select the backend via the `## Image Generation Tools` rule at the top: use whatever is available; if multiple, ask the user once. Do this once per session before any generation.
|
||||
- **`codex-imagegen` invocation**: when the rule resolves to `codex-imagegen`, see [references/codex-imagegen.md](references/codex-imagegen.md) for the invocation contract (preferred `baoyu-image-gen --provider codex-cli` path, runtime wrapper discovery, parameter notes, stdout schema, batch semantics).
|
||||
7. **Execution strategy**: Generate in batches per the `## Batch Generation Policy`: backend native batch first, runtime parallel tool calls second, sequential only as fallback. Default batch size is 4 unless EXTEND.md or the current request overrides it.
|
||||
8. Process references (`direct`/`style`/`palette`) per prompt frontmatter
|
||||
9. Apply watermark if EXTEND.md enabled
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# `codex-imagegen` Wrapper Invocation
|
||||
|
||||
Load this reference only when the [Image Generation Tools](../SKILL.md#image-generation-tools) rule has resolved to `codex-imagegen` — i.e., the current runtime exposes no native `imagegen` skill but `codex` CLI is on `PATH` with an active `codex login`.
|
||||
|
||||
## Preferred path: route through `baoyu-image-gen`
|
||||
|
||||
If the `baoyu-image-gen` skill is available in this runtime, **always** invoke through it rather than calling the wrapper directly. It handles retry/cache/batch/EXTEND.md preferences uniformly with every other provider.
|
||||
|
||||
```bash
|
||||
${BUN_X} <baoyu-image-gen-base>/scripts/main.ts \
|
||||
--provider codex-cli \
|
||||
--image <ABSOLUTE_output> \
|
||||
--promptfiles <ABSOLUTE_prompts/NN-{type}-{slug}.md> \
|
||||
--ar <ratio> \
|
||||
[--ref <ABSOLUTE_file>]...
|
||||
```
|
||||
|
||||
Resolve `<baoyu-image-gen-base>` the same way you resolve any sibling skill — through your runtime's skill registry (`Skill` tool, plugin marketplace, or `$HOME/.baoyu-skills/baoyu-image-gen/`).
|
||||
|
||||
## Fallback: spawn the wrapper directly
|
||||
|
||||
Only when `baoyu-image-gen` is NOT installed in the current runtime. Discover the wrapper's location at runtime — do NOT hard-code `../../packages/...` from this skill:
|
||||
|
||||
1. **Honor explicit override**: if `$BAOYU_CODEX_IMAGEGEN_BIN` is set and points to a real file, use that path. It may be `.ts` (spawn `bun <path>`) or `.sh`/binary (spawn directly).
|
||||
2. **Search the plugin root**: walk up from this skill's directory looking for `packages/baoyu-codex-imagegen/src/main.ts`. If found, that is the wrapper. Spawn it with `bun`.
|
||||
3. **Last resort**: tell the user that `codex-imagegen` is not available in this runtime and ask whether to install the `baoyu-skills` plugin (or set `BAOYU_CODEX_IMAGEGEN_BIN`) or pick another backend.
|
||||
|
||||
Once located, the invocation shape is:
|
||||
|
||||
```bash
|
||||
bun <WRAPPER>/main.ts \
|
||||
--image <ABSOLUTE_output> \
|
||||
--prompt-file <ABSOLUTE_prompts/NN-{type}-{slug}.md> \
|
||||
--aspect <ratio> \
|
||||
[--ref <ABSOLUTE_file>]... \
|
||||
[--timeout <ms>] \
|
||||
[--cache-dir ~/.cache/baoyu-codex-imagegen] \
|
||||
[--log-file <ABSOLUTE_jsonl_log_path>]
|
||||
```
|
||||
|
||||
If `bun` is missing, `npx -y bun <WRAPPER>/main.ts ...` works as a fallback.
|
||||
|
||||
## Parameter notes
|
||||
|
||||
- **All filesystem inputs** are auto-resolved against `process.cwd()` when relative, but agents should pass absolute paths to be robust against cwd drift.
|
||||
- **`--timeout`** defaults to `300000` (5 min) per `codex exec` attempt. Raise (e.g. `--timeout 600000` for 10 min) on slow networks or large prompts.
|
||||
- **`--cache-dir`** is off by default. Enable for repeatable runs to skip redundant generations of the same prompt+aspect+refs.
|
||||
- **Authentication**: the wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` is read or sent.
|
||||
|
||||
## Stdout contract
|
||||
|
||||
Single JSON line:
|
||||
|
||||
- Success: `{"status":"ok","path":"...","bytes":N,"elapsed_seconds":N,"thread_id":"...","attempts":N,"cached":bool,...}`
|
||||
- Failure: `{"status":"error","path":"...","bytes":0,"error":"...","error_kind":"..."}`
|
||||
|
||||
`error_kind` values: `codex_not_installed`, `invalid_args`, `prompt_file_missing`, `spawn_failed`, `timeout`, `no_image_gen_tool_use`, `output_missing`, `invalid_png`, `agent_refused`, `lock_busy`.
|
||||
|
||||
On retryable errors (timeout, spawn_failed, no_image_gen_tool_use, output_missing, invalid_png, agent_refused), ask the user whether to retry or fall back to another backend.
|
||||
|
||||
## Batch semantics
|
||||
|
||||
- Codex `image_gen` returns **one image per call** (`n=1` only). Multi-image jobs must dispatch one wrapper call per image.
|
||||
- The wrapper does NOT accept a `--sessionId` flag. Chain/scene consistency must come from `--ref` reference images.
|
||||
- `--size` and `--quality` are silently ignored — Codex picks pixel dimensions from `--aspect`.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-comic
|
||||
description: Knowledge comic creator supporting multiple art styles and tones. Creates original educational comics with detailed panel layouts and batch-capable image generation. Use when user asks to create "知识漫画", "教育漫画", "biography comic", "tutorial comic", or "Logicomix-style comic".
|
||||
version: 1.117.3
|
||||
version: 1.117.4
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-comic
|
||||
@@ -33,6 +33,7 @@ When this skill needs to render an image, resolve the backend in this order:
|
||||
2. **Saved preference** — if `EXTEND.md` sets `preferred_image_backend` to a backend available right now, use it.
|
||||
3. **Auto-select** (when the preference is `auto`, unset, or the pinned backend isn't available):
|
||||
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-image-gen`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime exposes no native `imagegen` skill but the `codex` CLI is on `PATH` with an active `codex login`, route through `baoyu-image-gen --provider codex-cli` (preferred), or — if baoyu-image-gen is unavailable — invoke the bundled wrapper directly. Details, parameters, and the runtime-discovery procedure live in [references/codex-imagegen.md](references/codex-imagegen.md) — load that file only when this branch is selected.
|
||||
- **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way.
|
||||
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-image-gen`), use it.
|
||||
- Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions.
|
||||
@@ -247,6 +248,8 @@ Analyze → [Check Existing?] → [Confirm: Style + Reviews] → Storyboard →
|
||||
|
||||
**Pick a backend once per session** using the `## Image Generation Tools` rule at the top. If the backend is a repo skill (e.g., `baoyu-image-gen`), read its `SKILL.md` and use its documented interface rather than its scripts.
|
||||
|
||||
**`codex-imagegen` invocation**: when the rule resolves to `codex-imagegen`, see [references/codex-imagegen.md](references/codex-imagegen.md) for the invocation contract (preferred `baoyu-image-gen --provider codex-cli` path, runtime wrapper discovery, parameter notes, stdout schema, batch semantics — n=1 per call so page batches must dispatch one wrapper call per page).
|
||||
|
||||
**7.1 Character sheet** — generate it (to `characters/characters.png`, aspect `4:3`) when the comic is multi-page with recurring characters. Skip for simple presets (e.g., four-panel minimalist) or single-page comics. Compress to JPEG before use-as-`--ref` (`sips -s format jpeg -s formatOptions 80 …` on macOS, `pngquant --quality=65-80 …` elsewhere) to avoid payload failures. The prompt file at `characters/characters.md` must exist before invoking the backend.
|
||||
|
||||
**7.2 Pages** — each page's prompt MUST already be at `prompts/NN-{cover|page}-[slug].md` before invoking the backend; the file is the reproducibility record. Strategy depends on the character sheet:
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# `codex-imagegen` Wrapper Invocation
|
||||
|
||||
Load this reference only when the [Image Generation Tools](../SKILL.md#image-generation-tools) rule has resolved to `codex-imagegen` — i.e., the current runtime exposes no native `imagegen` skill but `codex` CLI is on `PATH` with an active `codex login`.
|
||||
|
||||
## Preferred path: route through `baoyu-image-gen`
|
||||
|
||||
If the `baoyu-image-gen` skill is available in this runtime, **always** invoke through it rather than calling the wrapper directly. It handles retry/cache/batch/EXTEND.md preferences uniformly with every other provider.
|
||||
|
||||
```bash
|
||||
${BUN_X} <baoyu-image-gen-base>/scripts/main.ts \
|
||||
--provider codex-cli \
|
||||
--image <ABSOLUTE_output> \
|
||||
--promptfiles <ABSOLUTE_prompts/NN-{cover|page}-[slug].md> \
|
||||
--ar <ratio> \
|
||||
[--ref <ABSOLUTE_file>]...
|
||||
```
|
||||
|
||||
Resolve `<baoyu-image-gen-base>` the same way you resolve any sibling skill — through your runtime's skill registry (`Skill` tool, plugin marketplace, or `$HOME/.baoyu-skills/baoyu-image-gen/`).
|
||||
|
||||
## Fallback: spawn the wrapper directly
|
||||
|
||||
Only when `baoyu-image-gen` is NOT installed in the current runtime. Discover the wrapper's location at runtime — do NOT hard-code `../../packages/...` from this skill:
|
||||
|
||||
1. **Honor explicit override**: if `$BAOYU_CODEX_IMAGEGEN_BIN` is set and points to a real file, use that path. It may be `.ts` (spawn `bun <path>`) or `.sh`/binary (spawn directly).
|
||||
2. **Search the plugin root**: walk up from this skill's directory looking for `packages/baoyu-codex-imagegen/src/main.ts`. If found, that is the wrapper. Spawn it with `bun`.
|
||||
3. **Last resort**: tell the user that `codex-imagegen` is not available in this runtime and ask whether to install the `baoyu-skills` plugin (or set `BAOYU_CODEX_IMAGEGEN_BIN`) or pick another backend.
|
||||
|
||||
Once located, the invocation shape is:
|
||||
|
||||
```bash
|
||||
bun <WRAPPER>/main.ts \
|
||||
--image <ABSOLUTE_output> \
|
||||
--prompt-file <ABSOLUTE_prompts/NN-{cover|page}-[slug].md> \
|
||||
--aspect <ratio> \
|
||||
[--ref <ABSOLUTE_file>]... \
|
||||
[--timeout <ms>] \
|
||||
[--cache-dir ~/.cache/baoyu-codex-imagegen] \
|
||||
[--log-file <ABSOLUTE_jsonl_log_path>]
|
||||
```
|
||||
|
||||
If `bun` is missing, `npx -y bun <WRAPPER>/main.ts ...` works as a fallback.
|
||||
|
||||
## Parameter notes
|
||||
|
||||
- **All filesystem inputs** are auto-resolved against `process.cwd()` when relative, but agents should pass absolute paths to be robust against cwd drift.
|
||||
- **`--timeout`** defaults to `300000` (5 min) per `codex exec` attempt. Raise (e.g. `--timeout 600000` for 10 min) on slow networks or large prompts.
|
||||
- **`--cache-dir`** is off by default. Enable for repeatable runs to skip redundant generations of the same prompt+aspect+refs.
|
||||
- **Authentication**: the wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` is read or sent.
|
||||
|
||||
## Stdout contract
|
||||
|
||||
Single JSON line:
|
||||
|
||||
- Success: `{"status":"ok","path":"...","bytes":N,"elapsed_seconds":N,"thread_id":"...","attempts":N,"cached":bool,...}`
|
||||
- Failure: `{"status":"error","path":"...","bytes":0,"error":"...","error_kind":"..."}`
|
||||
|
||||
`error_kind` values: `codex_not_installed`, `invalid_args`, `prompt_file_missing`, `spawn_failed`, `timeout`, `no_image_gen_tool_use`, `output_missing`, `invalid_png`, `agent_refused`, `lock_busy`.
|
||||
|
||||
On retryable errors (timeout, spawn_failed, no_image_gen_tool_use, output_missing, invalid_png, agent_refused), ask the user whether to retry or fall back to another backend.
|
||||
|
||||
## Batch semantics
|
||||
|
||||
- Codex `image_gen` returns **one image per call** (`n=1` only). Multi-image jobs must dispatch one wrapper call per image.
|
||||
- The wrapper does NOT accept a `--sessionId` flag. Chain/scene consistency must come from `--ref` reference images.
|
||||
- `--size` and `--quality` are silently ignored — Codex picks pixel dimensions from `--aspect`.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-cover-image
|
||||
description: Generates article cover images with 5 dimensions (type, palette, rendering, text, mood) combining 11 color palettes and 7 rendering styles. Supports cinematic (2.35:1), widescreen (16:9), and square (1:1) aspects. Use when user asks to "generate cover image", "create article cover", or "make cover".
|
||||
version: 1.117.4
|
||||
version: 1.117.5
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-cover-image
|
||||
@@ -29,19 +29,7 @@ When this skill needs to render an image, resolve the backend in this order:
|
||||
2. **Saved preference** — if `EXTEND.md` sets `preferred_image_backend` to a backend available right now, use it.
|
||||
3. **Auto-select** (when the preference is `auto`, unset, or the pinned backend isn't available):
|
||||
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-image-gen`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime does NOT expose a native `imagegen` skill but the `codex` CLI is on `PATH` and `codex login` is active (e.g., Claude Code with Codex CLI installed), invoke the `codex-imagegen` wrapper. **Path resolution**: `scripts/codex-imagegen.sh` lives at the plugin/repo root, NOT relative to your shell cwd. From this `SKILL.md`'s base directory, the wrapper is at `../../scripts/codex-imagegen.sh` — resolve to an absolute path before invoking. Command shape:
|
||||
```bash
|
||||
<ABSOLUTE_PLUGIN_ROOT>/scripts/codex-imagegen.sh \
|
||||
--image <absolute_output_path> \
|
||||
--prompt-file <absolute_path_to_prompts/NN-cover-[slug].md> \
|
||||
--aspect <ratio> \
|
||||
[--ref <absolute_file>]... \
|
||||
[--timeout <ms>] \
|
||||
[--cache-dir ~/.cache/baoyu-codex-imagegen] \
|
||||
[--log-file <absolute_jsonl_log_path>]
|
||||
```
|
||||
`--timeout` defaults to 300000 (5 min) per codex exec attempt; raise it (e.g. `--timeout 600000` for 10 min) on slow networks or large prompts.
|
||||
All input paths to the wrapper are auto-resolved against the wrapper's `process.cwd()` if you pass relative ones, but agents should pass absolute paths to be robust against cwd drift. Parse the single-line JSON on stdout. On `{"status":"ok",...}` proceed to Step 5. On `{"status":"error","error_kind":...}` report the `error_kind` to the user and (if retryable) ask whether to retry or fall back to another backend. The wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` needed.
|
||||
- **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime exposes no native `imagegen` skill but the `codex` CLI is on `PATH` with an active `codex login`, route through `baoyu-image-gen --provider codex-cli` (preferred), or — if baoyu-image-gen is unavailable — invoke the bundled wrapper directly. Details, parameters, and the runtime-discovery procedure live in [references/codex-imagegen.md](references/codex-imagegen.md) — load that file only when this branch is selected.
|
||||
- **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way.
|
||||
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-image-gen`), use it.
|
||||
- Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions.
|
||||
@@ -228,7 +216,7 @@ Save to `prompts/cover.md`. Template: [references/workflow/prompt-template.md](r
|
||||
- `direct` usage → pass via `--ref` (use ref-capable backend)
|
||||
- `style`/`palette` → extract traits, append to prompt
|
||||
5. **Generate**: Call the chosen backend with the prompt file, output path, aspect ratio.
|
||||
- **`codex-imagegen`**: invoke `<ABSOLUTE_PLUGIN_ROOT>/scripts/codex-imagegen.sh` (NOT a cwd-relative `./scripts/...` — resolve the absolute path from this skill's base directory: `../../scripts/codex-imagegen.sh`) with `--image <ABSOLUTE_output>` `--prompt-file <ABSOLUTE_prompts/01-cover-[slug].md>` `--aspect <ratio>` (add `--ref <ABSOLUTE_file>` per reference, `--cache-dir ~/.cache/baoyu-codex-imagegen` to enable the idempotency cache, `--timeout <ms>` to override the default 300000 / 5-min per-attempt limit on slow networks). All input paths to the wrapper are auto-resolved against its `process.cwd()` if relative, but passing absolutes is more robust. Read the stdout JSON; act on `status` and `error_kind`.
|
||||
- **`codex-imagegen`**: see [references/codex-imagegen.md](references/codex-imagegen.md) for the invocation contract (preferred `baoyu-image-gen --provider codex-cli` path, runtime wrapper discovery, parameter notes, stdout schema, batch semantics).
|
||||
- **Codex `imagegen` (native)** or other runtime-native tools / `baoyu-image-gen` skill: per the rule in `## Image Generation Tools` above.
|
||||
6. On failure: auto-retry once
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# `codex-imagegen` Wrapper Invocation
|
||||
|
||||
Load this reference only when the [Image Generation Tools](../SKILL.md#image-generation-tools) rule has resolved to `codex-imagegen` — i.e., the current runtime exposes no native `imagegen` skill but `codex` CLI is on `PATH` with an active `codex login`.
|
||||
|
||||
## Preferred path: route through `baoyu-image-gen`
|
||||
|
||||
If the `baoyu-image-gen` skill is available in this runtime, **always** invoke through it rather than calling the wrapper directly. It handles retry/cache/batch/EXTEND.md preferences uniformly with every other provider.
|
||||
|
||||
```bash
|
||||
${BUN_X} <baoyu-image-gen-base>/scripts/main.ts \
|
||||
--provider codex-cli \
|
||||
--image <ABSOLUTE_output> \
|
||||
--promptfiles <ABSOLUTE_prompts/01-cover-[slug].md> \
|
||||
--ar <ratio> \
|
||||
[--ref <ABSOLUTE_file>]...
|
||||
```
|
||||
|
||||
Resolve `<baoyu-image-gen-base>` the same way you resolve any sibling skill — through your runtime's skill registry (`Skill` tool, plugin marketplace, or `$HOME/.baoyu-skills/baoyu-image-gen/`).
|
||||
|
||||
## Fallback: spawn the wrapper directly
|
||||
|
||||
Only when `baoyu-image-gen` is NOT installed in the current runtime. Discover the wrapper's location at runtime — do NOT hard-code `../../packages/...` from this skill:
|
||||
|
||||
1. **Honor explicit override**: if `$BAOYU_CODEX_IMAGEGEN_BIN` is set and points to a real file, use that path. It may be `.ts` (spawn `bun <path>`) or `.sh`/binary (spawn directly).
|
||||
2. **Search the plugin root**: walk up from this skill's directory looking for `packages/baoyu-codex-imagegen/src/main.ts`. If found, that is the wrapper. Spawn it with `bun`.
|
||||
3. **Last resort**: tell the user that `codex-imagegen` is not available in this runtime and ask whether to install the `baoyu-skills` plugin (or set `BAOYU_CODEX_IMAGEGEN_BIN`) or pick another backend.
|
||||
|
||||
Once located, the invocation shape is:
|
||||
|
||||
```bash
|
||||
bun <WRAPPER>/main.ts \
|
||||
--image <ABSOLUTE_output> \
|
||||
--prompt-file <ABSOLUTE_prompts/01-cover-[slug].md> \
|
||||
--aspect <ratio> \
|
||||
[--ref <ABSOLUTE_file>]... \
|
||||
[--timeout <ms>] \
|
||||
[--cache-dir ~/.cache/baoyu-codex-imagegen] \
|
||||
[--log-file <ABSOLUTE_jsonl_log_path>]
|
||||
```
|
||||
|
||||
If `bun` is missing, `npx -y bun <WRAPPER>/main.ts ...` works as a fallback.
|
||||
|
||||
## Parameter notes
|
||||
|
||||
- **All filesystem inputs** are auto-resolved against `process.cwd()` when relative, but agents should pass absolute paths to be robust against cwd drift.
|
||||
- **`--timeout`** defaults to `300000` (5 min) per `codex exec` attempt. Raise (e.g. `--timeout 600000` for 10 min) on slow networks or large prompts.
|
||||
- **`--cache-dir`** is off by default. Enable for repeatable runs to skip redundant generations of the same prompt+aspect+refs.
|
||||
- **Authentication**: the wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` is read or sent.
|
||||
|
||||
## Stdout contract
|
||||
|
||||
Single JSON line:
|
||||
|
||||
- Success: `{"status":"ok","path":"...","bytes":N,"elapsed_seconds":N,"thread_id":"...","attempts":N,"cached":bool,...}`
|
||||
- Failure: `{"status":"error","path":"...","bytes":0,"error":"...","error_kind":"..."}`
|
||||
|
||||
`error_kind` values: `codex_not_installed`, `invalid_args`, `prompt_file_missing`, `spawn_failed`, `timeout`, `no_image_gen_tool_use`, `output_missing`, `invalid_png`, `agent_refused`, `lock_busy`.
|
||||
|
||||
On retryable errors (timeout, spawn_failed, no_image_gen_tool_use, output_missing, invalid_png, agent_refused), ask the user whether to retry or fall back to another backend.
|
||||
|
||||
## Batch semantics
|
||||
|
||||
- Codex `image_gen` returns **one image per call** (`n=1` only). Multi-image jobs must dispatch one wrapper call per image.
|
||||
- The wrapper does NOT accept a `--sessionId` flag. Chain/scene consistency must come from `--ref` reference images.
|
||||
- `--size` and `--quality` are silently ignored — Codex picks pixel dimensions from `--aspect`.
|
||||
@@ -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
|
||||
@@ -27,7 +27,7 @@ Concrete `AskUserQuestion` references below are examples — substitute the loca
|
||||
|
||||
## Script Directory
|
||||
|
||||
`{baseDir}` = this SKILL.md's directory. Main script: `{baseDir}/scripts/main.ts`. Resolve `${BUN_X}`: prefer `bun`; else `npx -y bun`; else suggest `brew install oven-sh/bun/bun`.
|
||||
`{baseDir}` = this SKILL.md's directory. All `scripts/...` paths below are relative to `{baseDir}`. Main script: `{baseDir}/scripts/main.ts`. Batch payload helper: `{baseDir}/scripts/build-batch.ts`. Resolve `${BUN_X}`: prefer `bun`; else `npx -y bun`; else suggest `brew install oven-sh/bun/bun`.
|
||||
|
||||
## Step 0: Load Preferences ⛔ BLOCKING
|
||||
|
||||
@@ -81,8 +81,15 @@ ${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
|
||||
|
||||
# Build a batch file from outline.md + prompts/ (e.g. baoyu-article-illustrator output)
|
||||
${BUN_X} {baseDir}/scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
|
||||
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4
|
||||
```
|
||||
|
||||
## Reference-Image Identity Preservation
|
||||
@@ -104,7 +111,7 @@ When the user wants a person/object preserved from reference images:
|
||||
| `--image <path>` | Output image path (required in single-image mode) |
|
||||
| `--batchfile <path>` | JSON batch file for multi-image generation |
|
||||
| `--jobs <count>` | 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 <id>`, `-m` | Model ID — see provider references for defaults and allowed values |
|
||||
| `--ar <ratio>` | Aspect ratio (`16:9`, `1:1`, `4:3`, …) |
|
||||
| `--size <WxH>` | 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 +144,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_<PROVIDER>_CONCURRENCY` | Per-provider concurrency (e.g., `BAOYU_IMAGE_GEN_REPLICATE_CONCURRENCY`) |
|
||||
| `BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY` | Per-provider concurrency (e.g., `BAOYU_IMAGE_GEN_REPLICATE_CONCURRENCY`; for codex-cli use `BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY`) |
|
||||
| `BAOYU_IMAGE_GEN_<PROVIDER>_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 > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||
|
||||
@@ -149,10 +161,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 +206,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
|
||||
|
||||
@@ -232,7 +246,7 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`.
|
||||
| One image, or 1-2 simple images | Sequential | Lower coordination overhead, easier debugging |
|
||||
| Multiple images with saved prompt files | Batch (`--batchfile`) | Reuses finalized prompts, applies shared throttling/retries, predictable throughput |
|
||||
| Each image still needs its own reasoning / prompt writing / style exploration | Subagents | Work is still exploratory, each needs independent analysis |
|
||||
| Input is `outline.md` + `prompts/` (e.g. from `baoyu-article-illustrator`) | Batch — use `scripts/build-batch.ts` to assemble the payload | The outline + prompt files already contain everything needed |
|
||||
| Input is `outline.md` + `prompts/` (e.g. from `baoyu-article-illustrator`) | Batch — use `{baseDir}/scripts/build-batch.ts` to assemble the payload | The outline + prompt files already contain everything needed |
|
||||
|
||||
Rule of thumb: once prompt files are saved and the task is "generate all of these", prefer batch over subagents. Use subagents only when generation is coupled with per-image thinking or divergent creative exploration.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.<provider>.concurrency` | int\|null | provider default | Max simultaneous requests per provider |
|
||||
| `batch.provider_limits.<provider>.start_interval_ms` | int\|null | provider default | Minimum gap between request starts per provider |
|
||||
|
||||
@@ -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 <text>` / `--promptfiles <files>` | Required. Written to a temp file and passed to the wrapper as `--prompt-file`. |
|
||||
| `--image <path>` | Required. Final output PNG location. |
|
||||
| `--ar <ratio>` | Mapped to wrapper's `--aspect`. Supported by Codex: `1:1` (default), `16:9`, `9:16`, `4:3`, `2.35:1`. |
|
||||
| `--ref <files...>` | 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 (<error_kind>): <message>`. 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.
|
||||
@@ -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
|
||||
|
||||
@@ -28,7 +28,8 @@ type PromptReference = {
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`Usage:
|
||||
npx -y tsx scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
|
||||
bun <baseDir>/scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
|
||||
npx -y tsx <baseDir>/scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
|
||||
|
||||
Options:
|
||||
--outline <path> Path to outline.md
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const s = await stat(this.lockPath);
|
||||
return Date.now() - s.mtimeMs > 10 * 60 * 1000;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async release(force = false): Promise<void> {
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {}): Promise<void> {
|
||||
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<string, unknown>) {
|
||||
return this.log("info", event, extra);
|
||||
}
|
||||
warn(event: string, extra?: Record<string, unknown>) {
|
||||
return this.log("warn", event, extra);
|
||||
}
|
||||
error(event: string, extra?: Record<string, unknown>) {
|
||||
return this.log("error", event, extra);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonExtras(extra: Record<string, unknown>): 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(" ");
|
||||
}
|
||||
+326
@@ -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 <output.png> [--prompt <text> | --prompt-file <path>] [options]
|
||||
|
||||
Required:
|
||||
--image <path> Output PNG path
|
||||
--prompt <text> Prompt text (or use --prompt-file)
|
||||
--prompt-file <path> Read prompt from file
|
||||
|
||||
Options:
|
||||
--aspect <ratio> Aspect ratio (1:1, 16:9, 9:16, 4:3, 2.35:1). Default: 1:1
|
||||
--ref <file> Reference image (repeatable)
|
||||
--timeout <ms> Codex exec timeout in ms. Default: 300000
|
||||
--retries <n> Retry attempts on retryable errors. Default: 2
|
||||
--retry-delay <ms> Base retry delay (exponential). Default: 1500
|
||||
--cache-dir <path> Enable idempotency cache. Disabled by default.
|
||||
--log-file <path> 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<string> {
|
||||
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":<file_size_in_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<GenerateResult> {
|
||||
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();
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { CodexRunResult, ToolCall, TokenUsage } from "./types.ts";
|
||||
|
||||
export function parseEventStream(raw: string): Omit<CodexRunResult, "rawLogPath" | "durationMs"> {
|
||||
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<string, ToolCall>();
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -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<CodexRunResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<ErrorKind> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -64,6 +64,7 @@ const DEFAULT_PROVIDER_RATE_LIMITS: Record<Provider, ProviderRateLimit> = {
|
||||
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 <path> Output image path (required in single-image mode)
|
||||
--batchfile <path> JSON batch file for multi-image generation
|
||||
--jobs <count> 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 <id> Model ID
|
||||
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
||||
--size <WxH> 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_<PROVIDER>_CONCURRENCY Override provider concurrency
|
||||
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency (use underscores: BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY)
|
||||
BAOYU_IMAGE_GEN_<PROVIDER>_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 > <cwd>/.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<ExtendConfig> {
|
||||
jimeng: null,
|
||||
seedream: null,
|
||||
azure: null,
|
||||
"codex-cli": null,
|
||||
};
|
||||
currentKey = "default_model";
|
||||
currentProvider = null;
|
||||
@@ -458,7 +466,8 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
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<ExtendConfig> {
|
||||
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<ProviderModule> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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> = {}): 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"] })),
|
||||
);
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
try {
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveWrapperPath(): Promise<string> {
|
||||
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<SpawnResult> {
|
||||
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<Uint8Array> {
|
||||
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 }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-infographic
|
||||
description: Generate professional infographics with 21 layout types and 22 visual styles. Analyzes content, recommends layout×style combinations, and generates publication-ready infographics. Use when user asks to create "infographic", "信息图", "visual summary", "可视化", or "高密度信息大图".
|
||||
version: 1.117.3
|
||||
version: 1.117.4
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-infographic
|
||||
@@ -29,6 +29,7 @@ When this skill needs to render an image, resolve the backend in this order:
|
||||
2. **Saved preference** — if `EXTEND.md` sets `preferred_image_backend` to a backend available right now, use it.
|
||||
3. **Auto-select** (when the preference is `auto`, unset, or the pinned backend isn't available):
|
||||
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-image-gen`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime exposes no native `imagegen` skill but the `codex` CLI is on `PATH` with an active `codex login`, route through `baoyu-image-gen --provider codex-cli` (preferred), or — if baoyu-image-gen is unavailable — invoke the bundled wrapper directly. Details, parameters, and the runtime-discovery procedure live in [references/codex-imagegen.md](references/codex-imagegen.md) — load that file only when this branch is selected.
|
||||
- **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way.
|
||||
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-image-gen`), use it.
|
||||
- Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions.
|
||||
@@ -296,7 +297,8 @@ Combine:
|
||||
2. Ensure the full final prompt is persisted at `prompts/infographic.md` (already written in Step 5) BEFORE invoking the backend — the file is the reproducibility record.
|
||||
3. **Check for existing file**: Before generating, check if `infographic.png` exists
|
||||
- If exists: Rename to `infographic-backup-YYYYMMDD-HHMMSS.png`
|
||||
4. Call the chosen backend with the prompt file and output path
|
||||
4. Call the chosen backend with the prompt file and output path.
|
||||
- **`codex-imagegen` invocation**: when the rule resolves to `codex-imagegen`, see [references/codex-imagegen.md](references/codex-imagegen.md) for the invocation contract (preferred `baoyu-image-gen --provider codex-cli` path, runtime wrapper discovery, parameter notes, stdout schema, batch semantics).
|
||||
5. On failure, auto-retry once
|
||||
|
||||
Text correction policy:
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# `codex-imagegen` Wrapper Invocation
|
||||
|
||||
Load this reference only when the [Image Generation Tools](../SKILL.md#image-generation-tools) rule has resolved to `codex-imagegen` — i.e., the current runtime exposes no native `imagegen` skill but `codex` CLI is on `PATH` with an active `codex login`.
|
||||
|
||||
## Preferred path: route through `baoyu-image-gen`
|
||||
|
||||
If the `baoyu-image-gen` skill is available in this runtime, **always** invoke through it rather than calling the wrapper directly. It handles retry/cache/batch/EXTEND.md preferences uniformly with every other provider.
|
||||
|
||||
```bash
|
||||
${BUN_X} <baoyu-image-gen-base>/scripts/main.ts \
|
||||
--provider codex-cli \
|
||||
--image <ABSOLUTE_output> \
|
||||
--promptfiles <ABSOLUTE_prompts/infographic.md> \
|
||||
--ar <ratio> \
|
||||
[--ref <ABSOLUTE_file>]...
|
||||
```
|
||||
|
||||
Resolve `<baoyu-image-gen-base>` the same way you resolve any sibling skill — through your runtime's skill registry (`Skill` tool, plugin marketplace, or `$HOME/.baoyu-skills/baoyu-image-gen/`).
|
||||
|
||||
## Fallback: spawn the wrapper directly
|
||||
|
||||
Only when `baoyu-image-gen` is NOT installed in the current runtime. Discover the wrapper's location at runtime — do NOT hard-code `../../packages/...` from this skill:
|
||||
|
||||
1. **Honor explicit override**: if `$BAOYU_CODEX_IMAGEGEN_BIN` is set and points to a real file, use that path. It may be `.ts` (spawn `bun <path>`) or `.sh`/binary (spawn directly).
|
||||
2. **Search the plugin root**: walk up from this skill's directory looking for `packages/baoyu-codex-imagegen/src/main.ts`. If found, that is the wrapper. Spawn it with `bun`.
|
||||
3. **Last resort**: tell the user that `codex-imagegen` is not available in this runtime and ask whether to install the `baoyu-skills` plugin (or set `BAOYU_CODEX_IMAGEGEN_BIN`) or pick another backend.
|
||||
|
||||
Once located, the invocation shape is:
|
||||
|
||||
```bash
|
||||
bun <WRAPPER>/main.ts \
|
||||
--image <ABSOLUTE_output> \
|
||||
--prompt-file <ABSOLUTE_prompts/infographic.md> \
|
||||
--aspect <ratio> \
|
||||
[--ref <ABSOLUTE_file>]... \
|
||||
[--timeout <ms>] \
|
||||
[--cache-dir ~/.cache/baoyu-codex-imagegen] \
|
||||
[--log-file <ABSOLUTE_jsonl_log_path>]
|
||||
```
|
||||
|
||||
If `bun` is missing, `npx -y bun <WRAPPER>/main.ts ...` works as a fallback.
|
||||
|
||||
## Parameter notes
|
||||
|
||||
- **All filesystem inputs** are auto-resolved against `process.cwd()` when relative, but agents should pass absolute paths to be robust against cwd drift.
|
||||
- **`--timeout`** defaults to `300000` (5 min) per `codex exec` attempt. Raise (e.g. `--timeout 600000` for 10 min) on slow networks or large prompts.
|
||||
- **`--cache-dir`** is off by default. Enable for repeatable runs to skip redundant generations of the same prompt+aspect+refs.
|
||||
- **Authentication**: the wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` is read or sent.
|
||||
|
||||
## Stdout contract
|
||||
|
||||
Single JSON line:
|
||||
|
||||
- Success: `{"status":"ok","path":"...","bytes":N,"elapsed_seconds":N,"thread_id":"...","attempts":N,"cached":bool,...}`
|
||||
- Failure: `{"status":"error","path":"...","bytes":0,"error":"...","error_kind":"..."}`
|
||||
|
||||
`error_kind` values: `codex_not_installed`, `invalid_args`, `prompt_file_missing`, `spawn_failed`, `timeout`, `no_image_gen_tool_use`, `output_missing`, `invalid_png`, `agent_refused`, `lock_busy`.
|
||||
|
||||
On retryable errors (timeout, spawn_failed, no_image_gen_tool_use, output_missing, invalid_png, agent_refused), ask the user whether to retry or fall back to another backend.
|
||||
|
||||
## Batch semantics
|
||||
|
||||
- Codex `image_gen` returns **one image per call** (`n=1` only). Multi-image jobs must dispatch one wrapper call per image.
|
||||
- The wrapper does NOT accept a `--sessionId` flag. Chain/scene consistency must come from `--ref` reference images.
|
||||
- `--size` and `--quality` are silently ignored — Codex picks pixel dimensions from `--aspect`.
|
||||
@@ -54,3 +54,76 @@ test("CLI forwards wrapper title and package render options", async () => {
|
||||
/<body[^>]*style="[^"]*font-family: Menlo, Monaco, 'Courier New', monospace;[^"]*font-size: 18px/,
|
||||
);
|
||||
});
|
||||
|
||||
test("CLI renders Obsidian wikilink images with alt text and Attachments fallback", async () => {
|
||||
const root = await makeTempDir("baoyu-markdown-to-html-wikilink-cli-");
|
||||
const attachmentsDir = path.join(root, "Attachments");
|
||||
await fs.mkdir(attachmentsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(root, "a.png"), "a", "utf-8");
|
||||
await fs.writeFile(path.join(attachmentsDir, "b.webp"), "b", "utf-8");
|
||||
|
||||
const markdownPath = path.join(root, "article.md");
|
||||
await fs.writeFile(
|
||||
markdownPath,
|
||||
[
|
||||
"## Section",
|
||||
"",
|
||||
"![[a.png]]",
|
||||
"",
|
||||
"![[b.webp|B alt]]",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
SCRIPT_PATH,
|
||||
markdownPath,
|
||||
"--keep-title",
|
||||
],
|
||||
{ cwd: SCRIPT_DIR },
|
||||
);
|
||||
|
||||
const result = JSON.parse(stdout.trim()) as {
|
||||
contentImages: Array<{
|
||||
alt?: string;
|
||||
localPath: string;
|
||||
originalPath: string;
|
||||
placeholder: string;
|
||||
}>;
|
||||
htmlPath: string;
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
result.contentImages.map(({ alt, localPath, originalPath, placeholder }) => ({
|
||||
alt,
|
||||
localPath,
|
||||
originalPath,
|
||||
placeholder,
|
||||
})),
|
||||
[
|
||||
{
|
||||
alt: "",
|
||||
localPath: path.join(root, "a.png"),
|
||||
originalPath: "a.png",
|
||||
placeholder: "MDTOHTMLIMGPH_1",
|
||||
},
|
||||
{
|
||||
alt: "B alt",
|
||||
localPath: path.join(attachmentsDir, "b.webp"),
|
||||
originalPath: "b.webp",
|
||||
placeholder: "MDTOHTMLIMGPH_2",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const html = await fs.readFile(result.htmlPath, "utf-8");
|
||||
assert.match(html, /<img src="a\.png" data-local-path="[^"]+a\.png" alt=""/);
|
||||
assert.match(
|
||||
html,
|
||||
/<img src="b\.webp" data-local-path="[^"]+Attachments[^"]+b\.webp" alt="B alt"/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ interface ImageInfo {
|
||||
placeholder: string;
|
||||
localPath: string;
|
||||
originalPath: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
interface MermaidImageInfo {
|
||||
@@ -58,6 +59,14 @@ type ConvertMarkdownOptions = Partial<Omit<CliOptions, "inputPath">> & {
|
||||
mermaid?: MermaidCliOptions;
|
||||
};
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export async function convertMarkdown(
|
||||
markdownPath: string,
|
||||
options?: ConvertMarkdownOptions,
|
||||
@@ -160,7 +169,12 @@ export async function convertMarkdown(
|
||||
|
||||
let finalContent = fs.readFileSync(finalHtmlPath, "utf-8");
|
||||
for (const image of contentImages) {
|
||||
const imgTag = `<img src="${image.originalPath}" data-local-path="${image.localPath}" style="display: block; width: 100%; margin: 1.5em auto;">`;
|
||||
const altAttr = image.alt !== undefined
|
||||
? ` alt="${escapeHtmlAttribute(image.alt)}"`
|
||||
: "";
|
||||
const imgTag = `<img src="${escapeHtmlAttribute(image.originalPath)}" `
|
||||
+ `data-local-path="${escapeHtmlAttribute(image.localPath)}"${altAttr} `
|
||||
+ `style="display: block; width: 100%; margin: 1.5em auto;">`;
|
||||
finalContent = finalContent.replace(image.placeholder, imgTag);
|
||||
}
|
||||
fs.writeFileSync(finalHtmlPath, finalContent, "utf-8");
|
||||
|
||||
@@ -22,6 +22,7 @@ interface ImageInfo {
|
||||
placeholder: string;
|
||||
localPath: string;
|
||||
originalPath: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
interface ParsedResult {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseMarkdown } from './md-to-html.ts';
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test('parseMarkdown preserves mixed markdown and Obsidian wikilink image order', async (t) => {
|
||||
const root = await makeTempDir('x-md-to-html-wikilinks-');
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const articleDir = path.join(root, 'article');
|
||||
const attachmentsDir = path.join(articleDir, 'Attachments');
|
||||
const tempDir = path.join(root, 'tmp');
|
||||
await fs.mkdir(attachmentsDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(articleDir, 'a.png'), 'a');
|
||||
await fs.writeFile(path.join(articleDir, 'b.jpg'), 'b');
|
||||
await fs.writeFile(path.join(attachmentsDir, 'c.webp'), 'c');
|
||||
|
||||
const markdownPath = path.join(articleDir, 'post.md');
|
||||
await fs.writeFile(
|
||||
markdownPath,
|
||||
[
|
||||
'# Title',
|
||||
'',
|
||||
'![[a.png]]',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'![[c.webp|C alt]]',
|
||||
'',
|
||||
'![[note]]',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await parseMarkdown(markdownPath, { tempDir });
|
||||
|
||||
assert.deepEqual(
|
||||
result.contentImages.map(({ placeholder, originalPath, alt, localPath }) => ({
|
||||
placeholder,
|
||||
originalPath,
|
||||
alt,
|
||||
localPath,
|
||||
})),
|
||||
[
|
||||
{
|
||||
placeholder: 'XIMGPH_1',
|
||||
originalPath: 'a.png',
|
||||
alt: '',
|
||||
localPath: path.join(articleDir, 'a.png'),
|
||||
},
|
||||
{
|
||||
placeholder: 'XIMGPH_2',
|
||||
originalPath: 'b.jpg',
|
||||
alt: 'B alt',
|
||||
localPath: path.join(articleDir, 'b.jpg'),
|
||||
},
|
||||
{
|
||||
placeholder: 'XIMGPH_3',
|
||||
originalPath: 'c.webp',
|
||||
alt: 'C alt',
|
||||
localPath: path.join(attachmentsDir, 'c.webp'),
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.match(result.html, /XIMGPH_1[\s\S]*XIMGPH_2[\s\S]*XIMGPH_3/);
|
||||
assert.match(result.html, /!\[\[note\]\]/);
|
||||
});
|
||||
|
||||
test('parseMarkdown resolves encoded spaces and literal percent image paths', async (t) => {
|
||||
const root = await makeTempDir('baoyu-post-to-x-images-');
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const articlePath = path.join(root, 'article.md');
|
||||
const tempDir = path.join(root, 'tmp');
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(root, 'Pasted image.png'), 'png');
|
||||
await fs.writeFile(path.join(root, '100%.png'), 'png');
|
||||
await fs.writeFile(
|
||||
articlePath,
|
||||
[
|
||||
'# Title',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await parseMarkdown(articlePath, { tempDir });
|
||||
|
||||
assert.equal(result.contentImages[0]?.localPath, path.join(root, 'Pasted image.png'));
|
||||
assert.equal(result.contentImages[1]?.localPath, path.join(root, '100%.png'));
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import https from 'node:https';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import frontMatter from 'front-matter';
|
||||
@@ -15,7 +13,11 @@ import remarkCjkFriendly from 'remark-cjk-friendly';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkStringify from 'remark-stringify';
|
||||
|
||||
import { preprocessMermaidInMarkdown } from 'baoyu-md';
|
||||
import {
|
||||
preprocessMermaidInMarkdown,
|
||||
replaceMarkdownImagesWithPlaceholders,
|
||||
resolveImagePath,
|
||||
} from 'baoyu-md';
|
||||
import { closeRenderer, renderMermaidToPng } from 'baoyu-chrome-cdp/mermaid';
|
||||
|
||||
interface ImageInfo {
|
||||
@@ -23,6 +25,7 @@ interface ImageInfo {
|
||||
localPath: string;
|
||||
originalPath: string;
|
||||
blockIndex: number;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
interface ParsedMarkdown {
|
||||
@@ -109,85 +112,6 @@ function extractTitleFromMarkdown(markdown: string): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function downloadFile(url: string, destPath: string, maxRedirects = 5): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!url.startsWith('https://')) {
|
||||
reject(new Error(`Refusing non-HTTPS download: ${url}`));
|
||||
return;
|
||||
}
|
||||
if (maxRedirects <= 0) {
|
||||
reject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
const file = fs.createWriteStream(destPath);
|
||||
|
||||
const request = https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (response) => {
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
const redirectUrl = response.headers.location;
|
||||
if (redirectUrl) {
|
||||
file.close();
|
||||
fs.unlinkSync(destPath);
|
||||
downloadFile(redirectUrl, destPath, maxRedirects - 1).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close();
|
||||
fs.unlinkSync(destPath);
|
||||
reject(new Error(`Failed to download: ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
response.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', (err) => {
|
||||
file.close();
|
||||
fs.unlink(destPath, () => {});
|
||||
reject(err);
|
||||
});
|
||||
|
||||
request.setTimeout(30000, () => {
|
||||
request.destroy();
|
||||
reject(new Error('Download timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getImageExtension(urlOrPath: string): string {
|
||||
const match = urlOrPath.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i);
|
||||
return match ? match[1]!.toLowerCase() : 'png';
|
||||
}
|
||||
|
||||
async function resolveImagePath(imagePath: string, baseDir: string, tempDir: string): Promise<string> {
|
||||
if (imagePath.startsWith('http://')) {
|
||||
console.error(`[md-to-html] Skipping non-HTTPS image: ${imagePath}`);
|
||||
return '';
|
||||
}
|
||||
if (imagePath.startsWith('https://')) {
|
||||
const hash = createHash('md5').update(imagePath).digest('hex').slice(0, 8);
|
||||
const ext = getImageExtension(imagePath);
|
||||
const localPath = path.join(tempDir, `remote_${hash}.${ext}`);
|
||||
|
||||
if (!fs.existsSync(localPath)) {
|
||||
console.error(`[md-to-html] Downloading: ${imagePath}`);
|
||||
await downloadFile(imagePath, localPath);
|
||||
}
|
||||
return localPath;
|
||||
}
|
||||
|
||||
if (path.isAbsolute(imagePath)) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
return path.resolve(baseDir, imagePath);
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
@@ -197,6 +121,10 @@ function escapeHtml(text: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function highlightCode(code: string, lang: string): string {
|
||||
try {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
@@ -222,7 +150,7 @@ function preprocessCjkMarkdown(markdown: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } {
|
||||
function convertMarkdownToHtml(markdown: string): { html: string; totalBlocks: number } {
|
||||
const preprocessedMarkdown = preprocessCjkMarkdown(markdown);
|
||||
const blockTokens = Lexer.lex(preprocessedMarkdown, { gfm: true, breaks: true });
|
||||
|
||||
@@ -254,7 +182,7 @@ function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, al
|
||||
|
||||
image({ href, text }: Tokens.Image): string {
|
||||
if (!href) return '';
|
||||
return imageCallback(href, text ?? '');
|
||||
return escapeHtml(text ?? '');
|
||||
},
|
||||
|
||||
link({ href, title, tokens, text }: Tokens.Link): string {
|
||||
@@ -340,22 +268,20 @@ export async function parseMarkdown(
|
||||
);
|
||||
}
|
||||
|
||||
const images: Array<{ src: string; alt: string; blockIndex: number }> = [];
|
||||
let imageCounter = 0;
|
||||
|
||||
const { html, totalBlocks } = convertMarkdownToHtml(mermaidProcessedBody, (src, alt) => {
|
||||
const placeholder = `XIMGPH_${++imageCounter}`;
|
||||
images.push({ src, alt, blockIndex: -1 });
|
||||
return placeholder;
|
||||
});
|
||||
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
|
||||
mermaidProcessedBody,
|
||||
'XIMGPH_',
|
||||
);
|
||||
const { html, totalBlocks } = convertMarkdownToHtml(rewrittenBody);
|
||||
|
||||
const htmlLines = html.split('\n');
|
||||
const imageBlockIndexes = new Map<string, number>();
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const placeholder = `XIMGPH_${i + 1}`;
|
||||
const placeholder = images[i]!.placeholder;
|
||||
for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) {
|
||||
const regex = new RegExp(`\\b${placeholder}\\b`);
|
||||
const regex = new RegExp(`\\b${escapeRegExp(placeholder)}\\b`);
|
||||
if (regex.test(htmlLines[lineIndex]!)) {
|
||||
images[i]!.blockIndex = lineIndex;
|
||||
imageBlockIndexes.set(placeholder, lineIndex);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -366,17 +292,18 @@ export async function parseMarkdown(
|
||||
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const img = images[i]!;
|
||||
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
|
||||
const localPath = await resolveImagePath(img.originalPath, baseDir, tempDir, 'md-to-html');
|
||||
|
||||
if (i === 0 && !coverImagePath) {
|
||||
firstImageAsCover = localPath;
|
||||
}
|
||||
|
||||
contentImages.push({
|
||||
placeholder: `XIMGPH_${i + 1}`,
|
||||
placeholder: img.placeholder,
|
||||
localPath,
|
||||
originalPath: img.src,
|
||||
blockIndex: img.blockIndex,
|
||||
originalPath: img.originalPath,
|
||||
alt: img.alt,
|
||||
blockIndex: imageBlockIndexes.get(img.placeholder) ?? -1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -384,7 +311,7 @@ export async function parseMarkdown(
|
||||
|
||||
let resolvedCoverImage: string | null = null;
|
||||
if (coverImagePath) {
|
||||
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
|
||||
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir, 'md-to-html');
|
||||
} else if (firstImageAsCover) {
|
||||
resolvedCoverImage = firstImageAsCover;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-slide-deck
|
||||
description: Generates professional slide deck images from content. Creates outlines with style instructions, then generates individual slide images. Use when user asks to "create slides", "make a presentation", "generate deck", "slide deck", or "PPT".
|
||||
version: 1.117.3
|
||||
version: 1.117.4
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-slide-deck
|
||||
@@ -33,6 +33,7 @@ When this skill needs to render an image, resolve the backend in this order:
|
||||
2. **Saved preference** — if `EXTEND.md` sets `preferred_image_backend` to a backend available right now, use it.
|
||||
3. **Auto-select** (when the preference is `auto`, unset, or the pinned backend isn't available):
|
||||
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-image-gen`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime exposes no native `imagegen` skill but the `codex` CLI is on `PATH` with an active `codex login`, route through `baoyu-image-gen --provider codex-cli` (preferred), or — if baoyu-image-gen is unavailable — invoke the bundled wrapper directly. Details, parameters, and the runtime-discovery procedure live in [references/codex-imagegen.md](references/codex-imagegen.md) — load that file only when this branch is selected.
|
||||
- **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way.
|
||||
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-image-gen`), use it.
|
||||
- Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions.
|
||||
@@ -297,6 +298,7 @@ Display the prompts index (`# | Filename | Slide Title`) and ask: proceed / edit
|
||||
### Step 7: Generate Images
|
||||
|
||||
1. Resolve the image backend via the Image Generation Tools rule at the top — ask once if multiple are installed.
|
||||
- **`codex-imagegen` invocation**: when the rule resolves to `codex-imagegen`, see [references/codex-imagegen.md](references/codex-imagegen.md) for the invocation contract (preferred `baoyu-image-gen --provider codex-cli` path, runtime wrapper discovery, parameter notes, stdout schema, batch semantics — n=1 per call so slide batches must dispatch one wrapper call per slide).
|
||||
2. Confirm every `prompts/NN-slide-{slug}.md` exists (hard requirement; prompt files are the reproducibility record regardless of backend).
|
||||
3. Session ID: `slides-{topic-slug}-{timestamp}` — pass to the backend only if it supports sessions.
|
||||
4. Build a task list for selected slides with each slide's prompt file, output PNG path, aspect ratio, session ID, and verified direct references.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# `codex-imagegen` Wrapper Invocation
|
||||
|
||||
Load this reference only when the [Image Generation Tools](../SKILL.md#image-generation-tools) rule has resolved to `codex-imagegen` — i.e., the current runtime exposes no native `imagegen` skill but `codex` CLI is on `PATH` with an active `codex login`.
|
||||
|
||||
## Preferred path: route through `baoyu-image-gen`
|
||||
|
||||
If the `baoyu-image-gen` skill is available in this runtime, **always** invoke through it rather than calling the wrapper directly. It handles retry/cache/batch/EXTEND.md preferences uniformly with every other provider.
|
||||
|
||||
```bash
|
||||
${BUN_X} <baoyu-image-gen-base>/scripts/main.ts \
|
||||
--provider codex-cli \
|
||||
--image <ABSOLUTE_output> \
|
||||
--promptfiles <ABSOLUTE_prompts/NN-slide-{slug}.md> \
|
||||
--ar <ratio> \
|
||||
[--ref <ABSOLUTE_file>]...
|
||||
```
|
||||
|
||||
Resolve `<baoyu-image-gen-base>` the same way you resolve any sibling skill — through your runtime's skill registry (`Skill` tool, plugin marketplace, or `$HOME/.baoyu-skills/baoyu-image-gen/`).
|
||||
|
||||
## Fallback: spawn the wrapper directly
|
||||
|
||||
Only when `baoyu-image-gen` is NOT installed in the current runtime. Discover the wrapper's location at runtime — do NOT hard-code `../../packages/...` from this skill:
|
||||
|
||||
1. **Honor explicit override**: if `$BAOYU_CODEX_IMAGEGEN_BIN` is set and points to a real file, use that path. It may be `.ts` (spawn `bun <path>`) or `.sh`/binary (spawn directly).
|
||||
2. **Search the plugin root**: walk up from this skill's directory looking for `packages/baoyu-codex-imagegen/src/main.ts`. If found, that is the wrapper. Spawn it with `bun`.
|
||||
3. **Last resort**: tell the user that `codex-imagegen` is not available in this runtime and ask whether to install the `baoyu-skills` plugin (or set `BAOYU_CODEX_IMAGEGEN_BIN`) or pick another backend.
|
||||
|
||||
Once located, the invocation shape is:
|
||||
|
||||
```bash
|
||||
bun <WRAPPER>/main.ts \
|
||||
--image <ABSOLUTE_output> \
|
||||
--prompt-file <ABSOLUTE_prompts/NN-slide-{slug}.md> \
|
||||
--aspect <ratio> \
|
||||
[--ref <ABSOLUTE_file>]... \
|
||||
[--timeout <ms>] \
|
||||
[--cache-dir ~/.cache/baoyu-codex-imagegen] \
|
||||
[--log-file <ABSOLUTE_jsonl_log_path>]
|
||||
```
|
||||
|
||||
If `bun` is missing, `npx -y bun <WRAPPER>/main.ts ...` works as a fallback.
|
||||
|
||||
## Parameter notes
|
||||
|
||||
- **All filesystem inputs** are auto-resolved against `process.cwd()` when relative, but agents should pass absolute paths to be robust against cwd drift.
|
||||
- **`--timeout`** defaults to `300000` (5 min) per `codex exec` attempt. Raise (e.g. `--timeout 600000` for 10 min) on slow networks or large prompts.
|
||||
- **`--cache-dir`** is off by default. Enable for repeatable runs to skip redundant generations of the same prompt+aspect+refs.
|
||||
- **Authentication**: the wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` is read or sent.
|
||||
|
||||
## Stdout contract
|
||||
|
||||
Single JSON line:
|
||||
|
||||
- Success: `{"status":"ok","path":"...","bytes":N,"elapsed_seconds":N,"thread_id":"...","attempts":N,"cached":bool,...}`
|
||||
- Failure: `{"status":"error","path":"...","bytes":0,"error":"...","error_kind":"..."}`
|
||||
|
||||
`error_kind` values: `codex_not_installed`, `invalid_args`, `prompt_file_missing`, `spawn_failed`, `timeout`, `no_image_gen_tool_use`, `output_missing`, `invalid_png`, `agent_refused`, `lock_busy`.
|
||||
|
||||
On retryable errors (timeout, spawn_failed, no_image_gen_tool_use, output_missing, invalid_png, agent_refused), ask the user whether to retry or fall back to another backend.
|
||||
|
||||
## Batch semantics
|
||||
|
||||
- Codex `image_gen` returns **one image per call** (`n=1` only). Multi-image jobs must dispatch one wrapper call per image.
|
||||
- The wrapper does NOT accept a `--sessionId` flag. Chain/scene consistency must come from `--ref` reference images.
|
||||
- `--size` and `--quality` are silently ignored — Codex picks pixel dimensions from `--aspect`.
|
||||
@@ -31,3 +31,9 @@ default_version: normal
|
||||
# resolves to `{project_root}/wechat`. Useful if you want a shared archive
|
||||
# outside the current project.
|
||||
# data_root: ~/Documents/wechat-digests
|
||||
|
||||
# OPTIONAL — 触发「🤖 @bot 答疑」小节的名字(逗号分隔)。消息含 @<别名> 即视为
|
||||
# 冲总结 bot 提的问题/请求,会在每版简报里专门答复。
|
||||
# 请选用群里【不存在】的人物/机器人名,以免与真人 @ 混淆。
|
||||
# Default: bot, 精华bot
|
||||
# bot_aliases: bot, 精华bot
|
||||
|
||||
@@ -73,6 +73,7 @@ EXTEND.md is plain text with `key: value` or `key=value` lines, `#` for comments
|
||||
| `default_version` | `normal` / `roast` / `both` | `normal` | Which version(s) to generate when the user doesn't say otherwise. |
|
||||
| `default_time_range` | string (e.g. `7d`, `24h`, `1d`) | (none) | Default range when the user omits time and there's no incremental anchor. |
|
||||
| `data_root` | path | `{project_root}/wechat` | Override where digest folders live. |
|
||||
| `bot_aliases` | comma-separated strings | `bot, 精华bot` | Names that trigger the 「@bot 答疑」 section. A message containing `@<alias>` (case-insensitive) is treated as a question/request aimed at the digest bot. Pick names that do NOT match any real group member or existing bot, to avoid ambiguity. |
|
||||
|
||||
A starter template lives at [EXTEND.md.example](EXTEND.md.example).
|
||||
|
||||
@@ -239,6 +240,26 @@ If a match is found:
|
||||
|
||||
This is a heuristic — when uncertain (multiple matches, malformed title), default to `history.json` and tell the user what was skipped.
|
||||
|
||||
### Step 3.9: Detect @bot requests (if any)
|
||||
|
||||
Some group members address the digest bot directly — e.g. `@bot 帮我把昨天的讨论捋一下` or `@精华bot 这个链接讲了啥`. Catch these so each digest can answer them in a dedicated section instead of dropping them as noise.
|
||||
|
||||
**Trigger**: a message whose text contains `@<alias>` for any alias in `bot_aliases` (from EXTEND.md; default `bot`, `精华bot`; case-insensitive). Aliases are stored as bare names — match the `@` prefix plus the alias.
|
||||
|
||||
**Extract** into an internal worklist `== @bot 请求清单 ==` (working memory only — never written to the final digest):
|
||||
|
||||
- Asker's real name — after Step 3.6 resolution; substitute `self_display` for the `self_wxid` user.
|
||||
- Request body — the text after stripping the `@<alias>` prefix. If the message is a reply (per Step 3.5's quote/reply fields), include the quoted message as context.
|
||||
- Anchor `local_id` for back-reference.
|
||||
|
||||
**Misfire filtering**: if a real member's nickname happens to equal an alias, judge by context. Keep only messages genuinely aimed at the digest bot (a question or request for it); skip clear person-to-person talk — a reply to that real person, or banter teasing them. (Choosing a `bot_aliases` value no real member uses avoids this at the source; the filter is a backstop.) Pure greetings/banter (`@bot 在吗`) may be kept with a brief reply.
|
||||
|
||||
**Answer-source constraint** (honored when rendering the section per [references/output-formats.md](references/output-formats.md)): answer from the group chat context plus your own knowledge only — **no web access**. For any request needing real-time or external information you can't verify, say so honestly (`这个我查不到实时数据,需要联网确认`) rather than fabricating.
|
||||
|
||||
**No hits** → both versions omit the @bot 答疑 section entirely.
|
||||
|
||||
Do this in the same read-through as Round 1's skeleton (via its `== @bot 请求清单 ==` block) so the messages aren't scanned twice.
|
||||
|
||||
Generate the digest in three rounds so nothing slips through. The methodology stays here in SKILL.md; the content/style rules live in [references/output-formats.md](references/output-formats.md) — read that file in Round 2 before drafting.
|
||||
|
||||
#### Round 1 — Build the skeleton
|
||||
@@ -258,6 +279,10 @@ Internal working format (not written to the final file):
|
||||
|
||||
== 发言统计 ==
|
||||
1. XXX — N 条 2. YYY — N 条 ...
|
||||
|
||||
== @bot 请求清单(如有)==
|
||||
1. {提问者真名}(锚点 id:54080)— {去掉 @别名的请求正文}(reply 时附被回复内容)
|
||||
(本期无 @bot 请求则写「无」)
|
||||
```
|
||||
|
||||
Topic principles:
|
||||
|
||||
@@ -17,6 +17,7 @@ Both versions share the same overall layout and writing rules; the differences a
|
||||
[群友画像 — one entry per active user (3+ msgs)]
|
||||
[Categorized body — 3-6 self-named sections per day]
|
||||
[Optional pain-point section]
|
||||
[Optional @bot Q&A section]
|
||||
[Fixed footer]
|
||||
```
|
||||
|
||||
@@ -116,7 +117,23 @@ Example:
|
||||
```
|
||||
- Skip the section entirely if there are no genuine pain points — don't pad with trivial questions.
|
||||
|
||||
### 1.8 Footer
|
||||
### 1.8 @bot 答疑 section (optional)
|
||||
|
||||
- 仅当 SKILL.md Step 3.9 本批捕获到至少一条真实 @bot 请求时出现;否则整段省略。
|
||||
- Heading: `🤖 @bot 答疑`
|
||||
- 一条请求一个条目(• 请求行 + 缩进的 🤖 答复行)。多人问同一件事合并成一答。
|
||||
- **请求行措辞自由发挥**:点出提问者真名 + 自然转述其请求即可,别套「X 问:」这类固定句式。
|
||||
- 语气:真诚、热心、有用的助手——与普通版整体一致。答复落地、给具体建议,别空泛。
|
||||
- 来源:仅群聊上下文 + 自有知识,不联网。需实时/外部数据又无法核实的,如实说明(`这个我查不到实时数据,需要联网确认`),不编造。
|
||||
- Format(遵守 §3:不用 markdown、列表用 •、标题一个 emoji):
|
||||
```
|
||||
🤖 @bot 答疑
|
||||
|
||||
• {提问者 + 自然转述的请求}
|
||||
🤖 {真诚、简洁、有用的回答;查不到实时信息就如实说明}
|
||||
```
|
||||
|
||||
### 1.9 Footer
|
||||
|
||||
Fixed line, last in file:
|
||||
|
||||
@@ -130,7 +147,7 @@ No date, no signature, no version number.
|
||||
|
||||
## 2. Roast version (毒舌版)
|
||||
|
||||
Roast 版基于普通版的话题骨架和素材,用毒舌、尖锐、挑衅的风格重写。整体结构与普通版相同(统计区块、开头概览、群友画像、正文分类、结尾),但风格完全不同。痛点部分省略。仅当 `include_roast=true` 时生成。标题加 "毒舌版" 后缀。
|
||||
Roast 版基于普通版的话题骨架和素材,用毒舌、尖锐、挑衅的风格重写。整体结构与普通版相同(统计区块、开头概览、群友画像、正文分类、@bot 答疑(毒舌值班版,如有)、结尾),但风格完全不同。痛点部分省略。仅当 `include_roast=true` 时生成。标题加 "毒舌版" 后缀。
|
||||
|
||||
风格要求:
|
||||
- 你是一位以尖锐和挑衅风格著称的专业评论员
|
||||
@@ -140,6 +157,14 @@ Roast 版基于普通版的话题骨架和素材,用毒舌、尖锐、挑衅
|
||||
- 开头概览用更戏谑的口吻,突出荒诞和讽刺
|
||||
- 正文话题标题可以改得更损
|
||||
- 引用原话时配上辛辣点评
|
||||
- @bot 答疑改为「毒舌值班版」(本批有 @bot 请求时才出现,见 SKILL.md Step 3.9,放结尾前;无则省略):照样把干货答出来,但裹上调侃、嘴硬、吐槽提问者的口吻,与 roast 整体一致;来源同样只用群聊上下文 + 自有知识、不联网,查不到就嘴硬地承认查不到;同守下方红线。请求行措辞自由发挥,用调侃口吻点出提问者和请求即可,别套「又来了」这类固定句式。标题如 `🤖 bot 答疑(毒舌值班版)`,结构示意:
|
||||
|
||||
```
|
||||
🤖 bot 答疑(毒舌值班版)
|
||||
|
||||
• {提问者 + 请求,调侃口吻}
|
||||
🤖 {带刺但仍有实质内容的回答}
|
||||
```
|
||||
- 结尾改为:本简报由一个没有感情的 AI 自动生成,如有冒犯,概不负责
|
||||
|
||||
注意:毒舌但不恶毒,调侃但不人身攻击。目标是让群友看了会笑,而不是生气。具体红线:
|
||||
@@ -227,6 +252,11 @@ When you forget the structure mid-write, this is the skeleton:
|
||||
状态: ⚠️ 部分解决
|
||||
方案: {若有}
|
||||
|
||||
🤖 @bot 答疑(可选,没有就不写)
|
||||
|
||||
• {提问者 + 请求,自然转述}
|
||||
🤖 {真诚有用的回答}
|
||||
|
||||
本简报由 AI 自动生成
|
||||
```
|
||||
|
||||
@@ -252,6 +282,11 @@ When you forget the structure mid-write, this is the skeleton:
|
||||
|
||||
{保留真实引用的毒舌叙述}
|
||||
|
||||
🤖 bot 答疑(毒舌值班版,可选)
|
||||
|
||||
• {提问者 + 请求,调侃口吻}
|
||||
🤖 {带刺但仍有实质的回答}
|
||||
|
||||
本简报由一个没有感情的 AI 自动生成,如有冒犯,概不负责
|
||||
```
|
||||
|
||||
@@ -271,3 +306,4 @@ Before writing the digest file, mentally walk through:
|
||||
8. No markdown bold/heading/link syntax leaked through?
|
||||
9. (Roast only) Every roast bullet would pass the §2 红线 audit?
|
||||
10. Footer line exact match?
|
||||
11. (本批有 @bot 请求时)两版各有对应 @bot 答疑小节?普通版真诚有用、毒舌版带刺仍有干货?无编造的实时信息?
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-xhs-images
|
||||
description: Generates infographic image card series with 12 visual styles, 8 layouts, and 3 color palettes. Breaks content into 1-10 cartoon-style image cards optimized for social media engagement. Use when user mentions "小红书图片", "小红书种草", "小绿书", "微信图文", "微信贴图", "image cards", "图片卡片", baoyu-xhs-images, or wants social media infographic series.
|
||||
version: 2.0.0
|
||||
version: 2.0.1
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-xhs-images
|
||||
@@ -29,6 +29,7 @@ When this skill needs to render an image, resolve the backend in this order:
|
||||
2. **Saved preference** — if `EXTEND.md` sets `preferred_image_backend` to a backend available right now, use it.
|
||||
3. **Auto-select** (when the preference is `auto`, unset, or the pinned backend isn't available):
|
||||
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-image-gen`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime exposes no native `imagegen` skill but the `codex` CLI is on `PATH` with an active `codex login`, route through `baoyu-image-gen --provider codex-cli` (preferred), or — if baoyu-image-gen is unavailable — invoke the bundled wrapper directly. Details, parameters, and the runtime-discovery procedure live in [references/codex-imagegen.md](references/codex-imagegen.md) — load that file only when this branch is selected.
|
||||
- **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way.
|
||||
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-image-gen`), use it.
|
||||
- Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions.
|
||||
@@ -387,6 +388,8 @@ See `references/config/watermark-guide.md`.
|
||||
|
||||
**Backend selection**: per the Image Generation Tools rule at the top — use whatever is available, ask once if multiple, before any generation. Under `--yes`, use the EXTEND.md preference and fall back to the first available backend. Prompt files MUST exist before invoking any backend.
|
||||
|
||||
**`codex-imagegen` invocation**: when the rule resolves to `codex-imagegen`, see [references/codex-imagegen.md](references/codex-imagegen.md) for the invocation contract (preferred `baoyu-image-gen --provider codex-cli` path, runtime wrapper discovery, parameter notes, stdout schema, batch semantics — n=1 per call so card batches must dispatch one wrapper call per card; the wrapper does NOT accept `--sessionId`, so chain consistency must come from `--ref` per Step 3 above).
|
||||
|
||||
**Session ID** (if the backend supports `--sessionId`): use `cards-{topic-slug}-{timestamp}` for every image; combined with the ref chain this gives maximum consistency.
|
||||
|
||||
### Step 4: Completion Report
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# `codex-imagegen` Wrapper Invocation
|
||||
|
||||
Load this reference only when the [Image Generation Tools](../SKILL.md#image-generation-tools) rule has resolved to `codex-imagegen` — i.e., the current runtime exposes no native `imagegen` skill but `codex` CLI is on `PATH` with an active `codex login`.
|
||||
|
||||
## Preferred path: route through `baoyu-image-gen`
|
||||
|
||||
If the `baoyu-image-gen` skill is available in this runtime, **always** invoke through it rather than calling the wrapper directly. It handles retry/cache/batch/EXTEND.md preferences uniformly with every other provider.
|
||||
|
||||
```bash
|
||||
${BUN_X} <baoyu-image-gen-base>/scripts/main.ts \
|
||||
--provider codex-cli \
|
||||
--image <ABSOLUTE_output> \
|
||||
--promptfiles <ABSOLUTE_prompts/NN-{type}-[slug].md> \
|
||||
--ar <ratio> \
|
||||
[--ref <ABSOLUTE_file>]...
|
||||
```
|
||||
|
||||
Resolve `<baoyu-image-gen-base>` the same way you resolve any sibling skill — through your runtime's skill registry (`Skill` tool, plugin marketplace, or `$HOME/.baoyu-skills/baoyu-image-gen/`).
|
||||
|
||||
## Fallback: spawn the wrapper directly
|
||||
|
||||
Only when `baoyu-image-gen` is NOT installed in the current runtime. Discover the wrapper's location at runtime — do NOT hard-code `../../packages/...` from this skill:
|
||||
|
||||
1. **Honor explicit override**: if `$BAOYU_CODEX_IMAGEGEN_BIN` is set and points to a real file, use that path. It may be `.ts` (spawn `bun <path>`) or `.sh`/binary (spawn directly).
|
||||
2. **Search the plugin root**: walk up from this skill's directory looking for `packages/baoyu-codex-imagegen/src/main.ts`. If found, that is the wrapper. Spawn it with `bun`.
|
||||
3. **Last resort**: tell the user that `codex-imagegen` is not available in this runtime and ask whether to install the `baoyu-skills` plugin (or set `BAOYU_CODEX_IMAGEGEN_BIN`) or pick another backend.
|
||||
|
||||
Once located, the invocation shape is:
|
||||
|
||||
```bash
|
||||
bun <WRAPPER>/main.ts \
|
||||
--image <ABSOLUTE_output> \
|
||||
--prompt-file <ABSOLUTE_prompts/NN-{type}-[slug].md> \
|
||||
--aspect <ratio> \
|
||||
[--ref <ABSOLUTE_file>]... \
|
||||
[--timeout <ms>] \
|
||||
[--cache-dir ~/.cache/baoyu-codex-imagegen] \
|
||||
[--log-file <ABSOLUTE_jsonl_log_path>]
|
||||
```
|
||||
|
||||
If `bun` is missing, `npx -y bun <WRAPPER>/main.ts ...` works as a fallback.
|
||||
|
||||
## Parameter notes
|
||||
|
||||
- **All filesystem inputs** are auto-resolved against `process.cwd()` when relative, but agents should pass absolute paths to be robust against cwd drift.
|
||||
- **`--timeout`** defaults to `300000` (5 min) per `codex exec` attempt. Raise (e.g. `--timeout 600000` for 10 min) on slow networks or large prompts.
|
||||
- **`--cache-dir`** is off by default. Enable for repeatable runs to skip redundant generations of the same prompt+aspect+refs.
|
||||
- **Authentication**: the wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` is read or sent.
|
||||
|
||||
## Stdout contract
|
||||
|
||||
Single JSON line:
|
||||
|
||||
- Success: `{"status":"ok","path":"...","bytes":N,"elapsed_seconds":N,"thread_id":"...","attempts":N,"cached":bool,...}`
|
||||
- Failure: `{"status":"error","path":"...","bytes":0,"error":"...","error_kind":"..."}`
|
||||
|
||||
`error_kind` values: `codex_not_installed`, `invalid_args`, `prompt_file_missing`, `spawn_failed`, `timeout`, `no_image_gen_tool_use`, `output_missing`, `invalid_png`, `agent_refused`, `lock_busy`.
|
||||
|
||||
On retryable errors (timeout, spawn_failed, no_image_gen_tool_use, output_missing, invalid_png, agent_refused), ask the user whether to retry or fall back to another backend.
|
||||
|
||||
## Batch semantics
|
||||
|
||||
- Codex `image_gen` returns **one image per call** (`n=1` only). Multi-image jobs must dispatch one wrapper call per image.
|
||||
- The wrapper does NOT accept a `--sessionId` flag. Chain/scene consistency must come from `--ref` reference images.
|
||||
- `--size` and `--quality` are silently ignored — Codex picks pixel dimensions from `--aspect`.
|
||||
Reference in New Issue
Block a user