Compare commits

..

44 Commits

Author SHA1 Message Date
Jim Liu 宝玉 b0ac5233cc docs(image-generation): document Cursor GenerateImage backend 2026-06-12 23:56:34 -05:00
Jim Liu 宝玉 c1f96f8421 chore: release v2.5.0 2026-06-12 20:29:18 -05:00
Jim Liu 宝玉 4d2322fd02 feat(baoyu-wechat-summary): add per-group fact memory (memory.md) 2026-06-12 20:28:46 -05:00
Jim Liu 宝玉 154d0d1f52 Merge pull request #179 from sandypoli-boop/fix/windows-bun-mkdir-eexist
fix(baoyu-image-gen): tolerate Bun-on-Windows EEXIST from mkdir(recursive)
2026-06-12 19:45:52 -05:00
sandy 7a956f6e7d fix(baoyu-image-gen): tolerate Bun-on-Windows EEXIST from mkdir(recursive)
On Windows, Bun throws EEXIST for mkdir(dir, { recursive: true }) when the
directory already exists, contradicting Node's documented contract (it should
resolve silently). Image generation then succeeds but the file save fails
whenever the output directory already exists (e.g. the Desktop):

    EEXIST: file already exists, mkdir 'C:\Users\...\Desktop'

Add an ensureDir() helper that tolerates EEXIST only when the path really is a
directory (rethrowing otherwise, so a genuine EEXIST against an existing file
is not swallowed), and route writeImage() and migrateLegacyExtendConfig()
through it. Covered by a cross-platform unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:13:22 +08:00
Jim Liu 宝玉 55223daf5c Merge pull request #176 from Osamaali313/fix/youtube-embed-url
fix(baoyu-fetch): parse YouTube /embed/ URLs in parseYouTubeVideoId
2026-06-10 17:56:23 -05:00
Syed Osama Ali Shah a1c4b732c5 fix(baoyu-fetch): parse YouTube /embed/ URLs in parseYouTubeVideoId
parseYouTubeVideoId handled watch, youtu.be, /shorts/ and /live/ URLs but
not the common /embed/<id> player form, so embed links returned null and the
YouTube adapter treated them as "no document". Add an /embed/ branch
mirroring the existing /shorts/ and /live/ handling, with a regression test.
2026-06-09 23:55:40 +03:00
Jim Liu 宝玉 894008c7f6 Promote baoyu-design in README skill listings 2026-06-09 01:47:53 -05:00
Jim Liu 宝玉 9daa6f5db3 Merge pull request #174 from Davidlaizz/custom/main
feat(baoyu-image-gen): add Agnes AI image generation provider
2026-06-08 23:32:34 -05:00
Davidlaizz 53aa30bbca fix(baoyu-image-gen): remove gcd from resolveSize to fix decimal aspect ratio distortion 2026-06-06 05:46:38 +08:00
Davidlaizz 3f1120e903 fix(baoyu-image-gen): add agnes to provider_limits YAML whitelist 2026-06-06 05:18:27 +08:00
Davidlaizz 7ea2692acd fix(baoyu-image-gen): add agnes to default_model YAML whitelist and remote ref allowlist 2026-06-06 05:15:26 +08:00
Davidlaizz 591614cfa5 docs(baoyu-image-gen): add Agnes to SKILL.md, README, and config docs 2026-06-06 04:56:19 +08:00
Davidlaizz ad7a7a646d feat(baoyu-image-gen): add Agnes AI image generation provider 2026-06-06 04:53:00 +08:00
Jim Liu 宝玉 ce84174bf7 Merge pull request #173 from yanghaod2278827/improve-skill-descriptions
docs: improve skill description for better trigger accuracy
2026-06-03 12:43:13 -05:00
yanghaod2278827 06e84b92c3 docs: improve skill description for better trigger accuracy 2026-06-03 11:30:52 +08:00
Jim Liu 宝玉 67fa5cd329 docs: add ebook link and fix WeChat config spacing 2026-06-02 17:01:56 -05:00
Jim Liu 宝玉 3907281f48 docs: add book info to Chinese README 2026-06-02 11:01:23 -05:00
Jim Liu 宝玉 011406036c fix: bump baoyu package dependencies 2026-06-01 23:21:05 -05:00
Jim Liu 宝玉 e6f4cd8a46 Merge pull request #171 from NTLx/feat/content-source-url
feat(baoyu-post-to-wechat): add content_source_url support for original article link
2026-05-29 23:01:42 -05:00
Jim Liu 宝玉 a80eec7d75 Merge pull request #170 from hypn4/feat/google-ga-image-models
feat(baoyu-image-gen): migrate Google image generation to GA Gemini endpoints
2026-05-29 23:01:12 -05:00
Jim Liu 宝玉 ec704c8afd chore: release v2.4.0 2026-05-29 18:59:31 -05:00
Jim Liu 宝玉 a85c81e8db feat(baoyu-wechat-summary): add @bot Q&A section to normal and roast digests 2026-05-29 18:59:26 -05:00
NTLx f06a9021a0 feat(baoyu-post-to-wechat): add content_source_url support for "阅读原文" link
Add the ability to specify the original article URL ("阅读原文" / "Read
Original" link) when publishing to WeChat Official Account draft via API.

Changes:
- ArticleOptions interface: add contentSourceUrl optional field
- publishToDraft: conditionally write content_source_url for news articles
- CLI: add --source-url parameter
- Frontmatter: extract sourceUrl / contentSourceUrl / content_source_url
- Priority chain: CLI --source-url → frontmatter → (none)
- SKILL.md: document the new option and draft/add payload rule

The field is only written for article_type=news per WeChat API spec.
content_source_url is optional (max 1KB) per the official draft/add API.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 13:20:58 +08:00
hypn4 d79ebe4838 feat(baoyu-image-gen): default to GA Gemini image endpoints and sync docs 2026-05-29 12:35:28 +09:00
hypn4 d3d1a9f7cd feat(baoyu-image-gen): allow reference images with GA Gemini image endpoints 2026-05-29 11:44:28 +09:00
Jim Liu 宝玉 77dd193b58 fix: sync npm lockfile 2026-05-27 23:26:46 -05:00
Jim Liu 宝玉 84d817ed52 chore: release v2.3.0 2026-05-27 23:24:29 -05:00
Jim Liu 宝玉 639e0b4193 feat: support Obsidian wikilink images
Support Obsidian image wikilinks alongside standard markdown images, preserve mixed image order, and resolve Obsidian default Attachments/ paths.

Co-authored-by: Chao Zheng <10296164+zcqqq@users.noreply.github.com>
2026-05-27 23:06:56 -05:00
Jim Liu 宝玉 84cefc2784 fix: harden URL-decoded image paths
Co-authored-by: zcqqq <10296164+zcqqq@users.noreply.github.com>
2026-05-27 21:44:22 -05:00
Chao Zheng 876f01ac19 fix(baoyu-md, baoyu-post-to-x): decode URL-encoded image paths
Obsidian creates markdown image links with URL-encoded filenames
(e.g. `Pasted%20image%2020260524.png`) but the actual file has spaces.
Add `decodeURIComponent()` before path resolution in both baoyu-md's
shared `resolveImagePath()` and baoyu-post-to-x's independent version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 21:43:53 -05:00
Jim Liu 宝玉 e1c0ff7c02 chore: release v2.2.1 2026-05-26 00:53:56 -05:00
Jim Liu 宝玉 860dd36bb6 docs(baoyu-image-gen): surface build-batch.ts in Usage and clarify {baseDir} script paths
Add the outline.md + prompts/ → batch.json one-liner to SKILL.md's Usage section so the
build-batch helper is discoverable next to --batchfile, and update build-batch.ts --help
to print the bun / npx-y bun invocations with the {baseDir}/scripts/... layout used by
the rest of the skill.
2026-05-26 00:52:51 -05:00
Jim Liu 宝玉 aab0e28823 chore: release v2.2.0 2026-05-25 00:21:09 -05:00
Jim Liu 宝玉 95cdad2a2a docs(baoyu-article-illustrator,baoyu-comic,baoyu-infographic,baoyu-slide-deck,baoyu-xhs-images): point codex-imagegen flow at baoyu-image-gen --provider codex-cli
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 baoyu-image-gen --provider codex-cli
path, direct-wrapper fallback, parameter notes, stdout schema, batch
semantics).
2026-05-25 00:18:41 -05:00
Jim Liu 宝玉 8838161729 docs(baoyu-cover-image): redirect codex-imagegen invocation through baoyu-image-gen --provider codex-cli
Replace the long inline `scripts/codex-imagegen.sh` invocation block in
SKILL.md with a pointer to references/codex-imagegen.md. The reference
documents the preferred `baoyu-image-gen --provider codex-cli` path and
keeps the direct-wrapper fallback for runtimes without baoyu-image-gen.
2026-05-25 00:18:32 -05:00
Jim Liu 宝玉 49e9c46ca4 feat(baoyu-image-gen): add codex-cli provider wrapping codex-imagegen
Expose Codex CLI's built-in image_gen tool through baoyu-image-gen's
standard CLI + batch flow as a dedicated provider. The provider spawns
the bundled scripts/codex-imagegen/main.ts (synced from
packages/baoyu-codex-imagegen/src/) so the skill remains self-contained
and inherits retry, cache, file-lock, and JSONL logging. No
OPENAI_API_KEY required — uses the user's Codex subscription.

New env vars: BAOYU_CODEX_IMAGEGEN_{BIN,CACHE_DIR,TIMEOUT_MS,RETRIES,LOG_FILE}.
Hyphenated provider names now resolve under-scored env vars (e.g.,
BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY). codex-cli is never auto-selected
— pin via --provider or default_provider in EXTEND.md.
2026-05-25 00:18:27 -05:00
Jim Liu 宝玉 23fac03691 refactor(codex-imagegen): extract backend to packages/baoyu-codex-imagegen workspace
Move the codex-imagegen wrapper out of scripts/ into its own workspace
package alongside baoyu-md, baoyu-chrome-cdp, and baoyu-fetch. Drop the
bash shim — src/main.ts now carries a `#\!/usr/bin/env bun` shebang and
serves as the sole entrypoint. Add scripts/sync-codex-imagegen.sh so
skills/baoyu-image-gen/scripts/codex-imagegen/ can be regenerated from
packages/baoyu-codex-imagegen/src/ for skill self-containment. Update
CLAUDE.md, docs/codex-imagegen-backend.md, and the CI workflow paths
accordingly.
2026-05-25 00:18:16 -05:00
Jim Liu 宝玉 66160abb9f chore: release v2.1.0 2026-05-24 21:27:57 -05:00
Jim Liu 宝玉 b34e19daad feat(baoyu-post-to-wechat,baoyu-post-to-weibo,baoyu-post-to-x): cascade Mermaid PNG preprocessing into publishing pipelines 2026-05-24 21:27:53 -05:00
Jim Liu 宝玉 cda76e20fd feat(baoyu-markdown-to-html): render Mermaid code blocks to PNG via headless Chrome 2026-05-24 21:27:43 -05:00
Jim Liu 宝玉 8d2fd7aae0 feat(baoyu-md): add Mermaid preprocessing and utility modules 2026-05-24 21:27:38 -05:00
Jim Liu 宝玉 f60702cc8e feat(baoyu-chrome-cdp): add Mermaid-to-PNG rendering via CDP 2026-05-24 21:27:33 -05:00
Jim Liu 宝玉 613bee6bb8 feat(build): support multi-entry output in shared package build script 2026-05-24 21:27:28 -05:00
113 changed files with 10576 additions and 10634 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "2.0.1"
"version": "2.5.1"
},
"plugins": [
{
+5 -7
View File
@@ -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
+67
View File
@@ -2,6 +2,73 @@
English | [中文](./CHANGELOG.zh.md)
## 2.5.1 - 2026-06-13
### Documentation
- Image generation skills: document Cursor's native `GenerateImage` backend in the shared backend-selection rule, including prompt-based aspect-ratio guidance, tool-managed output handling, and `reference_image_paths` support.
## 2.5.0 - 2026-06-12
### Features
- `baoyu-wechat-summary`: add per-group fact memory (`memory.md`). Objective facts corrected or confirmed by group members (e.g., the real cause behind an error message, the correct name of a product) persist across digests, so a mistake corrected once is never repeated. Writes are conservative — three-part threshold (specific fact + evidence + unchallenged), strict injection guardrails (statements only, never instructions to the bot; `@bot` is not a whitelist channel), provenance on every entry, and revision/expiry/dedup rules with a 30-entry cap. Memory is shared by the normal and roast versions
- `baoyu-image-gen`: add Agnes AI image generation provider (by @Davidlaizz)
- `baoyu-image-gen`: default to GA Gemini image endpoints and allow reference images with them (by @hypn4)
- `baoyu-post-to-wechat`: add `content_source_url` support for the "阅读原文" link (by @NTLx)
### Fixes
- `baoyu-image-gen`: remove gcd from `resolveSize` so decimal aspect ratios are no longer distorted (by @Davidlaizz)
- `baoyu-fetch`: parse YouTube `/embed/` URLs in `parseYouTubeVideoId` (by @Osamaali313)
- `baoyu-image-gen`: tolerate Bun-on-Windows `EEXIST` errors from `mkdir(recursive)` (by @sandypoli-boop)
### Documentation
- Improve skill descriptions for better trigger accuracy (by @yanghaod2278827)
- README: add book/ebook links and promote `baoyu-design` in skill listings
## 2.4.1 - 2026-06-01
### Fixes
- `baoyu-md` and `baoyu-chrome-cdp`: bump npm package versions to `0.1.1` and refresh consuming skill dependencies/lockfiles so fresh installs pick up the Mermaid exports, bundled `assets/mermaid.min.js`, and Markdown Mermaid preprocessing exports required by `baoyu-markdown-to-html`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-post-to-x`, `baoyu-danger-gemini-web`, and `baoyu-danger-x-to-markdown` ([#172](https://github.com/JimLiu/baoyu-skills/issues/172))
## 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
- `baoyu-markdown-to-html`: render fenced ` ```mermaid ` code blocks to local PNG via shared Chrome (CDP) before the standard image-placeholder pipeline runs. New CLI flags: `--mermaid-theme <default|forest|dark|neutral|base>`, `--mermaid-scale <N>` (default `2` for @2x resolution), `--mermaid-bg <white|transparent|#hex>`, and `--no-mermaid` to disable rendering. Generated images land in `imgs/.mermaid-cache/mermaid-<hash>.png` and are deduplicated/reused across runs by a 12-char SHA-256 over `(code, theme, scale, background, mermaid version)`. The browser-side `<pre class="mermaid">` path is retained as a graceful fallback when Chrome is unavailable or a single block fails to render
- `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-post-to-x`: cascade the same Mermaid → PNG preprocessing into the WeChat / Weibo / X publishing pipelines so diagrams appear as real images in the published posts (previously they fell through as unrendered `<pre>` blocks). Existing `WECHATIMGPH_*` / `WBIMGPH_*` / `XIMGPH_*` placeholder pipelines pick up the generated PNGs unchanged
- `baoyu-md` package: new exports `preprocessMermaidInMarkdown`, `extractMermaidBlocks`, `replaceMermaidBlocks`, `hashMermaidCode`, and `MERMAID_VERSION` (skills inject the render function so the package stays Chrome-free)
- `baoyu-chrome-cdp` package: new `./mermaid` subexport providing `renderMermaidToPng(code, outputPath, options)` backed by a process-singleton CDP connection that reuses the shared Chrome profile and ships the vendored Mermaid 10.9.1 UMD bundle as an asset
## 2.0.1 - 2026-05-24
### Fixes
+67
View File
@@ -2,6 +2,73 @@
[English](./CHANGELOG.md) | 中文
## 2.5.1 - 2026-06-13
### 文档
- 图片生成类 skills:在共享后端选择规则中补充 Cursor 原生 `GenerateImage` 后端说明,包括通过提示词声明宽高比、处理工具托管输出文件,以及使用 `reference_image_paths` 传入参考图。
## 2.5.0 - 2026-06-12
### 新功能
- `baoyu-wechat-summary`:新增群级事实记忆(`memory.md`)。群友指正过、确认过的客观事实(如某个报错提示的真实原因、某产品名的正确写法)跨期生效——上一期摘要说错、被群友指正的说法,后续不再重犯。写入保守:三条门槛全满足才记(针对具体事实 + 有理由或证据 + 无人反驳),严格防注入(只记陈述句事实、不记对 bot 的行为指令,`@bot` 不是白名单通道),每条带出处,并有修订/作废/去重规则和 30 条上限。事实记忆由正常版和毒舌版共用
- `baoyu-image-gen`:新增 Agnes AI 图片生成 provider (by @Davidlaizz)
- `baoyu-image-gen`:默认使用 GA Gemini 图片端点,并支持搭配参考图使用 (by @hypn4)
- `baoyu-post-to-wechat`:支持 `content_source_url`,用于设置"阅读原文"链接 (by @NTLx)
### 修复
- `baoyu-image-gen`:移除 `resolveSize` 中的 gcd 计算,修复小数宽高比被扭曲的问题 (by @Davidlaizz)
- `baoyu-fetch``parseYouTubeVideoId` 支持解析 YouTube `/embed/` 链接 (by @Osamaali313)
- `baoyu-image-gen`:容忍 Windows 上 Bun 的 `mkdir(recursive)` 抛出 `EEXIST` 错误 (by @sandypoli-boop)
### 文档
- 优化各 skill 的 description,提高触发准确度 (by @yanghaod2278827)
- README:添加图书/电子书链接,并在技能列表中突出 `baoyu-design`
## 2.4.1 - 2026-06-01
### 修复
- `baoyu-md``baoyu-chrome-cdp`:将 npm 包版本提升到 `0.1.1`,并刷新依赖它们的 skill 依赖约束与 lockfile,确保全新安装会拿到 Mermaid 子导出、打包的 `assets/mermaid.min.js`,以及 Markdown Mermaid 预处理导出。影响范围包括 `baoyu-markdown-to-html``baoyu-post-to-wechat``baoyu-post-to-weibo``baoyu-post-to-x``baoyu-danger-gemini-web``baoyu-danger-x-to-markdown`[#172](https://github.com/JimLiu/baoyu-skills/issues/172)
## 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
### 新功能
- `baoyu-markdown-to-html`:在标准图片占位符流水线之前,通过共享 Chrome(CDP)把 ` ```mermaid ` 围栏代码块渲染为本地 PNG。新增 CLI 参数 `--mermaid-theme <default|forest|dark|neutral|base>``--mermaid-scale <N>`(默认 `2`,即 @2x 分辨率)、`--mermaid-bg <white|transparent|#hex>`,以及用于关闭渲染的 `--no-mermaid`。生成的图片落在 `imgs/.mermaid-cache/mermaid-<hash>.png`,跨次运行通过对 `(code, theme, scale, background, mermaid 版本)` 取 SHA-256 前 12 位进行去重/复用。Chrome 不可用或单块渲染失败时,保留浏览器侧 `<pre class="mermaid">` 兜底
- `baoyu-post-to-wechat``baoyu-post-to-weibo``baoyu-post-to-x`:在微信 / 微博 / X 发布流水线中同步接入上述 Mermaid → PNG 预处理,Mermaid 图能以真图形式出现在已发布的稿件中(此前会落到未渲染的 `<pre>` 块)。现有的 `WECHATIMGPH_*` / `WBIMGPH_*` / `XIMGPH_*` 占位符流水线无需改动,会直接拾取生成的 PNG
- `baoyu-md` 包:新增导出 `preprocessMermaidInMarkdown``extractMermaidBlocks``replaceMermaidBlocks``hashMermaidCode``MERMAID_VERSION`(渲染函数由 skill 注入,本包不再反向依赖 Chrome)
- `baoyu-chrome-cdp` 包:新增 `./mermaid` 子导出,提供 `renderMermaidToPng(code, outputPath, options)`,底层为进程级单例 CDP 连接,复用共享 Chrome profile,并把 Mermaid 10.9.1 UMD 资产打入包内
## 2.0.1 - 2026-05-24
### 修复
+7 -5
View File
@@ -64,23 +64,25 @@ Skills that prompt users for choices MUST declare the tool-selection convention
## Image Generation Tools
Skills that render images MUST declare the backend-selection convention **inline** in exactly one place per `SKILL.md` — a `## Image Generation Tools` section near the top (after `## User Input Tools`). Do NOT link out to [docs/image-generation-tools.md](docs/image-generation-tools.md); that doc is the author-side canonical source — copy its body into each SKILL.md. Concrete tool names (`imagegen`, `image_generate`, `baoyu-image-gen`) elsewhere in a skill are treated as examples — other runtimes substitute their local equivalent under the rule. The rule is stateless: use whatever backend is available; if multiple, ask the user once; if none, ask how to proceed. Every rendered image's full prompt must be written to a standalone `prompts/NN-*.md` file before any backend is invoked. Backend skills (`baoyu-image-gen`, `baoyu-danger-gemini-web`) are exempt — they render directly rather than selecting a backend.
Skills that render images MUST declare the backend-selection convention **inline** in exactly one place per `SKILL.md` — a `## Image Generation Tools` section near the top (after `## User Input Tools`). Do NOT link out to [docs/image-generation-tools.md](docs/image-generation-tools.md); that doc is the author-side canonical source — copy its body into each SKILL.md. Concrete tool names (`imagegen`, `GenerateImage`, `image_generate`, `baoyu-image-gen`) elsewhere in a skill are treated as examples — other runtimes substitute their local equivalent under the rule. The rule is stateless: use whatever backend is available; if multiple, ask the user once; if none, ask how to proceed. Every rendered image's full prompt must be written to a standalone `prompts/NN-*.md` file before any backend is invoked. Backend skills (`baoyu-image-gen`, `baoyu-danger-gemini-web`) are exempt — they render directly rather than selecting a backend.
### `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
+22 -9
View File
@@ -95,6 +95,18 @@ You can also **Enable auto-update** to get the latest versions automatically.
Skills are organized into three categories:
### Featured Design Skill: baoyu-design
If you want a design-focused Agent Skill, check out [JimLiu/baoyu-design](https://github.com/JimLiu/baoyu-design). It is a separate project that runs Claude Design locally in Cursor, Claude Code, Codex, Claude Desktop, or any file-capable coding agent, producing polished UI mockups, interactive prototypes, wireframes, landing pages, dashboards, mobile apps, and slide decks as self-contained HTML.
<a href="https://github.com/JimLiu/baoyu-design">
<img src="https://raw.githubusercontent.com/JimLiu/baoyu-design/main/assets/screenshots/cursor-reader-mac-app.webp" alt="Cursor running baoyu-design" width="720">
</a>
```bash
npx skills add JimLiu/baoyu-design
```
### Content Skills
Content generation and publishing skills.
@@ -750,7 +762,7 @@ AI SDK-based image generation using OpenAI GPT Image 2, Azure OpenAI, Google, Op
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openrouter
# OpenRouter with reference images
/baoyu-image-gen --prompt "Make it blue" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png
/baoyu-image-gen --prompt "Make it blue" --image out.png --provider openrouter --model google/gemini-3.1-flash-image --ref source.png
# DashScope (Aliyun Tongyi Wanxiang)
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
@@ -797,7 +809,7 @@ AI SDK-based image generation using OpenAI GPT Image 2, Azure OpenAI, Google, Op
| `--image` | Output image path (required) |
| `--batchfile` | JSON batch file for multi-image generation |
| `--jobs` | Worker count for batch mode |
| `--provider` | `google`, `openai`, `azure`, `openrouter`, `dashscope`, `zai`, `minimax`, `jimeng`, `seedream`, or `replicate` |
| `--provider` | `google`, `openai`, `azure`, `openrouter`, `dashscope`, `zai`, `minimax`, `jimeng`, `seedream`, `replicate`, or `agnes` |
| `--model`, `-m` | Model ID or deployment name. Azure uses deployment name; OpenRouter uses full model IDs; Z.AI uses `glm-image`; MiniMax uses `image-01` / `image-01-live` |
| `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
| `--size` | Size (e.g., `1024x1024`; `gpt-image-2` accepts valid custom sizes up to 3840px max edge) |
@@ -827,8 +839,8 @@ AI SDK-based image generation using OpenAI GPT Image 2, Azure OpenAI, Google, Op
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-2` |
| `AZURE_OPENAI_DEPLOYMENT` | Azure default deployment name | - |
| `AZURE_OPENAI_IMAGE_MODEL` | Backward-compatible Azure deployment/model alias | `gpt-image-2` |
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model | `google/gemini-3.1-flash-image-preview` |
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model | `google/gemini-3.1-flash-image` |
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image` |
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `qwen-image-2.0-pro` |
| `ZAI_IMAGE_MODEL` | Z.AI model | `glm-image` |
| `BIGMODEL_IMAGE_MODEL` | Backward-compatible alias for Z.AI model | `glm-image` |
@@ -871,9 +883,9 @@ AI SDK-based image generation using OpenAI GPT Image 2, Azure OpenAI, Google, Op
**Provider Auto-Selection**:
1. If `--provider` is specified → use it
2. If `--ref` is provided and no provider is specified → try Google, then OpenAI, Azure, OpenRouter, Replicate, Seedream, and finally MiniMax
2. If `--ref` is provided and no provider is specified → try Google, then OpenAI, Azure, OpenRouter, Replicate, Seedream, MiniMax, and finally Agnes
3. If only one API key is available → use that provider
4. If multiple providers are available → default to Google, then OpenAI, Azure, OpenRouter, DashScope, Z.AI, MiniMax, Replicate, Jimeng, Seedream
4. If multiple providers are available → default to Google, then OpenAI, Azure, OpenRouter, DashScope, Z.AI, MiniMax, Replicate, Jimeng, Seedream, Agnes
#### baoyu-danger-gemini-web
@@ -1128,7 +1140,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, per-user profiles, and per-group fact memory across runs. Supports normal and roast (毒舌) versions, and answers `@bot` questions raised in the chat.
```bash
# Summarize a group's recent messages
@@ -1151,6 +1163,7 @@ Summarize WeChat group chat highlights into a structured digest. Extracts topics
**Features**:
- Topic extraction with attribution and quotes
- Message leaderboard and per-user profiles
- Per-group fact memory: corrections confirmed in chat persist across digests (with injection guardrails)
- Incremental mode (picks up where last digest left off)
- Multi-day range splitting for large batches
- Normal and roast (毒舌) digest versions
@@ -1225,14 +1238,14 @@ AZURE_OPENAI_DEPLOYMENT=gpt-image-2
# OpenRouter
OPENROUTER_API_KEY=sk-or-xxx
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image-preview
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# OPENROUTER_HTTP_REFERER=https://your-app.example.com
# OPENROUTER_TITLE=Your App Name
# Google
GOOGLE_API_KEY=xxx
GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
GOOGLE_IMAGE_MODEL=gemini-3-pro-image
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta
# DashScope (Aliyun Tongyi Wanxiang)
+34 -11
View File
@@ -4,6 +4,16 @@
宝玉分享的 AI Agent 技能集(适用于 Claude Code、Codex 等),提升日常工作效率。
## 作者的图书
<img width="500" height="500" alt="图解 Skill —— AI 提效实战指南" src="https://github.com/user-attachments/assets/6caef6a2-6f11-490e-a43b-e810df8e9354" />
作者的图书《图解 Skill —— AI 提效实战指南》系统讲解如何设计、编写、安装和迭代 Skill,并配有完整示例、提示词、插图生成工作流和章节配套资源。
- 官方配套仓库:[JimLiu/Illustrated-Agent-Skills](https://github.com/JimLiu/Illustrated-Agent-Skills)
- 购买链接:[京东购买](https://u.jd.com/RDY9YwC)
- 电子书购买链接:https://www.ituring.com.cn/book/3616
## 前置要求
- 已安装 Node.js 环境
@@ -95,6 +105,18 @@ clawhub install baoyu-markdown-to-html
技能分为三大类:
### 设计技能推荐:baoyu-design
如果你想让本地 Agent 直接做设计,可以试试 [JimLiu/baoyu-design](https://github.com/JimLiu/baoyu-design)。这是一个独立项目,它把 Claude Design 打包成可移植的 Agent Skill,可在 Cursor、Claude Code、Codex、Claude Desktop 或其他能读写文件的编码 Agent 中运行,用来生成精致 UI 稿、可交互原型、线框图、落地页、仪表盘、移动 App 和幻灯片,产物都是自包含 HTML,留在你自己的仓库里。
<a href="https://github.com/JimLiu/baoyu-design">
<img src="https://raw.githubusercontent.com/JimLiu/baoyu-design/main/assets/screenshots/cursor-reader-mac-app.webp" alt="Cursor 运行 baoyu-design" width="720">
</a>
```bash
npx skills add JimLiu/baoyu-design
```
### 内容技能 (Content Skills)
内容生成和发布技能。
@@ -658,8 +680,8 @@ accounts:
default_author: 宝玉
need_open_comment: 1
only_fans_can_comment: 0
app_id: 你的微信AppID
app_secret: 你的微信AppSecret
app_id: 你的微信 AppID
app_secret: 你的微信 AppSecret
- name: AI 工具集
alias: ai-tools
default_publish_method: browser
@@ -741,7 +763,7 @@ AI 驱动的生成后端。
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openrouter
# OpenRouter + 参考图
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --provider openrouter --model google/gemini-3.1-flash-image --ref source.png
# DashScope(阿里通义万相)
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
@@ -788,7 +810,7 @@ AI 驱动的生成后端。
| `--image` | 输出图片路径(必需) |
| `--batchfile` | 多图批量生成的 JSON 文件 |
| `--jobs` | 批量模式的并发 worker 数 |
| `--provider` | `google``openai``azure``openrouter``dashscope``zai``minimax``jimeng``seedream``replicate` |
| `--provider` | `google``openai``azure``openrouter``dashscope``zai``minimax``jimeng``seedream``replicate``agnes` |
| `--model`, `-m` | 模型 ID 或部署名。Azure 使用部署名;OpenRouter 使用完整模型 IDZ.AI 使用 `glm-image`MiniMax 使用 `image-01` / `image-01-live` |
| `--ar` | 宽高比(如 `16:9``1:1``4:3` |
| `--size` | 尺寸(如 `1024x1024``gpt-image-2` 支持最长边不超过 3840px 的有效自定义尺寸) |
@@ -818,8 +840,8 @@ AI 驱动的生成后端。
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-2` |
| `AZURE_OPENAI_DEPLOYMENT` | Azure 默认部署名 | - |
| `AZURE_OPENAI_IMAGE_MODEL` | 兼容旧配置的 Azure 部署/模型别名 | `gpt-image-2` |
| `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image-preview` |
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
| `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image` |
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image` |
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `qwen-image-2.0-pro` |
| `ZAI_IMAGE_MODEL` | Z.AI 模型 | `glm-image` |
| `BIGMODEL_IMAGE_MODEL` | Z.AI 模型向后兼容别名 | `glm-image` |
@@ -862,9 +884,9 @@ AI 驱动的生成后端。
**服务商自动选择**
1. 如果指定了 `--provider` → 使用指定的
2. 如果传了 `--ref` 且未指定 provider → 依次尝试 Google、OpenAI、Azure、OpenRouter、Replicate、Seedream,最后是 MiniMax
2. 如果传了 `--ref` 且未指定 provider → 依次尝试 Google、OpenAI、Azure、OpenRouter、Replicate、SeedreamMiniMax,最后是 Agnes
3. 如果只有一个 API 密钥 → 使用对应服务商
4. 如果多个可用 → 默认使用 Google,然后依次为 OpenAI、Azure、OpenRouter、DashScope、Z.AI、MiniMax、Replicate、即梦、豆包
4. 如果多个可用 → 默认使用 Google,然后依次为 OpenAI、Azure、OpenRouter、DashScope、Z.AI、MiniMax、Replicate、即梦、豆包、Agnes
#### baoyu-danger-gemini-web
@@ -1119,7 +1141,7 @@ AI 驱动的生成后端。
#### baoyu-wechat-summary
微信群聊精华提取。使用 [wx-cli](https://github.com/jackwener/wx-cli) 从群消息中提取话题、引言和统计数据,生成结构化简报。支持跨次运行的群聊历史群友画像维护,可生成正常版和毒舌版。
微信群聊精华提取。使用 [wx-cli](https://github.com/jackwener/wx-cli) 从群消息中提取话题、引言和统计数据,生成结构化简报。支持跨次运行的群聊历史群友画像和群级事实记忆维护,可生成正常版和毒舌版,并在简报中回应群里向 `@bot` 提出的问题
```bash
# 总结群最近消息
@@ -1142,6 +1164,7 @@ AI 驱动的生成后端。
**特性**
- 话题提取,带归属和引言
- 发言排行榜和群友画像
- 群级事实记忆:群友指正过的事实跨期生效(内置防注入规则)
- 增量模式(从上次摘要断点继续)
- 大批量消息自动按天分割
- 正常版和毒舌版两种风格
@@ -1216,14 +1239,14 @@ AZURE_OPENAI_DEPLOYMENT=gpt-image-2
# OpenRouter
OPENROUTER_API_KEY=sk-or-xxx
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image-preview
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# OPENROUTER_HTTP_REFERER=https://your-app.example.com
# OPENROUTER_TITLE=你的应用名
# Google
GOOGLE_API_KEY=xxx
GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
GOOGLE_IMAGE_MODEL=gemini-3-pro-image
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta
# DashScope(阿里通义万相)
+40 -24
View File
@@ -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 |
+2 -2
View File
@@ -165,11 +165,11 @@ Standard snippet (copy verbatim):
When this skill needs to render an image:
- **Use whatever image-generation tool or skill is available** in the current runtime — e.g., Codex `imagegen`, Hermes `image_generate`, `baoyu-image-gen`, or any equivalent the user has installed.
- **Use whatever image-generation tool or skill is available** in the current runtime — e.g., Codex `imagegen`, Cursor `GenerateImage`, Hermes `image_generate`, `baoyu-image-gen`, or any equivalent the user has installed.
- **If multiple are available**, ask the user **once** at the start which to use (batch with any other initial questions).
- **If none are available**, tell the user and ask how to proceed.
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
Concrete tool names (`imagegen`, `GenerateImage`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
```
+4 -3
View File
@@ -1,6 +1,6 @@
# Image Generation Tools
Skills in this repo are loaded by multiple agent runtimes (Claude Code, Codex, Hermes, other agents, bare CLI). Each runtime exposes a different image-generation capability — some have a runtime-native tool (Codex `imagegen`, Hermes `image_generate`), others rely on an installed skill (`baoyu-image-gen`, or user-defined). This document defines the canonical **backend-selection rule** every skill that renders images follows so skills stay portable.
Skills in this repo are loaded by multiple agent runtimes (Claude Code, Codex, Cursor, Hermes, other agents, bare CLI). Each runtime exposes a different image-generation capability — some have a runtime-native tool (Codex `imagegen`, Cursor `GenerateImage`, Hermes `image_generate`), others rely on an installed skill (`baoyu-image-gen`, or user-defined). This document defines the canonical **backend-selection rule** every skill that renders images follows so skills stay portable.
## The Rule
@@ -10,6 +10,7 @@ When a skill needs to render an image, resolve the backend in this order:
2. **Saved preference** — if the skill's `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`.
- **Cursor (`GenerateImage`)** — if the runtime exposes a native `GenerateImage` tool, you are running inside Cursor and it outranks any non-native skill the same way Codex `imagegen` does. Two hard caveats: (a) it has no aspect-ratio parameter — state the target aspect ratio / dimensions explicitly in the prompt text passed as `description`; (b) it does not accept an output directory — it saves to a tool-managed location, so after generation copy/move the file to the skill's expected output path (e.g., `outputs/.../NN-xxx.png`). Reference images go in `reference_image_paths`.
- **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.
@@ -27,7 +28,7 @@ Each image-consuming skill's `EXTEND.md` carries a single `preferred_image_backe
|---|---|
| `auto` (default) | Apply the auto-select rule — runtime-native preferred, fall back to only installed backend, ask if multiple non-native. |
| `ask` | Always confirm the backend on every run, even when a runtime-native tool exists. |
| `<backend-id>` (e.g., `codex-imagegen`, `baoyu-image-gen`, `image_generate`) | Pin this backend when available; fall back to `auto` if it isn't. |
| `<backend-id>` (e.g., `codex-imagegen`, `baoyu-image-gen`, `GenerateImage`, `image_generate`) | Pin this backend when available; fall back to `auto` if it isn't. |
The field is **absent-equals-auto**: older `EXTEND.md` files without this field behave exactly as if `preferred_image_backend: auto` were set. No schema version bump is needed to introduce it.
@@ -41,7 +42,7 @@ Each `SKILL.md` that renders images includes **exactly one** `## Image Generatio
Each skill's `references/config/preferences-schema.md` (and its `EXTEND.md` template in `first-time-setup.md`) lists `preferred_image_backend` alongside other preference fields. First-time setup does NOT ask the user about the backend — `auto` is set silently. Users who want to pin a specific backend edit `EXTEND.md` later, and each skill's `## Changing Preferences` section documents the common one-line edits.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-image-gen`) in this document and in SKILL.md are **examples** — agents in other runtimes apply the rule above and substitute the local equivalent. Skill-specific parameters for these backends are illustrative; runtimes without those knobs can omit them.
Concrete tool names (`imagegen`, `GenerateImage`, `image_generate`, `baoyu-image-gen`) in this document and in SKILL.md are **examples** — agents in other runtimes apply the rule above and substitute the local equivalent. Skill-specific parameters for these backends are illustrative; runtimes without those knobs can omit them.
## Backend Skills Are Exempt
+14 -1
View File
@@ -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
@@ -5013,7 +5017,16 @@
}
},
"packages/baoyu-chrome-cdp": {
"version": "0.1.1",
"engines": {
"bun": ">=1.2.0"
}
},
"packages/baoyu-codex-imagegen": {
"version": "0.1.0",
"bin": {
"codex-imagegen": "src/main.ts"
},
"engines": {
"bun": ">=1.2.0"
}
@@ -5047,7 +5060,7 @@
}
},
"packages/baoyu-md": {
"version": "0.1.0",
"version": "0.1.1",
"dependencies": {
"fflate": "^0.8.2",
"front-matter": "^4.0.2",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+729
View File
@@ -0,0 +1,729 @@
var __create = Object.create;
var __getProtoOf = Object.getPrototypeOf;
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __hasOwnProp = Object.prototype.hasOwnProperty;
function __accessProp(key) {
return this[key];
}
var __toESMCache_node;
var __toESMCache_esm;
var __toESM = (mod, isNodeMode, target) => {
var canCache = mod != null && typeof mod === "object";
if (canCache) {
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
var cached = cache.get(mod);
if (cached)
return cached;
}
target = mod != null ? __create(__getProtoOf(mod)) : {};
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
for (let key of __getOwnPropNames(mod))
if (!__hasOwnProp.call(to, key))
__defProp(to, key, {
get: __accessProp.bind(mod, key),
enumerable: true
});
if (canCache)
cache.set(mod, to);
return to;
};
var __toCommonJS = (from) => {
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
if (entry)
return entry;
entry = __defProp({}, "__esModule", { value: true });
if (from && typeof from === "object" || typeof from === "function") {
for (var key of __getOwnPropNames(from))
if (!__hasOwnProp.call(entry, key))
__defProp(entry, key, {
get: __accessProp.bind(from, key),
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
});
}
__moduleCache.set(from, entry);
return entry;
};
var __moduleCache;
var __returnValue = (v) => v;
function __exportSetter(name, newValue) {
this[name] = __returnValue.bind(null, newValue);
}
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: __exportSetter.bind(all, name)
});
};
// src/mermaid.ts
var exports_mermaid = {};
__export(exports_mermaid, {
renderMermaidToPng: () => renderMermaidToPng,
closeRenderer: () => closeRenderer,
MermaidRenderError: () => MermaidRenderError
});
module.exports = __toCommonJS(exports_mermaid);
var import_node_child_process2 = require("node:child_process");
var import_node_fs2 = __toESM(require("node:fs"));
var import_node_path2 = __toESM(require("node:path"));
var import_node_process2 = __toESM(require("node:process"));
var import_node_url = require("node:url");
// src/index.ts
var import_node_child_process = require("node:child_process");
var import_node_fs = __toESM(require("node:fs"));
var import_node_net = __toESM(require("node:net"));
var import_node_os = __toESM(require("node:os"));
var import_node_path = __toESM(require("node:path"));
var import_node_process = __toESM(require("node:process"));
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFreePort(fixedEnvName) {
const fixed = fixedEnvName ? Number.parseInt(import_node_process.default.env[fixedEnvName] ?? "", 10) : NaN;
if (Number.isInteger(fixed) && fixed > 0)
return fixed;
return await new Promise((resolve, reject) => {
const server = import_node_net.default.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
return;
}
const port = address.port;
server.close((err) => {
if (err)
reject(err);
else
resolve(port);
});
});
});
}
function findChromeExecutable(options) {
for (const envName of options.envNames ?? []) {
const override = import_node_process.default.env[envName]?.trim();
if (override && import_node_fs.default.existsSync(override))
return override;
}
const candidates = import_node_process.default.platform === "darwin" ? options.candidates.darwin ?? options.candidates.default : import_node_process.default.platform === "win32" ? options.candidates.win32 ?? options.candidates.default : options.candidates.default;
for (const candidate of candidates) {
if (import_node_fs.default.existsSync(candidate))
return candidate;
}
return;
}
function resolveSharedChromeProfileDir(options = {}) {
for (const envName of options.envNames ?? []) {
const override = import_node_process.default.env[envName]?.trim();
if (override)
return import_node_path.default.resolve(override);
}
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
const profileDirName = options.profileDirName ?? "chrome-profile";
if (options.wslWindowsHome) {
return import_node_path.default.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
}
const base = import_node_process.default.platform === "darwin" ? import_node_path.default.join(import_node_os.default.homedir(), "Library", "Application Support") : import_node_process.default.platform === "win32" ? import_node_process.default.env.APPDATA ?? import_node_path.default.join(import_node_os.default.homedir(), "AppData", "Roaming") : import_node_process.default.env.XDG_DATA_HOME ?? import_node_path.default.join(import_node_os.default.homedir(), ".local", "share");
return import_node_path.default.join(base, appDataDirName, profileDirName);
}
async function fetchWithTimeout(url, timeoutMs) {
if (!timeoutMs || timeoutMs <= 0)
return await fetch(url, { redirect: "follow" });
const ctl = new AbortController;
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
return await fetch(url, { redirect: "follow", signal: ctl.signal });
} finally {
clearTimeout(timer);
}
}
async function fetchJson(url, options = {}) {
const response = await fetchWithTimeout(url, options.timeoutMs);
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
return await response.json();
}
async function isDebugPortReady(port, timeoutMs = 3000) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
return !!version.webSocketDebuggerUrl;
} catch {
return false;
}
}
function parseDevToolsActivePort(filePath) {
try {
const content = import_node_fs.default.readFileSync(filePath, "utf-8");
const lines = content.split(/\r?\n/);
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
const wsPath = lines[1]?.trim();
if (port > 0 && wsPath)
return { port, wsPath };
} catch {}
return null;
}
async function findExistingChromeDebugPort(options) {
const timeoutMs = options.timeoutMs ?? 3000;
const parsed = parseDevToolsActivePort(import_node_path.default.join(options.profileDir, "DevToolsActivePort"));
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs))
return parsed.port;
if (import_node_process.default.platform === "win32")
return null;
try {
const result = import_node_child_process.spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5000 });
if (result.status !== 0 || !result.stdout)
return null;
const lines = result.stdout.split(`
`).filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
for (const line of lines) {
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs))
return port;
}
} catch {}
return null;
}
async function waitForChromeDebugPort(port, timeoutMs, options) {
const start = Date.now();
let lastError = null;
while (Date.now() - start < timeoutMs) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5000 });
if (version.webSocketDebuggerUrl)
return version.webSocketDebuggerUrl;
lastError = new Error("Missing webSocketDebuggerUrl");
} catch (error) {
lastError = error;
}
await sleep(200);
}
if (options?.includeLastError && lastError) {
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
throw new Error("Chrome debug port not ready");
}
class CdpConnection {
ws;
nextId = 0;
pending = new Map;
eventHandlers = new Map;
defaultTimeoutMs;
constructor(ws, defaultTimeoutMs = 15000) {
this.ws = ws;
this.defaultTimeoutMs = defaultTimeoutMs;
this.ws.addEventListener("message", (event) => {
try {
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
const msg = JSON.parse(data);
if (msg.method) {
const handlers = this.eventHandlers.get(msg.method);
if (handlers) {
handlers.forEach((handler) => handler(msg.params));
}
}
if (msg.id) {
const pending = this.pending.get(msg.id);
if (pending) {
this.pending.delete(msg.id);
if (pending.timer)
clearTimeout(pending.timer);
if (msg.error?.message)
pending.reject(new Error(msg.error.message));
else
pending.resolve(msg.result);
}
}
} catch {}
});
this.ws.addEventListener("close", () => {
for (const [id, pending] of this.pending.entries()) {
this.pending.delete(id);
if (pending.timer)
clearTimeout(pending.timer);
pending.reject(new Error("CDP connection closed."));
}
});
}
static async connect(url, timeoutMs, options) {
const ws = new WebSocket(url);
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
ws.addEventListener("open", () => {
clearTimeout(timer);
resolve();
});
ws.addEventListener("error", () => {
clearTimeout(timer);
reject(new Error("CDP connection failed."));
});
});
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15000);
}
on(method, handler) {
if (!this.eventHandlers.has(method)) {
this.eventHandlers.set(method, new Set);
}
this.eventHandlers.get(method)?.add(handler);
}
off(method, handler) {
this.eventHandlers.get(method)?.delete(handler);
}
async send(method, params, options) {
const id = ++this.nextId;
const message = { id, method };
if (params)
message.params = params;
if (options?.sessionId)
message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
const result = await new Promise((resolve, reject) => {
const timer = timeoutMs > 0 ? setTimeout(() => {
this.pending.delete(id);
reject(new Error(`CDP timeout: ${method}`));
}, timeoutMs) : null;
this.pending.set(id, { resolve, reject, timer });
this.ws.send(JSON.stringify(message));
});
return result;
}
close() {
try {
this.ws.close();
} catch {}
}
}
async function launchChrome(options) {
await import_node_fs.default.promises.mkdir(options.profileDir, { recursive: true });
const args = [
`--remote-debugging-port=${options.port}`,
`--user-data-dir=${options.profileDir}`,
"--no-first-run",
"--no-default-browser-check",
...options.extraArgs ?? []
];
if (options.headless)
args.push("--headless=new");
if (options.url)
args.push(options.url);
return import_node_child_process.spawn(options.chromePath, args, { stdio: "ignore" });
}
async function openPageSession(options) {
let targetId;
let createdTarget = false;
if (options.reusing) {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
} else {
const targets = await options.cdp.send("Target.getTargets");
const existing = targets.targetInfos.find(options.matchTarget);
if (existing) {
targetId = existing.targetId;
} else {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
}
}
const { sessionId } = await options.cdp.send("Target.attachToTarget", { targetId, flatten: true });
if (options.activateTarget ?? true) {
await options.cdp.send("Target.activateTarget", { targetId });
}
if (options.enablePage)
await options.cdp.send("Page.enable", {}, { sessionId });
if (options.enableRuntime)
await options.cdp.send("Runtime.enable", {}, { sessionId });
if (options.enableDom)
await options.cdp.send("DOM.enable", {}, { sessionId });
if (options.enableNetwork)
await options.cdp.send("Network.enable", {}, { sessionId });
return { sessionId, targetId, createdTarget };
}
// src/mermaid.ts
class MermaidRenderError extends Error {
constructor(message, options) {
super(message);
this.name = "MermaidRenderError";
if (options?.cause !== undefined) {
this.cause = options.cause;
}
}
}
function resolveRenderScale(scale) {
const resolved = scale ?? 2;
if (!Number.isFinite(resolved) || resolved <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render scale: ${scale}`);
}
return resolved;
}
function resolveMinWidth(minWidth) {
if (minWidth === undefined)
return;
if (!Number.isFinite(minWidth) || minWidth <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render minWidth: ${minWidth}`);
}
return minWidth;
}
var CHROME_CANDIDATES = {
darwin: [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
],
win32: [
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
],
default: [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
"/usr/bin/microsoft-edge"
]
};
var wslHome;
function getWslWindowsHome() {
if (wslHome !== undefined)
return wslHome;
if (!import_node_process2.default.env.WSL_DISTRO_NAME) {
wslHome = null;
return null;
}
try {
const raw = import_node_child_process2.execSync('cmd.exe /C "echo %USERPROFILE%"', {
encoding: "utf-8",
timeout: 5000
}).trim().replace(/\r/g, "");
wslHome = import_node_child_process2.execSync(`wslpath -u "${raw}"`, {
encoding: "utf-8",
timeout: 5000
}).trim() || null;
} catch {
wslHome = null;
}
return wslHome;
}
function getProfileDir() {
return resolveSharedChromeProfileDir({
envNames: ["BAOYU_CHROME_PROFILE_DIR", "MERMAID_RENDER_PROFILE_DIR"],
wslWindowsHome: getWslWindowsHome()
});
}
function resolveAssetsDir() {
const here = import_node_url.fileURLToPath(undefined);
const dir = import_node_path2.default.dirname(here);
const candidates = [
import_node_path2.default.resolve(dir, "..", "assets"),
import_node_path2.default.resolve(dir, "assets")
];
for (const candidate of candidates) {
if (import_node_fs2.default.existsSync(import_node_path2.default.join(candidate, "mermaid.min.js")))
return candidate;
}
throw new MermaidRenderError(`Cannot locate mermaid.min.js. Looked in: ${candidates.join(", ")}`);
}
var cachedMermaidScript = null;
function loadMermaidScript() {
if (cachedMermaidScript)
return cachedMermaidScript;
const assetsDir = resolveAssetsDir();
cachedMermaidScript = import_node_fs2.default.readFileSync(import_node_path2.default.join(assetsDir, "mermaid.min.js"), "utf-8");
return cachedMermaidScript;
}
var rendererState = null;
var connectingPromise = null;
var exitHookInstalled = false;
function installExitHook() {
if (exitHookInstalled)
return;
exitHookInstalled = true;
const cleanup = () => {
const state = rendererState;
rendererState = null;
if (!state)
return;
try {
state.cdp.close();
} catch {}
if (state.ownsChrome && state.chrome) {
try {
state.chrome.kill("SIGTERM");
} catch {}
}
};
import_node_process2.default.on("exit", cleanup);
import_node_process2.default.on("beforeExit", cleanup);
}
async function tryConnectExisting(port) {
try {
const wsUrl = await waitForChromeDebugPort(port, 5000, { includeLastError: true });
return await CdpConnection.connect(wsUrl, 5000);
} catch {
return null;
}
}
async function ensureRenderer() {
if (rendererState)
return rendererState;
if (connectingPromise)
return await connectingPromise;
connectingPromise = (async () => {
const profileDir = getProfileDir();
const existingPort = await findExistingChromeDebugPort({ profileDir });
if (existingPort) {
const cdp2 = await tryConnectExisting(existingPort);
if (cdp2) {
const state2 = {
cdp: cdp2,
chrome: null,
port: existingPort,
ownsChrome: false
};
rendererState = state2;
installExitHook();
return state2;
}
}
const chromePath = findChromeExecutable({
candidates: CHROME_CANDIDATES,
envNames: ["BAOYU_CHROME_PATH", "MERMAID_RENDER_CHROME_PATH"]
});
if (!chromePath) {
throw new MermaidRenderError("Chrome not found. Install Google Chrome / Chromium / Edge, or set BAOYU_CHROME_PATH.");
}
const port = await getFreePort("MERMAID_RENDER_DEBUG_PORT");
const chrome = await launchChrome({
chromePath,
profileDir,
port,
headless: true,
extraArgs: [
"--disable-blink-features=AutomationControlled",
"--disable-gpu",
"--hide-scrollbars"
]
});
const wsUrl = await waitForChromeDebugPort(port, 30000, { includeLastError: true });
const cdp = await CdpConnection.connect(wsUrl, 30000);
const state = { cdp, chrome, port, ownsChrome: true };
rendererState = state;
installExitHook();
return state;
})();
try {
return await connectingPromise;
} finally {
connectingPromise = null;
}
}
function buildHostHtml(code, theme, background) {
const script = loadMermaidScript();
const safeCode = code.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const safeTheme = JSON.stringify(theme);
const cssBackground = background === "transparent" ? "transparent" : background;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body {
margin: 0;
padding: 0;
background: ${cssBackground};
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
}
#host {
display: inline-block;
padding: 16px;
}
#host svg {
max-width: none !important;
}
</style>
</head>
<body>
<div id="host"><div class="mermaid">${safeCode}</div></div>
<script>${script}</script>
<script>
window.__mermaidReady = false;
window.__mermaidError = null;
try {
mermaid.initialize({ startOnLoad: false, theme: ${safeTheme}, securityLevel: "loose" });
mermaid.run({ querySelector: ".mermaid" })
.then(function () { window.__mermaidReady = true; })
.catch(function (err) { window.__mermaidError = String(err && err.message || err); });
} catch (err) {
window.__mermaidError = String(err && err.message || err);
}
</script>
</body>
</html>`;
}
async function evaluate(cdp, sessionId, expression) {
const result = await cdp.send("Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: false
}, { sessionId });
const exception = result.exceptionDetails;
if (exception) {
return {
value: undefined,
exceptionText: exception.exception?.description ?? exception.text ?? "evaluation failed"
};
}
return { value: result.result.value };
}
async function waitForMermaidSvg(cdp, sessionId, timeoutMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const status = await evaluate(cdp, sessionId, `(function () {
if (window.__mermaidError) {
return { ready: false, error: window.__mermaidError, rect: null };
}
var svg = document.querySelector(".mermaid svg");
if (!svg) return { ready: false, error: null, rect: null };
var bbox = svg.getBoundingClientRect();
return {
ready: window.__mermaidReady === true,
error: null,
rect: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
},
};
})()`);
if (status.exceptionText) {
throw new MermaidRenderError(`Mermaid evaluation failed: ${status.exceptionText}`);
}
const value = status.value;
if (value?.error) {
throw new MermaidRenderError(`Mermaid render failed: ${value.error}`);
}
if (value?.ready && value.rect && value.rect.width > 0 && value.rect.height > 0) {
const host = await evaluate(cdp, sessionId, `(function () {
var host = document.getElementById("host");
if (!host) return null;
var rect = host.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
})()`);
if (host.value)
return host.value;
return value.rect;
}
await sleep(80);
}
throw new MermaidRenderError(`Mermaid render timed out after ${timeoutMs}ms`);
}
async function withPageSession(state, fn) {
let session = null;
try {
session = await openPageSession({
cdp: state.cdp,
reusing: !state.ownsChrome,
url: "about:blank",
matchTarget: () => false,
enablePage: true,
enableRuntime: true
});
return await fn(session.sessionId, session.targetId);
} finally {
if (session?.createdTarget) {
try {
await state.cdp.send("Target.closeTarget", { targetId: session.targetId });
} catch {}
}
}
}
async function renderMermaidToPng(code, outputPath, options = {}) {
const theme = options.theme ?? "default";
const scale = resolveRenderScale(options.scale);
const minWidth = resolveMinWidth(options.minWidth);
const background = options.background ?? "white";
const timeoutMs = options.timeoutMs ?? 15000;
if (!code.trim()) {
throw new MermaidRenderError("Mermaid code is empty");
}
await import_node_fs2.default.promises.mkdir(import_node_path2.default.dirname(outputPath), { recursive: true });
const state = await ensureRenderer();
const html = buildHostHtml(code, theme, background);
return await withPageSession(state, async (sessionId) => {
await state.cdp.send("Emulation.setDeviceMetricsOverride", {
width: 1280,
height: 800,
deviceScaleFactor: 1,
mobile: false
}, { sessionId });
await state.cdp.send("Page.setDocumentContent", { frameId: await getFrameId(state.cdp, sessionId), html }, { sessionId });
const rect = await waitForMermaidSvg(state.cdp, sessionId, timeoutMs);
const cssWidth = Math.max(1, Math.ceil(rect.width));
const cssHeight = Math.max(1, Math.ceil(rect.height));
const targetCssWidth = Math.max(cssWidth, Math.ceil(minWidth ?? cssWidth));
const captureScale = scale * (targetCssWidth / cssWidth);
const bitmapWidth = Math.max(1, Math.ceil(cssWidth * captureScale));
const bitmapHeight = Math.max(1, Math.ceil(cssHeight * captureScale));
await state.cdp.send("Emulation.setDeviceMetricsOverride", {
width: cssWidth,
height: cssHeight,
deviceScaleFactor: 1,
mobile: false
}, { sessionId });
const shot = await state.cdp.send("Page.captureScreenshot", {
format: "png",
clip: {
x: rect.x,
y: rect.y,
width: cssWidth,
height: cssHeight,
scale: captureScale
},
captureBeyondViewport: true
}, { sessionId });
const buffer = Buffer.from(shot.data, "base64");
await import_node_fs2.default.promises.writeFile(outputPath, buffer);
return {
width: bitmapWidth,
height: bitmapHeight,
bytes: buffer.length
};
});
}
async function getFrameId(cdp, sessionId) {
const result = await cdp.send("Page.getFrameTree", {}, { sessionId });
return result.frameTree.frame.id;
}
async function closeRenderer() {
const state = rendererState;
rendererState = null;
if (!state)
return;
try {
state.cdp.close();
} catch {}
if (state.ownsChrome && state.chrome) {
try {
state.chrome.kill("SIGTERM");
} catch {}
}
}
+665
View File
@@ -0,0 +1,665 @@
// src/mermaid.ts
import { execSync } from "node:child_process";
import fs2 from "node:fs";
import path2 from "node:path";
import process2 from "node:process";
import { fileURLToPath } from "node:url";
// src/index.ts
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFreePort(fixedEnvName) {
const fixed = fixedEnvName ? Number.parseInt(process.env[fixedEnvName] ?? "", 10) : NaN;
if (Number.isInteger(fixed) && fixed > 0)
return fixed;
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
return;
}
const port = address.port;
server.close((err) => {
if (err)
reject(err);
else
resolve(port);
});
});
});
}
function findChromeExecutable(options) {
for (const envName of options.envNames ?? []) {
const override = process.env[envName]?.trim();
if (override && fs.existsSync(override))
return override;
}
const candidates = process.platform === "darwin" ? options.candidates.darwin ?? options.candidates.default : process.platform === "win32" ? options.candidates.win32 ?? options.candidates.default : options.candidates.default;
for (const candidate of candidates) {
if (fs.existsSync(candidate))
return candidate;
}
return;
}
function resolveSharedChromeProfileDir(options = {}) {
for (const envName of options.envNames ?? []) {
const override = process.env[envName]?.trim();
if (override)
return path.resolve(override);
}
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
const profileDirName = options.profileDirName ?? "chrome-profile";
if (options.wslWindowsHome) {
return path.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
}
const base = process.platform === "darwin" ? path.join(os.homedir(), "Library", "Application Support") : process.platform === "win32" ? process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming") : process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
return path.join(base, appDataDirName, profileDirName);
}
async function fetchWithTimeout(url, timeoutMs) {
if (!timeoutMs || timeoutMs <= 0)
return await fetch(url, { redirect: "follow" });
const ctl = new AbortController;
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
return await fetch(url, { redirect: "follow", signal: ctl.signal });
} finally {
clearTimeout(timer);
}
}
async function fetchJson(url, options = {}) {
const response = await fetchWithTimeout(url, options.timeoutMs);
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
return await response.json();
}
async function isDebugPortReady(port, timeoutMs = 3000) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
return !!version.webSocketDebuggerUrl;
} catch {
return false;
}
}
function parseDevToolsActivePort(filePath) {
try {
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split(/\r?\n/);
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
const wsPath = lines[1]?.trim();
if (port > 0 && wsPath)
return { port, wsPath };
} catch {}
return null;
}
async function findExistingChromeDebugPort(options) {
const timeoutMs = options.timeoutMs ?? 3000;
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs))
return parsed.port;
if (process.platform === "win32")
return null;
try {
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5000 });
if (result.status !== 0 || !result.stdout)
return null;
const lines = result.stdout.split(`
`).filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
for (const line of lines) {
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs))
return port;
}
} catch {}
return null;
}
async function waitForChromeDebugPort(port, timeoutMs, options) {
const start = Date.now();
let lastError = null;
while (Date.now() - start < timeoutMs) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5000 });
if (version.webSocketDebuggerUrl)
return version.webSocketDebuggerUrl;
lastError = new Error("Missing webSocketDebuggerUrl");
} catch (error) {
lastError = error;
}
await sleep(200);
}
if (options?.includeLastError && lastError) {
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
throw new Error("Chrome debug port not ready");
}
class CdpConnection {
ws;
nextId = 0;
pending = new Map;
eventHandlers = new Map;
defaultTimeoutMs;
constructor(ws, defaultTimeoutMs = 15000) {
this.ws = ws;
this.defaultTimeoutMs = defaultTimeoutMs;
this.ws.addEventListener("message", (event) => {
try {
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
const msg = JSON.parse(data);
if (msg.method) {
const handlers = this.eventHandlers.get(msg.method);
if (handlers) {
handlers.forEach((handler) => handler(msg.params));
}
}
if (msg.id) {
const pending = this.pending.get(msg.id);
if (pending) {
this.pending.delete(msg.id);
if (pending.timer)
clearTimeout(pending.timer);
if (msg.error?.message)
pending.reject(new Error(msg.error.message));
else
pending.resolve(msg.result);
}
}
} catch {}
});
this.ws.addEventListener("close", () => {
for (const [id, pending] of this.pending.entries()) {
this.pending.delete(id);
if (pending.timer)
clearTimeout(pending.timer);
pending.reject(new Error("CDP connection closed."));
}
});
}
static async connect(url, timeoutMs, options) {
const ws = new WebSocket(url);
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
ws.addEventListener("open", () => {
clearTimeout(timer);
resolve();
});
ws.addEventListener("error", () => {
clearTimeout(timer);
reject(new Error("CDP connection failed."));
});
});
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15000);
}
on(method, handler) {
if (!this.eventHandlers.has(method)) {
this.eventHandlers.set(method, new Set);
}
this.eventHandlers.get(method)?.add(handler);
}
off(method, handler) {
this.eventHandlers.get(method)?.delete(handler);
}
async send(method, params, options) {
const id = ++this.nextId;
const message = { id, method };
if (params)
message.params = params;
if (options?.sessionId)
message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
const result = await new Promise((resolve, reject) => {
const timer = timeoutMs > 0 ? setTimeout(() => {
this.pending.delete(id);
reject(new Error(`CDP timeout: ${method}`));
}, timeoutMs) : null;
this.pending.set(id, { resolve, reject, timer });
this.ws.send(JSON.stringify(message));
});
return result;
}
close() {
try {
this.ws.close();
} catch {}
}
}
async function launchChrome(options) {
await fs.promises.mkdir(options.profileDir, { recursive: true });
const args = [
`--remote-debugging-port=${options.port}`,
`--user-data-dir=${options.profileDir}`,
"--no-first-run",
"--no-default-browser-check",
...options.extraArgs ?? []
];
if (options.headless)
args.push("--headless=new");
if (options.url)
args.push(options.url);
return spawn(options.chromePath, args, { stdio: "ignore" });
}
async function openPageSession(options) {
let targetId;
let createdTarget = false;
if (options.reusing) {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
} else {
const targets = await options.cdp.send("Target.getTargets");
const existing = targets.targetInfos.find(options.matchTarget);
if (existing) {
targetId = existing.targetId;
} else {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
}
}
const { sessionId } = await options.cdp.send("Target.attachToTarget", { targetId, flatten: true });
if (options.activateTarget ?? true) {
await options.cdp.send("Target.activateTarget", { targetId });
}
if (options.enablePage)
await options.cdp.send("Page.enable", {}, { sessionId });
if (options.enableRuntime)
await options.cdp.send("Runtime.enable", {}, { sessionId });
if (options.enableDom)
await options.cdp.send("DOM.enable", {}, { sessionId });
if (options.enableNetwork)
await options.cdp.send("Network.enable", {}, { sessionId });
return { sessionId, targetId, createdTarget };
}
// src/mermaid.ts
class MermaidRenderError extends Error {
constructor(message, options) {
super(message);
this.name = "MermaidRenderError";
if (options?.cause !== undefined) {
this.cause = options.cause;
}
}
}
function resolveRenderScale(scale) {
const resolved = scale ?? 2;
if (!Number.isFinite(resolved) || resolved <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render scale: ${scale}`);
}
return resolved;
}
function resolveMinWidth(minWidth) {
if (minWidth === undefined)
return;
if (!Number.isFinite(minWidth) || minWidth <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render minWidth: ${minWidth}`);
}
return minWidth;
}
var CHROME_CANDIDATES = {
darwin: [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
],
win32: [
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
],
default: [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
"/usr/bin/microsoft-edge"
]
};
var wslHome;
function getWslWindowsHome() {
if (wslHome !== undefined)
return wslHome;
if (!process2.env.WSL_DISTRO_NAME) {
wslHome = null;
return null;
}
try {
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', {
encoding: "utf-8",
timeout: 5000
}).trim().replace(/\r/g, "");
wslHome = execSync(`wslpath -u "${raw}"`, {
encoding: "utf-8",
timeout: 5000
}).trim() || null;
} catch {
wslHome = null;
}
return wslHome;
}
function getProfileDir() {
return resolveSharedChromeProfileDir({
envNames: ["BAOYU_CHROME_PROFILE_DIR", "MERMAID_RENDER_PROFILE_DIR"],
wslWindowsHome: getWslWindowsHome()
});
}
function resolveAssetsDir() {
const here = fileURLToPath(import.meta.url);
const dir = path2.dirname(here);
const candidates = [
path2.resolve(dir, "..", "assets"),
path2.resolve(dir, "assets")
];
for (const candidate of candidates) {
if (fs2.existsSync(path2.join(candidate, "mermaid.min.js")))
return candidate;
}
throw new MermaidRenderError(`Cannot locate mermaid.min.js. Looked in: ${candidates.join(", ")}`);
}
var cachedMermaidScript = null;
function loadMermaidScript() {
if (cachedMermaidScript)
return cachedMermaidScript;
const assetsDir = resolveAssetsDir();
cachedMermaidScript = fs2.readFileSync(path2.join(assetsDir, "mermaid.min.js"), "utf-8");
return cachedMermaidScript;
}
var rendererState = null;
var connectingPromise = null;
var exitHookInstalled = false;
function installExitHook() {
if (exitHookInstalled)
return;
exitHookInstalled = true;
const cleanup = () => {
const state = rendererState;
rendererState = null;
if (!state)
return;
try {
state.cdp.close();
} catch {}
if (state.ownsChrome && state.chrome) {
try {
state.chrome.kill("SIGTERM");
} catch {}
}
};
process2.on("exit", cleanup);
process2.on("beforeExit", cleanup);
}
async function tryConnectExisting(port) {
try {
const wsUrl = await waitForChromeDebugPort(port, 5000, { includeLastError: true });
return await CdpConnection.connect(wsUrl, 5000);
} catch {
return null;
}
}
async function ensureRenderer() {
if (rendererState)
return rendererState;
if (connectingPromise)
return await connectingPromise;
connectingPromise = (async () => {
const profileDir = getProfileDir();
const existingPort = await findExistingChromeDebugPort({ profileDir });
if (existingPort) {
const cdp2 = await tryConnectExisting(existingPort);
if (cdp2) {
const state2 = {
cdp: cdp2,
chrome: null,
port: existingPort,
ownsChrome: false
};
rendererState = state2;
installExitHook();
return state2;
}
}
const chromePath = findChromeExecutable({
candidates: CHROME_CANDIDATES,
envNames: ["BAOYU_CHROME_PATH", "MERMAID_RENDER_CHROME_PATH"]
});
if (!chromePath) {
throw new MermaidRenderError("Chrome not found. Install Google Chrome / Chromium / Edge, or set BAOYU_CHROME_PATH.");
}
const port = await getFreePort("MERMAID_RENDER_DEBUG_PORT");
const chrome = await launchChrome({
chromePath,
profileDir,
port,
headless: true,
extraArgs: [
"--disable-blink-features=AutomationControlled",
"--disable-gpu",
"--hide-scrollbars"
]
});
const wsUrl = await waitForChromeDebugPort(port, 30000, { includeLastError: true });
const cdp = await CdpConnection.connect(wsUrl, 30000);
const state = { cdp, chrome, port, ownsChrome: true };
rendererState = state;
installExitHook();
return state;
})();
try {
return await connectingPromise;
} finally {
connectingPromise = null;
}
}
function buildHostHtml(code, theme, background) {
const script = loadMermaidScript();
const safeCode = code.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const safeTheme = JSON.stringify(theme);
const cssBackground = background === "transparent" ? "transparent" : background;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body {
margin: 0;
padding: 0;
background: ${cssBackground};
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
}
#host {
display: inline-block;
padding: 16px;
}
#host svg {
max-width: none !important;
}
</style>
</head>
<body>
<div id="host"><div class="mermaid">${safeCode}</div></div>
<script>${script}</script>
<script>
window.__mermaidReady = false;
window.__mermaidError = null;
try {
mermaid.initialize({ startOnLoad: false, theme: ${safeTheme}, securityLevel: "loose" });
mermaid.run({ querySelector: ".mermaid" })
.then(function () { window.__mermaidReady = true; })
.catch(function (err) { window.__mermaidError = String(err && err.message || err); });
} catch (err) {
window.__mermaidError = String(err && err.message || err);
}
</script>
</body>
</html>`;
}
async function evaluate(cdp, sessionId, expression) {
const result = await cdp.send("Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: false
}, { sessionId });
const exception = result.exceptionDetails;
if (exception) {
return {
value: undefined,
exceptionText: exception.exception?.description ?? exception.text ?? "evaluation failed"
};
}
return { value: result.result.value };
}
async function waitForMermaidSvg(cdp, sessionId, timeoutMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const status = await evaluate(cdp, sessionId, `(function () {
if (window.__mermaidError) {
return { ready: false, error: window.__mermaidError, rect: null };
}
var svg = document.querySelector(".mermaid svg");
if (!svg) return { ready: false, error: null, rect: null };
var bbox = svg.getBoundingClientRect();
return {
ready: window.__mermaidReady === true,
error: null,
rect: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
},
};
})()`);
if (status.exceptionText) {
throw new MermaidRenderError(`Mermaid evaluation failed: ${status.exceptionText}`);
}
const value = status.value;
if (value?.error) {
throw new MermaidRenderError(`Mermaid render failed: ${value.error}`);
}
if (value?.ready && value.rect && value.rect.width > 0 && value.rect.height > 0) {
const host = await evaluate(cdp, sessionId, `(function () {
var host = document.getElementById("host");
if (!host) return null;
var rect = host.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
})()`);
if (host.value)
return host.value;
return value.rect;
}
await sleep(80);
}
throw new MermaidRenderError(`Mermaid render timed out after ${timeoutMs}ms`);
}
async function withPageSession(state, fn) {
let session = null;
try {
session = await openPageSession({
cdp: state.cdp,
reusing: !state.ownsChrome,
url: "about:blank",
matchTarget: () => false,
enablePage: true,
enableRuntime: true
});
return await fn(session.sessionId, session.targetId);
} finally {
if (session?.createdTarget) {
try {
await state.cdp.send("Target.closeTarget", { targetId: session.targetId });
} catch {}
}
}
}
async function renderMermaidToPng(code, outputPath, options = {}) {
const theme = options.theme ?? "default";
const scale = resolveRenderScale(options.scale);
const minWidth = resolveMinWidth(options.minWidth);
const background = options.background ?? "white";
const timeoutMs = options.timeoutMs ?? 15000;
if (!code.trim()) {
throw new MermaidRenderError("Mermaid code is empty");
}
await fs2.promises.mkdir(path2.dirname(outputPath), { recursive: true });
const state = await ensureRenderer();
const html = buildHostHtml(code, theme, background);
return await withPageSession(state, async (sessionId) => {
await state.cdp.send("Emulation.setDeviceMetricsOverride", {
width: 1280,
height: 800,
deviceScaleFactor: 1,
mobile: false
}, { sessionId });
await state.cdp.send("Page.setDocumentContent", { frameId: await getFrameId(state.cdp, sessionId), html }, { sessionId });
const rect = await waitForMermaidSvg(state.cdp, sessionId, timeoutMs);
const cssWidth = Math.max(1, Math.ceil(rect.width));
const cssHeight = Math.max(1, Math.ceil(rect.height));
const targetCssWidth = Math.max(cssWidth, Math.ceil(minWidth ?? cssWidth));
const captureScale = scale * (targetCssWidth / cssWidth);
const bitmapWidth = Math.max(1, Math.ceil(cssWidth * captureScale));
const bitmapHeight = Math.max(1, Math.ceil(cssHeight * captureScale));
await state.cdp.send("Emulation.setDeviceMetricsOverride", {
width: cssWidth,
height: cssHeight,
deviceScaleFactor: 1,
mobile: false
}, { sessionId });
const shot = await state.cdp.send("Page.captureScreenshot", {
format: "png",
clip: {
x: rect.x,
y: rect.y,
width: cssWidth,
height: cssHeight,
scale: captureScale
},
captureBeyondViewport: true
}, { sessionId });
const buffer = Buffer.from(shot.data, "base64");
await fs2.promises.writeFile(outputPath, buffer);
return {
width: bitmapWidth,
height: bitmapHeight,
bytes: buffer.length
};
});
}
async function getFrameId(cdp, sessionId) {
const result = await cdp.send("Page.getFrameTree", {}, { sessionId });
return result.frameTree.frame.id;
}
async function closeRenderer() {
const state = rendererState;
rendererState = null;
if (!state)
return;
try {
state.cdp.close();
} catch {}
if (state.ownsChrome && state.chrome) {
try {
state.chrome.kill("SIGTERM");
} catch {}
}
}
export {
renderMermaidToPng,
closeRenderer,
MermaidRenderError
};
+11 -3
View File
@@ -1,10 +1,11 @@
{
"name": "baoyu-chrome-cdp",
"version": "0.1.0",
"version": "0.1.1",
"type": "module",
"files": [
"dist",
"src/**/*.ts",
"assets",
"!src/**/*.test.ts"
],
"exports": {
@@ -14,7 +15,14 @@
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./src/*": "./src/*"
"./mermaid": {
"types": "./src/mermaid.ts",
"import": "./dist/mermaid.js",
"require": "./dist/mermaid.cjs",
"default": "./dist/mermaid.js"
},
"./src/*": "./src/*",
"./assets/*": "./assets/*"
},
"description": "Shared Chrome DevTools Protocol utilities for baoyu skills.",
"main": "./dist/index.cjs",
@@ -33,7 +41,7 @@
"access": "public"
},
"scripts": {
"build": "node ../../scripts/build-shared-package.mjs",
"build": "node ../../scripts/build-shared-package.mjs --entry-out src/index.ts:index --entry-out src/mermaid.ts:mermaid --asset assets:assets",
"prepack": "bun run build"
},
"engines": {
+499
View File
@@ -0,0 +1,499 @@
import { execSync, type ChildProcess } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import {
CdpConnection,
findChromeExecutable,
findExistingChromeDebugPort,
getFreePort,
launchChrome,
openPageSession,
resolveSharedChromeProfileDir,
sleep,
waitForChromeDebugPort,
type PageSession,
type PlatformCandidates,
} from "./index.js";
export class MermaidRenderError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message);
this.name = "MermaidRenderError";
if (options?.cause !== undefined) {
(this as { cause?: unknown }).cause = options.cause;
}
}
}
export interface MermaidRenderOptions {
theme?: string;
scale?: number;
background?: string;
timeoutMs?: number;
minWidth?: number;
}
export interface MermaidRenderResult {
width: number;
height: number;
bytes: number;
}
function resolveRenderScale(scale: number | undefined): number {
const resolved = scale ?? 2;
if (!Number.isFinite(resolved) || resolved <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render scale: ${scale}`);
}
return resolved;
}
function resolveMinWidth(minWidth: number | undefined): number | undefined {
if (minWidth === undefined) return undefined;
if (!Number.isFinite(minWidth) || minWidth <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render minWidth: ${minWidth}`);
}
return minWidth;
}
const CHROME_CANDIDATES: PlatformCandidates = {
darwin: [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
],
win32: [
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
],
default: [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
"/usr/bin/microsoft-edge",
],
};
let wslHome: string | null | undefined;
function getWslWindowsHome(): string | null {
if (wslHome !== undefined) return wslHome;
if (!process.env.WSL_DISTRO_NAME) {
wslHome = null;
return null;
}
try {
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', {
encoding: "utf-8",
timeout: 5_000,
}).trim().replace(/\r/g, "");
wslHome = execSync(`wslpath -u "${raw}"`, {
encoding: "utf-8",
timeout: 5_000,
}).trim() || null;
} catch {
wslHome = null;
}
return wslHome;
}
function getProfileDir(): string {
return resolveSharedChromeProfileDir({
envNames: ["BAOYU_CHROME_PROFILE_DIR", "MERMAID_RENDER_PROFILE_DIR"],
wslWindowsHome: getWslWindowsHome(),
});
}
function resolveAssetsDir(): string {
const here = fileURLToPath(import.meta.url);
const dir = path.dirname(here);
const candidates = [
path.resolve(dir, "..", "assets"),
path.resolve(dir, "assets"),
];
for (const candidate of candidates) {
if (fs.existsSync(path.join(candidate, "mermaid.min.js"))) return candidate;
}
throw new MermaidRenderError(
`Cannot locate mermaid.min.js. Looked in: ${candidates.join(", ")}`,
);
}
let cachedMermaidScript: string | null = null;
function loadMermaidScript(): string {
if (cachedMermaidScript) return cachedMermaidScript;
const assetsDir = resolveAssetsDir();
cachedMermaidScript = fs.readFileSync(path.join(assetsDir, "mermaid.min.js"), "utf-8");
return cachedMermaidScript;
}
interface RendererState {
cdp: CdpConnection;
chrome: ChildProcess | null;
port: number | null;
ownsChrome: boolean;
}
let rendererState: RendererState | null = null;
let connectingPromise: Promise<RendererState> | null = null;
let exitHookInstalled = false;
function installExitHook(): void {
if (exitHookInstalled) return;
exitHookInstalled = true;
const cleanup = () => {
const state = rendererState;
rendererState = null;
if (!state) return;
try { state.cdp.close(); } catch {}
if (state.ownsChrome && state.chrome) {
try { state.chrome.kill("SIGTERM"); } catch {}
}
};
process.on("exit", cleanup);
process.on("beforeExit", cleanup);
}
async function tryConnectExisting(port: number): Promise<CdpConnection | null> {
try {
const wsUrl = await waitForChromeDebugPort(port, 5_000, { includeLastError: true });
return await CdpConnection.connect(wsUrl, 5_000);
} catch {
return null;
}
}
async function ensureRenderer(): Promise<RendererState> {
if (rendererState) return rendererState;
if (connectingPromise) return await connectingPromise;
connectingPromise = (async (): Promise<RendererState> => {
const profileDir = getProfileDir();
const existingPort = await findExistingChromeDebugPort({ profileDir });
if (existingPort) {
const cdp = await tryConnectExisting(existingPort);
if (cdp) {
const state: RendererState = {
cdp,
chrome: null,
port: existingPort,
ownsChrome: false,
};
rendererState = state;
installExitHook();
return state;
}
}
const chromePath = findChromeExecutable({
candidates: CHROME_CANDIDATES,
envNames: ["BAOYU_CHROME_PATH", "MERMAID_RENDER_CHROME_PATH"],
});
if (!chromePath) {
throw new MermaidRenderError(
"Chrome not found. Install Google Chrome / Chromium / Edge, or set BAOYU_CHROME_PATH.",
);
}
const port = await getFreePort("MERMAID_RENDER_DEBUG_PORT");
const chrome = await launchChrome({
chromePath,
profileDir,
port,
headless: true,
extraArgs: [
"--disable-blink-features=AutomationControlled",
"--disable-gpu",
"--hide-scrollbars",
],
});
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
const cdp = await CdpConnection.connect(wsUrl, 30_000);
const state: RendererState = { cdp, chrome, port, ownsChrome: true };
rendererState = state;
installExitHook();
return state;
})();
try {
return await connectingPromise;
} finally {
connectingPromise = null;
}
}
function buildHostHtml(code: string, theme: string, background: string): string {
const script = loadMermaidScript();
const safeCode = code
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const safeTheme = JSON.stringify(theme);
const cssBackground = background === "transparent" ? "transparent" : background;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body {
margin: 0;
padding: 0;
background: ${cssBackground};
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
}
#host {
display: inline-block;
padding: 16px;
}
#host svg {
max-width: none !important;
}
</style>
</head>
<body>
<div id="host"><div class="mermaid">${safeCode}</div></div>
<script>${script}</script>
<script>
window.__mermaidReady = false;
window.__mermaidError = null;
try {
mermaid.initialize({ startOnLoad: false, theme: ${safeTheme}, securityLevel: "loose" });
mermaid.run({ querySelector: ".mermaid" })
.then(function () { window.__mermaidReady = true; })
.catch(function (err) { window.__mermaidError = String(err && err.message || err); });
} catch (err) {
window.__mermaidError = String(err && err.message || err);
}
</script>
</body>
</html>`;
}
async function evaluate<T = unknown>(
cdp: CdpConnection,
sessionId: string,
expression: string,
): Promise<{ value: T | undefined; exceptionText?: string }> {
const result = await cdp.send<{
result: { value?: T };
exceptionDetails?: { text?: string; exception?: { description?: string } };
}>(
"Runtime.evaluate",
{
expression,
returnByValue: true,
awaitPromise: false,
},
{ sessionId },
);
const exception = result.exceptionDetails;
if (exception) {
return {
value: undefined,
exceptionText: exception.exception?.description ?? exception.text ?? "evaluation failed",
};
}
return { value: result.result.value };
}
interface BoundingRect {
x: number;
y: number;
width: number;
height: number;
}
async function waitForMermaidSvg(
cdp: CdpConnection,
sessionId: string,
timeoutMs: number,
): Promise<BoundingRect> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const status = await evaluate<{
ready: boolean;
error: string | null;
rect: BoundingRect | null;
}>(
cdp,
sessionId,
`(function () {
if (window.__mermaidError) {
return { ready: false, error: window.__mermaidError, rect: null };
}
var svg = document.querySelector(".mermaid svg");
if (!svg) return { ready: false, error: null, rect: null };
var bbox = svg.getBoundingClientRect();
return {
ready: window.__mermaidReady === true,
error: null,
rect: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
},
};
})()`,
);
if (status.exceptionText) {
throw new MermaidRenderError(`Mermaid evaluation failed: ${status.exceptionText}`);
}
const value = status.value;
if (value?.error) {
throw new MermaidRenderError(`Mermaid render failed: ${value.error}`);
}
if (value?.ready && value.rect && value.rect.width > 0 && value.rect.height > 0) {
const host = await evaluate<BoundingRect | null>(
cdp,
sessionId,
`(function () {
var host = document.getElementById("host");
if (!host) return null;
var rect = host.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
})()`,
);
if (host.value) return host.value;
return value.rect;
}
await sleep(80);
}
throw new MermaidRenderError(`Mermaid render timed out after ${timeoutMs}ms`);
}
async function withPageSession<T>(
state: RendererState,
fn: (sessionId: string, targetId: string) => Promise<T>,
): Promise<T> {
let session: PageSession | null = null;
try {
session = await openPageSession({
cdp: state.cdp,
reusing: !state.ownsChrome,
url: "about:blank",
matchTarget: () => false,
enablePage: true,
enableRuntime: true,
});
return await fn(session.sessionId, session.targetId);
} finally {
if (session?.createdTarget) {
try {
await state.cdp.send("Target.closeTarget", { targetId: session.targetId });
} catch {}
}
}
}
export async function renderMermaidToPng(
code: string,
outputPath: string,
options: MermaidRenderOptions = {},
): Promise<MermaidRenderResult> {
const theme = options.theme ?? "default";
const scale = resolveRenderScale(options.scale);
const minWidth = resolveMinWidth(options.minWidth);
const background = options.background ?? "white";
const timeoutMs = options.timeoutMs ?? 15_000;
if (!code.trim()) {
throw new MermaidRenderError("Mermaid code is empty");
}
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
const state = await ensureRenderer();
const html = buildHostHtml(code, theme, background);
return await withPageSession(state, async (sessionId) => {
await state.cdp.send(
"Emulation.setDeviceMetricsOverride",
{
width: 1280,
height: 800,
deviceScaleFactor: 1,
mobile: false,
},
{ sessionId },
);
await state.cdp.send(
"Page.setDocumentContent",
{ frameId: await getFrameId(state.cdp, sessionId), html },
{ sessionId },
);
const rect = await waitForMermaidSvg(state.cdp, sessionId, timeoutMs);
const cssWidth = Math.max(1, Math.ceil(rect.width));
const cssHeight = Math.max(1, Math.ceil(rect.height));
const targetCssWidth = Math.max(cssWidth, Math.ceil(minWidth ?? cssWidth));
const captureScale = scale * (targetCssWidth / cssWidth);
const bitmapWidth = Math.max(1, Math.ceil(cssWidth * captureScale));
const bitmapHeight = Math.max(1, Math.ceil(cssHeight * captureScale));
await state.cdp.send(
"Emulation.setDeviceMetricsOverride",
{
width: cssWidth,
height: cssHeight,
deviceScaleFactor: 1,
mobile: false,
},
{ sessionId },
);
const shot = await state.cdp.send<{ data: string }>(
"Page.captureScreenshot",
{
format: "png",
clip: {
x: rect.x,
y: rect.y,
width: cssWidth,
height: cssHeight,
scale: captureScale,
},
captureBeyondViewport: true,
},
{ sessionId },
);
const buffer = Buffer.from(shot.data, "base64");
await fs.promises.writeFile(outputPath, buffer);
return {
width: bitmapWidth,
height: bitmapHeight,
bytes: buffer.length,
};
});
}
async function getFrameId(cdp: CdpConnection, sessionId: string): Promise<string> {
const result = await cdp.send<{ frameTree: { frame: { id: string } } }>(
"Page.getFrameTree",
{},
{ sessionId },
);
return result.frameTree.frame.id;
}
export async function closeRenderer(): Promise<void> {
const state = rendererState;
rendererState = null;
if (!state) return;
try { state.cdp.close(); } catch {}
if (state.ownsChrome && state.chrome) {
try { state.chrome.kill("SIGTERM"); } catch {}
}
}
+102
View File
@@ -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
- 510× 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"
}
}
@@ -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";
@@ -19,6 +19,10 @@ describe("parseYouTubeVideoId", () => {
test("parses shorts URLs", () => {
expect(parseYouTubeVideoId(new URL("https://www.youtube.com/shorts/abc123"))).toBe("abc123");
});
test("parses embed URLs", () => {
expect(parseYouTubeVideoId(new URL("https://www.youtube.com/embed/abc123"))).toBe("abc123");
});
});
describe("parseYouTubeDescriptionChapters", () => {
@@ -52,6 +52,11 @@ export function parseYouTubeVideoId(url: URL): string | null {
return liveMatch[1];
}
const embedMatch = url.pathname.match(/^\/embed\/([^/?#]+)/);
if (embedMatch) {
return embedMatch[1];
}
return null;
}
+288 -5129
View File
File diff suppressed because one or more lines are too long
+288 -5129
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "baoyu-md",
"version": "0.1.0",
"version": "0.1.1",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
+90
View File
@@ -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![B](b.jpg)\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
View File
@@ -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);
}
+2
View File
@@ -5,6 +5,8 @@ export * from "./document.js";
export * from "./extend-config.js";
export * from "./html-builder.js";
export * from "./images.js";
export * from "./mermaid-preprocess.js";
export * from "./mermaid-utils.js";
export * from "./renderer.js";
export * from "./themes.js";
export * from "./types.js";
@@ -0,0 +1,134 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
preprocessMermaidInMarkdown,
type MermaidRenderFn,
} from "./mermaid-preprocess.ts";
function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mermaid-preprocess-test-"));
return fn(dir).finally(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
}
const stubPngBytes = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
const stubRender: MermaidRenderFn = async (_code, outPath) => {
await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
await fs.promises.writeFile(outPath, stubPngBytes);
};
test("preprocessMermaidInMarkdown skips when disabled", async () => {
await withTempDir(async (baseDir) => {
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
const result = await preprocessMermaidInMarkdown(markdown, {
baseDir,
renderFn: stubRender,
enabled: false,
});
assert.equal(result.markdown, markdown);
assert.equal(result.images.length, 0);
});
});
test("preprocessMermaidInMarkdown skips when renderFn is missing", async () => {
await withTempDir(async (baseDir) => {
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
const result = await preprocessMermaidInMarkdown(markdown, { baseDir });
assert.equal(result.markdown, markdown);
assert.equal(result.images.length, 0);
});
});
test("preprocessMermaidInMarkdown deduplicates identical blocks via hashed cache", async () => {
await withTempDir(async (baseDir) => {
const block = "```mermaid\ngraph TD\nA-->B\n```";
const markdown = `${block}\n\nsome text\n\n${block}\n\nother text\n\n\`\`\`mermaid\nflowchart LR\nX-->Y\n\`\`\``;
let renderCalls = 0;
const renderFn: MermaidRenderFn = async (code, outPath) => {
renderCalls += 1;
await stubRender(code, outPath, {});
};
const result = await preprocessMermaidInMarkdown(markdown, {
baseDir,
renderFn,
});
assert.equal(renderCalls, 2, "should render two distinct blocks");
assert.equal(result.images.length, 3, "all three blocks produce image entries");
const uniqueHashes = new Set(result.images.map((image) => image.hash));
assert.equal(uniqueHashes.size, 2);
const matches = result.markdown.match(/!\[Mermaid diagram\]/g) ?? [];
assert.equal(matches.length, 3);
assert.ok(!result.markdown.includes("```mermaid"));
});
});
test("preprocessMermaidInMarkdown reuses cached files (cached=true when file exists)", async () => {
await withTempDir(async (baseDir) => {
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
let renderCalls = 0;
const renderFn: MermaidRenderFn = async (code, outPath) => {
renderCalls += 1;
await stubRender(code, outPath, {});
};
const first = await preprocessMermaidInMarkdown(markdown, { baseDir, renderFn });
assert.equal(renderCalls, 1);
assert.equal(first.images[0]!.cached, false);
const second = await preprocessMermaidInMarkdown(markdown, { baseDir, renderFn });
assert.equal(renderCalls, 1, "second pass hits cache");
assert.equal(second.images[0]!.cached, true);
});
});
test("preprocessMermaidInMarkdown survives renderFn errors and keeps raw block", async () => {
await withTempDir(async (baseDir) => {
const markdown = "```mermaid\ninvalid syntax!!\n```\n\nrest";
const errors: string[] = [];
const failingRender: MermaidRenderFn = async () => {
throw new Error("render boom");
};
const result = await preprocessMermaidInMarkdown(markdown, {
baseDir,
renderFn: failingRender,
onError: (error) => {
errors.push(error instanceof Error ? error.message : String(error));
},
});
assert.equal(errors.length, 1);
assert.equal(errors[0], "render boom");
assert.equal(result.images.length, 0);
assert.ok(result.markdown.includes("```mermaid"));
assert.ok(result.markdown.includes("rest"));
});
});
test("preprocessMermaidInMarkdown writes PNGs under imgs/.mermaid-cache/", async () => {
await withTempDir(async (baseDir) => {
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
const result = await preprocessMermaidInMarkdown(markdown, {
baseDir,
renderFn: stubRender,
});
assert.equal(result.images.length, 1);
const image = result.images[0]!;
assert.ok(image.localPath.includes(path.join("imgs", ".mermaid-cache")));
assert.ok(image.mdRef.includes("imgs/.mermaid-cache/"));
assert.ok(fs.existsSync(image.localPath));
});
});
+131
View File
@@ -0,0 +1,131 @@
import fs from "node:fs";
import path from "node:path";
import {
MERMAID_VERSION,
extractMermaidBlocks,
hashMermaidCode,
replaceMermaidBlocks,
type MermaidBlock,
} from "./mermaid-utils.js";
export interface MermaidRenderOptions {
theme?: string;
scale?: number;
background?: string;
minWidth?: number;
}
export type MermaidRenderFn = (
code: string,
outputPath: string,
options: MermaidRenderOptions,
) => Promise<void>;
export interface MermaidPreprocessOptions extends MermaidRenderOptions {
baseDir: string;
imgSubdir?: string;
renderFn?: MermaidRenderFn;
enabled?: boolean;
alt?: string;
onError?: (error: unknown, block: MermaidBlock) => void;
}
export interface MermaidPreprocessedImage {
raw: string;
code: string;
hash: string;
localPath: string;
mdRef: string;
cached: boolean;
}
export interface MermaidPreprocessResult {
markdown: string;
images: MermaidPreprocessedImage[];
}
export async function preprocessMermaidInMarkdown(
markdown: string,
options: MermaidPreprocessOptions,
): Promise<MermaidPreprocessResult> {
const {
baseDir,
imgSubdir = "imgs/.mermaid-cache",
renderFn,
enabled = true,
theme,
scale,
background,
minWidth,
alt = "Mermaid diagram",
onError,
} = options;
if (!enabled || !renderFn) {
return { markdown, images: [] };
}
const blocks = extractMermaidBlocks(markdown);
if (blocks.length === 0) {
return { markdown, images: [] };
}
const cacheDir = path.resolve(baseDir, imgSubdir);
fs.mkdirSync(cacheDir, { recursive: true });
const replacements = new Map<string, string>();
const images: MermaidPreprocessedImage[] = [];
const renderedHashes = new Set<string>();
for (const block of blocks) {
const hash = hashMermaidCode({
code: block.code,
theme,
scale,
background,
minWidth,
version: MERMAID_VERSION,
});
const filename = `mermaid-${hash}.png`;
const localPath = path.join(cacheDir, filename);
const mdRef = `![${alt}](${path.posix.join(imgSubdir, filename)})`;
const cached = fs.existsSync(localPath);
if (!cached && !renderedHashes.has(hash)) {
try {
await renderFn(block.code, localPath, { theme, scale, background, minWidth });
renderedHashes.add(hash);
} catch (error) {
if (onError) {
onError(error, block);
} else {
console.error(
`[mermaid] render failed for block (hash ${hash}): ${
error instanceof Error ? error.message : String(error)
}`,
);
}
continue;
}
}
if (!fs.existsSync(localPath)) {
continue;
}
replacements.set(block.raw, mdRef);
images.push({
raw: block.raw,
code: block.code,
hash,
localPath,
mdRef,
cached,
});
}
const newMarkdown = replaceMermaidBlocks(markdown, replacements);
return { markdown: newMarkdown, images };
}
+115
View File
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
MERMAID_VERSION,
extractMermaidBlocks,
hashMermaidCode,
replaceMermaidBlocks,
} from "./mermaid-utils.ts";
test("extractMermaidBlocks finds fenced mermaid blocks at the top level", () => {
const markdown = `Intro
\`\`\`mermaid
graph TD
A --> B
\`\`\`
Outro`;
const blocks = extractMermaidBlocks(markdown);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]!.code.trim(), "graph TD\n A --> B");
assert.equal(blocks[0]!.infoString, "");
assert.ok(blocks[0]!.raw.includes("```mermaid"));
});
test("extractMermaidBlocks preserves info-string suffixes", () => {
const markdown = "```mermaid theme=dark\nflowchart LR\n A --> B\n```";
const blocks = extractMermaidBlocks(markdown);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]!.infoString, "theme=dark");
});
test("extractMermaidBlocks finds blocks nested inside lists", () => {
const markdown = `Steps:
1. First, render the diagram:
\`\`\`mermaid
sequenceDiagram
Alice->>Bob: Hello
\`\`\`
2. Then do something else.`;
const blocks = extractMermaidBlocks(markdown);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]!.code.includes("sequenceDiagram"), true);
});
test("extractMermaidBlocks ignores non-mermaid fences and empty blocks", () => {
const markdown = `\`\`\`ts
const x = 1;
\`\`\`
\`\`\`mermaid
\`\`\`
\`\`\`mermaidsomething
not a real lang
\`\`\``;
const blocks = extractMermaidBlocks(markdown);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]!.infoString, "something");
assert.equal(blocks[0]!.code.trim(), "not a real lang");
});
test("replaceMermaidBlocks performs exact string replacement", () => {
const markdown = "before\n\n```mermaid\ngraph TD\nA-->B\n```\n\nafter";
const blocks = extractMermaidBlocks(markdown);
const map = new Map([[blocks[0]!.raw, "![diagram](img.png)"]]);
const replaced = replaceMermaidBlocks(markdown, map);
assert.equal(replaced, "before\n\n![diagram](img.png)\n\nafter");
});
test("replaceMermaidBlocks leaves markdown unchanged when no replacements match", () => {
const markdown = "hello\n\nworld";
const replaced = replaceMermaidBlocks(markdown, new Map([["nope", "x"]]));
assert.equal(replaced, markdown);
});
test("hashMermaidCode is stable for the same inputs", () => {
const a = hashMermaidCode({ code: "graph TD\nA-->B" });
const b = hashMermaidCode({ code: "graph TD\nA-->B" });
assert.equal(a, b);
assert.equal(a.length, 12);
});
test("hashMermaidCode defaults to 2x render scale", () => {
const implicit = hashMermaidCode({ code: "graph TD\nA-->B" });
const explicit = hashMermaidCode({ code: "graph TD\nA-->B", scale: 2 });
assert.equal(implicit, explicit);
});
test("hashMermaidCode ignores trailing whitespace", () => {
const a = hashMermaidCode({ code: "graph TD\nA-->B" });
const b = hashMermaidCode({ code: "graph TD\nA-->B \n\n" });
assert.equal(a, b);
});
test("hashMermaidCode reflects theme/scale/background/version changes", () => {
const base = hashMermaidCode({ code: "graph TD\nA-->B" });
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", theme: "dark" }));
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", scale: 3 }));
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", minWidth: 860 }));
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", background: "#000" }));
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", version: "x.y.z" }));
});
test("MERMAID_VERSION matches the vendored bundle (10.x)", () => {
assert.match(MERMAID_VERSION, /^10\./);
});
+74
View File
@@ -0,0 +1,74 @@
import { createHash } from "node:crypto";
import { Marked, type Tokens } from "marked";
export const MERMAID_VERSION = "10.9.1";
export interface MermaidBlock {
raw: string;
code: string;
infoString: string;
}
export interface HashMermaidInput {
code: string;
theme?: string;
scale?: number;
background?: string;
minWidth?: number;
version?: string;
}
export function extractMermaidBlocks(markdown: string): MermaidBlock[] {
const blocks: MermaidBlock[] = [];
const lexer = new Marked({ breaks: true });
const tokens = lexer.lexer(markdown);
walkTokens(tokens, (token) => {
if (token.type !== "code") return;
const codeToken = token as Tokens.Code;
const lang = (codeToken.lang ?? "").trim();
if (!lang.startsWith("mermaid")) return;
const infoString = lang.slice("mermaid".length).trim();
const code = codeToken.text ?? "";
if (code.trim() === "") return;
blocks.push({
raw: codeToken.raw,
code,
infoString,
});
});
return blocks;
}
export function replaceMermaidBlocks(
markdown: string,
replacements: Map<string, string>,
): string {
let result = markdown;
for (const [raw, replacement] of replacements) {
if (!raw || replacement === undefined) continue;
result = result.split(raw).join(replacement);
}
return result;
}
export function hashMermaidCode(input: HashMermaidInput): string {
const payload = JSON.stringify({
code: input.code.replace(/\s+$/g, ""),
theme: input.theme ?? "default",
scale: input.scale ?? 2,
minWidth: input.minWidth ?? null,
background: input.background ?? "white",
version: input.version ?? MERMAID_VERSION,
});
return createHash("sha256").update(payload).digest("hex").slice(0, 12);
}
type AnyToken = { type?: string; tokens?: AnyToken[]; items?: AnyToken[] };
function walkTokens(tokens: AnyToken[], visit: (token: AnyToken) => void): void {
for (const token of tokens) {
visit(token);
if (Array.isArray(token.tokens)) walkTokens(token.tokens, visit);
if (Array.isArray(token.items)) walkTokens(token.items, visit);
}
}
+1 -15
View File
@@ -119,7 +119,6 @@ function wrapInlineCode(value: string): string {
export function initRenderer(opts: IOpts = {}): RendererAPI {
const footnotes: [number, string, string][] = [];
let footnoteIndex = 0;
let codeIndex = 0;
const listOrderedStack: boolean[] = [];
const listCounters: number[] = [];
const isBrowser = typeof window !== "undefined";
@@ -208,20 +207,7 @@ export function initRenderer(opts: IOpts = {}): RendererAPI {
code({ text, lang = "" }: Tokens.Code): string {
if (lang.startsWith("mermaid")) {
if (isBrowser) {
clearTimeout(codeIndex as any);
codeIndex = setTimeout(async () => {
const windowRef = typeof window !== "undefined" ? (window as any) : undefined;
if (windowRef && windowRef.mermaid) {
const mermaid = windowRef.mermaid;
await mermaid.run();
} else {
const mermaid = await import("mermaid");
await mermaid.default.run();
}
}, 0) as any as number;
}
return `<pre class="mermaid">${text}</pre>`;
return `<pre class="mermaid">${escapeHtml(text)}</pre>`;
}
const langText = lang.split(" ")[0];
const isLanguageRegistered = hljs.getLanguage(langText);
+41 -21
View File
@@ -9,27 +9,33 @@ import { pathToFileURL } from "node:url";
async function main() {
const options = parseArgs(process.argv.slice(2));
const packageDir = path.resolve(options.packageDir);
const entryPath = path.resolve(packageDir, options.entry);
const outDir = path.resolve(packageDir, options.outDir);
await fs.rm(outDir, { recursive: true, force: true });
await fs.mkdir(outDir, { recursive: true });
const bun = resolveBunRuntime();
runBuild(bun, {
entryPath,
external: options.external,
format: "esm",
outfile: path.join(outDir, "index.js"),
});
const cjsOutfile = path.join(outDir, "index.cjs");
runBuild(bun, {
entryPath,
external: options.external,
format: "cjs",
outfile: cjsOutfile,
});
await scrubCommonJsSourceFileUrls(cjsOutfile, packageDir);
const entries = options.entries.length > 0
? options.entries
: [{ source: options.entry, name: "index" }];
for (const entry of entries) {
const entryPath = path.resolve(packageDir, entry.source);
runBuild(bun, {
entryPath,
external: options.external,
format: "esm",
outfile: path.join(outDir, `${entry.name}.js`),
});
const cjsOutfile = path.join(outDir, `${entry.name}.cjs`);
runBuild(bun, {
entryPath,
external: options.external,
format: "cjs",
outfile: cjsOutfile,
});
await scrubCommonJsSourceFileUrls(cjsOutfile, packageDir);
}
for (const asset of options.assets) {
const source = path.resolve(packageDir, asset.source);
@@ -45,6 +51,7 @@ function parseArgs(argv) {
outDir: "dist",
external: [],
assets: [],
entries: [],
};
for (let index = 0; index < argv.length; index += 1) {
@@ -57,6 +64,10 @@ function parseArgs(argv) {
options.entry = readArgValue(argv, ++index, arg);
continue;
}
if (arg === "--entry-out") {
options.entries.push(parseEntryOutArg(readArgValue(argv, ++index, arg)));
continue;
}
if (arg === "--out-dir") {
options.outDir = readArgValue(argv, ++index, arg);
continue;
@@ -79,6 +90,14 @@ function parseArgs(argv) {
return options;
}
function parseEntryOutArg(value) {
const [source, name] = value.split(":");
if (!source || !name) {
throw new Error(`Invalid --entry-out value: ${value} (expected <source>:<name>)`);
}
return { source, name };
}
function readArgValue(argv, index, flag) {
const value = argv[index];
if (!value) {
@@ -153,12 +172,13 @@ function printUsage() {
console.log(`Usage: build-shared-package.mjs [options]
Options:
--package-dir <dir> Package root (default: current directory)
--entry <file> Entry file relative to package root (default: src/index.ts)
--out-dir <dir> Output directory relative to package root (default: dist)
--external <name> Leave a module external (can be repeated)
--asset <src[:dst]> Copy an asset directory/file into out-dir (can be repeated)
-h, --help Show help`);
--package-dir <dir> Package root (default: current directory)
--entry <file> Entry file relative to package root (default: src/index.ts)
--entry-out <src:name> Add an entry that emits <name>.js / <name>.cjs (repeatable)
--out-dir <dir> Output directory relative to package root (default: dist)
--external <name> Leave a module external (can be repeated)
--asset <src[:dst]> Copy an asset directory/file into out-dir (can be repeated)
-h, --help Show help`);
}
main().catch((error) => {
-19
View File
@@ -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" "$@"
+59
View File
@@ -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
+5 -2
View File
@@ -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,8 @@ 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.
- **Cursor (`GenerateImage`)** — if the runtime exposes a native `GenerateImage` tool, you are running inside Cursor and it outranks any non-native skill the same way Codex `imagegen` does. Two hard caveats: (a) it has no aspect-ratio parameter — state the target aspect ratio / dimensions explicitly in the prompt text passed as `description`; (b) it does not accept an output directory — it saves to a tool-managed location, so after generation copy/move the file to the skill's expected output path (e.g., `outputs/.../NN-xxx.png`). Reference images go in `reference_image_paths`.
- **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.
@@ -42,7 +44,7 @@ Setting `preferred_image_backend: ask` forces the step-3 prompt every run regard
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
Concrete tool names (`imagegen`, `GenerateImage`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
## Batch Generation Policy
@@ -185,6 +187,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`.
+6 -2
View File
@@ -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,8 @@ 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.
- **Cursor (`GenerateImage`)** — if the runtime exposes a native `GenerateImage` tool, you are running inside Cursor and it outranks any non-native skill the same way Codex `imagegen` does. Two hard caveats: (a) it has no aspect-ratio parameter — state the target aspect ratio / dimensions explicitly in the prompt text passed as `description`; (b) it does not accept an output directory — it saves to a tool-managed location, so after generation copy/move the file to the skill's expected output path (e.g., `outputs/.../NN-xxx.png`). Reference images go in `reference_image_paths`.
- **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.
@@ -46,7 +48,7 @@ Setting `preferred_image_backend: ask` forces the step-3 prompt every run regard
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
Concrete tool names (`imagegen`, `GenerateImage`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
## Batch Generation Policy
@@ -247,6 +249,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`.
+5 -16
View File
@@ -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,8 @@ 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.
- **Cursor (`GenerateImage`)** — if the runtime exposes a native `GenerateImage` tool, you are running inside Cursor and it outranks any non-native skill the same way Codex `imagegen` does. Two hard caveats: (a) it has no aspect-ratio parameter — state the target aspect ratio / dimensions explicitly in the prompt text passed as `description`; (b) it does not accept an output directory — it saves to a tool-managed location, so after generation copy/move the file to the skill's expected output path (e.g., `outputs/.../NN-xxx.png`). Reference images go in `reference_image_paths`.
- **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.
@@ -55,7 +44,7 @@ Setting `preferred_image_backend: ask` forces the step-3 prompt every run regard
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
Concrete tool names (`imagegen`, `GenerateImage`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
## Confirmation Policy
@@ -228,7 +217,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 -1
View File
@@ -1,7 +1,7 @@
---
name: baoyu-danger-gemini-web
description: Generates images and text via reverse-engineered Gemini Web API. Supports text generation, image generation from prompts, reference images for vision input, and multi-turn conversations. Use when other skills need image generation backend, or when user requests "generate image with Gemini", "Gemini text generation", or needs vision-capable AI generation.
version: 1.56.1
version: 1.56.2
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-danger-gemini-web
@@ -5,11 +5,11 @@
"": {
"name": "baoyu-danger-gemini-web-scripts",
"dependencies": {
"baoyu-chrome-cdp": "^0.1.0",
"baoyu-chrome-cdp": "^0.1.1",
},
},
},
"packages": {
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@0.1.0", "", {}, "sha512-Hk1yolVrlIlzMCKXjc21yAJP0dttun+SaPRcW7HL9/mmwZ9kedQ6fFgxf8M91I+/Fe348sPbdYVhSAmYHzVunQ=="],
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@0.1.1", "", {}, "sha512-OR3PQ7NzJDykCXl20TnkZuwvNQ0hsVC2czje93P72xQaA3vKOyPN/Q1CwEgKuYzP7Rka4Fdh4HvURj6AoNR7Tg=="],
}
}
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"dependencies": {
"baoyu-chrome-cdp": "^0.1.0"
"baoyu-chrome-cdp": "^0.1.1"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: baoyu-danger-x-to-markdown
description: Converts X (Twitter) tweets and articles to markdown with YAML front matter. Uses reverse-engineered API requiring user consent. Use when user mentions "X to markdown", "tweet to markdown", "save tweet", or provides x.com/twitter.com URLs for conversion.
version: 1.56.1
version: 1.117.3
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-danger-x-to-markdown
@@ -5,11 +5,11 @@
"": {
"name": "baoyu-danger-x-to-markdown-scripts",
"dependencies": {
"baoyu-chrome-cdp": "^0.1.0",
"baoyu-chrome-cdp": "^0.1.1",
},
},
},
"packages": {
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@0.1.0", "", {}, "sha512-Hk1yolVrlIlzMCKXjc21yAJP0dttun+SaPRcW7HL9/mmwZ9kedQ6fFgxf8M91I+/Fe348sPbdYVhSAmYHzVunQ=="],
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@0.1.1", "", {}, "sha512-OR3PQ7NzJDykCXl20TnkZuwvNQ0hsVC2czje93P72xQaA3vKOyPN/Q1CwEgKuYzP7Rka4Fdh4HvURj6AoNR7Tg=="],
}
}
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"dependencies": {
"baoyu-chrome-cdp": "^0.1.0"
"baoyu-chrome-cdp": "^0.1.1"
}
}
+30 -14
View File
@@ -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
description: AI image generation with OpenAI GPT Image 2, Azure OpenAI, Google, OpenRouter, DashScope, Z.AI GLM-Image, MiniMax, Jimeng, Seedream, Replicate and Agnes 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.1.0
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-gen
@@ -13,7 +13,7 @@ metadata:
# Image Generation (AI SDK)
Official API-based image generation. Supports OpenAI GPT Image 2, Azure OpenAI, Google, OpenRouter, DashScope (阿里通义万象), Z.AI GLM-Image, MiniMax, Jimeng (即梦), Seedream (豆包) and Replicate.
Official API-based image generation. Supports OpenAI GPT Image 2, Azure OpenAI, Google, OpenRouter, DashScope (阿里通义万象), Z.AI GLM-Image, MiniMax, Jimeng (即梦), Seedream (豆包), Replicate and Agnes.
## User Input Tools
@@ -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\|agnes` | 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) |
@@ -129,7 +136,7 @@ When the user wants a person/object preserved from reference images:
| `REPLICATE_API_TOKEN` | Replicate API token |
| `JIMENG_ACCESS_KEY_ID`, `JIMENG_SECRET_ACCESS_KEY` | Jimeng (即梦) Volcengine credentials |
| `ARK_API_KEY` | Seedream (豆包) Volcengine ARK API key |
| `<PROVIDER>_IMAGE_MODEL` | Per-provider model override (`OPENAI_IMAGE_MODEL`, `GOOGLE_IMAGE_MODEL`, `DASHSCOPE_IMAGE_MODEL`, `ZAI_IMAGE_MODEL`/`BIGMODEL_IMAGE_MODEL`, `MINIMAX_IMAGE_MODEL`, `OPENROUTER_IMAGE_MODEL`, `REPLICATE_IMAGE_MODEL`, `JIMENG_IMAGE_MODEL`, `SEEDREAM_IMAGE_MODEL`) |
| `<PROVIDER>_IMAGE_MODEL` | Per-provider model override (`OPENAI_IMAGE_MODEL`, `GOOGLE_IMAGE_MODEL`, `DASHSCOPE_IMAGE_MODEL`, `ZAI_IMAGE_MODEL`/`BIGMODEL_IMAGE_MODEL`, `MINIMAX_IMAGE_MODEL`, `OPENROUTER_IMAGE_MODEL`, `REPLICATE_IMAGE_MODEL`, `JIMENG_IMAGE_MODEL`, `SEEDREAM_IMAGE_MODEL`, `AGNES_IMAGE_MODEL`) |
| `AZURE_OPENAI_DEPLOYMENT` (alias `AZURE_OPENAI_IMAGE_MODEL`) | Azure default deployment |
| `<PROVIDER>_BASE_URL` | Per-provider endpoint override |
| `AZURE_API_VERSION` | Azure image API version (default `2025-04-01-preview`) |
@@ -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
@@ -167,7 +179,7 @@ For OpenAI, the built-in default is `gpt-image-2`. `gpt-image-1.5`, `gpt-image-1
For Azure, `--model` / `default_model.azure` is the Azure deployment name. `AZURE_OPENAI_DEPLOYMENT` is the preferred env var; `AZURE_OPENAI_IMAGE_MODEL` is kept as a backward-compatible alias. If your Azure deployment is named after the underlying model, use `gpt-image-2`; otherwise use the exact custom deployment name.
EXTEND.md overrides env vars: if EXTEND.md sets `default_model.google: "gemini-3-pro-image-preview"` and the env var sets `GOOGLE_IMAGE_MODEL=gemini-3.1-flash-image-preview`, EXTEND.md wins.
EXTEND.md overrides env vars: if EXTEND.md sets `default_model.google: "gemini-3-pro-image"` and the env var sets `GOOGLE_IMAGE_MODEL=gemini-3.1-flash-image`, EXTEND.md wins.
**Display model info before each generation**:
@@ -194,13 +206,16 @@ 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` |
| Agnes (agnes-image-2.1-flash, reference-image support) | `references/providers/agnes.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)
1. `--ref` provided + no `--provider` → auto-select Google → OpenAI → Azure → OpenRouter → Replicate → Seedream → MiniMax → Agnes (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/codex-cli/agnes)
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
4. Multiple keys → default priority: Google → OpenAI → Azure → OpenRouter → DashScope → Z.AI → MiniMax → Replicate → Jimeng → Seedream → Agnes
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 +247,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.
@@ -267,6 +282,7 @@ If `--provider openai --model gpt-image-2` fails because `OPENAI_API_KEY` is mis
| `references/providers/minimax.md` | MiniMax image-01 + subject reference |
| `references/providers/openrouter.md` | OpenRouter multimodal flow |
| `references/providers/replicate.md` | Replicate supported families + guardrails |
| `references/providers/agnes.md` | Agnes (agnes-image-2.1-flash) sizing, refs, and limits |
| `references/config/preferences-schema.md` | EXTEND.md schema |
| `references/config/first-time-setup.md` | First-time setup flow |
@@ -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
@@ -59,6 +59,8 @@ options:
description: "MiniMax image generation with subject-reference character workflows"
- label: "Replicate"
description: "Curated Replicate image families - nano-banana-2, Seedream, and Wan image models"
- label: "Agnes"
description: "Sapiens AI Agnes - optimized for high information density, complex layouts, reference-image support"
```
### Question 2: Default Google Model
@@ -69,9 +71,9 @@ Only show if user selected Google or auto-detect (no explicit provider).
header: "Google Model"
question: "Default Google image generation model?"
options:
- label: "gemini-3-pro-image-preview (Recommended)"
- label: "gemini-3-pro-image (Recommended)"
description: "Highest quality, best for production use"
- label: "gemini-3.1-flash-image-preview"
- label: "gemini-3.1-flash-image"
description: "Fast generation, good quality, lower cost"
- label: "gemini-3-flash-preview"
description: "Fast generation, balanced quality and speed"
@@ -85,7 +87,7 @@ Only show if user selected OpenRouter.
header: "OpenRouter Model"
question: "Default OpenRouter image generation model?"
options:
- label: "google/gemini-3.1-flash-image-preview (Recommended)"
- label: "google/gemini-3.1-flash-image (Recommended)"
description: "Best general-purpose OpenRouter image model with reference-image workflows"
- label: "google/gemini-2.5-flash-image-preview"
description: "Fast Gemini preview model on OpenRouter"
@@ -187,6 +189,7 @@ default_model:
zai: [selected Z.AI model or null]
minimax: [selected minimax model or null]
replicate: null
agnes: null
---
```
@@ -202,9 +205,9 @@ When EXTEND.md exists but `default_model.[current_provider]` is null, ask ONLY t
header: "Google Model"
question: "Choose a default Google image generation model?"
options:
- label: "gemini-3-pro-image-preview (Recommended)"
- label: "gemini-3-pro-image (Recommended)"
description: "Highest quality, best for production use"
- label: "gemini-3.1-flash-image-preview"
- label: "gemini-3.1-flash-image"
description: "Fast generation, good quality, lower cost"
- label: "gemini-3-flash-preview"
description: "Fast generation, balanced quality and speed"
@@ -249,7 +252,7 @@ Notes for Azure setup:
header: "OpenRouter Model"
question: "Choose a default OpenRouter image generation model?"
options:
- label: "google/gemini-3.1-flash-image-preview (Recommended)"
- label: "google/gemini-3.1-flash-image (Recommended)"
description: "Recommended for image output and reference-image edits"
- label: "google/gemini-2.5-flash-image-preview"
description: "Fast preview-oriented image generation"
@@ -358,6 +361,7 @@ default_model:
zai: [value or null]
minimax: [value or null]
replicate: [value or null]
agnes: [value or null]
```
Only set the selected provider's model; leave others as their current value or null.
@@ -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|agnes|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)
@@ -22,14 +22,16 @@ default_image_size: null # 1K|2K|4K|null (Google/OpenRouter, overrides qualit
default_image_api_dialect: null # openai-native|ratio-metadata|null (OpenAI-compatible gateways; null = use env/default)
default_model:
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
google: null # e.g., "gemini-3-pro-image", "gemini-3.1-flash-image"
openai: null # e.g., "gpt-image-2", "gpt-image-1.5", "gpt-image-1"
azure: null # Azure deployment name, e.g., "gpt-image-2" or "image-prod"
openrouter: null # e.g., "google/gemini-3.1-flash-image-preview"
openrouter: null # e.g., "google/gemini-3.1-flash-image"
dashscope: null # e.g., "qwen-image-2.0-pro"
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"
agnes: null # e.g., "agnes-image-2.1-flash"
batch:
max_workers: 10
@@ -58,6 +60,12 @@ batch:
minimax:
concurrency: 3
start_interval_ms: 1100
codex-cli:
concurrency: 1
start_interval_ms: 2000
agnes:
concurrency: 3
start_interval_ms: 1100
---
```
@@ -79,6 +87,8 @@ 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) |
| `default_model.agnes` | string\|null | null | Agnes default 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 |
@@ -105,14 +115,15 @@ default_aspect_ratio: "16:9"
default_image_size: 2K
default_image_api_dialect: null
default_model:
google: "gemini-3-pro-image-preview"
google: "gemini-3-pro-image"
openai: "gpt-image-2"
azure: "gpt-image-2"
openrouter: "google/gemini-3.1-flash-image-preview"
openrouter: "google/gemini-3.1-flash-image"
dashscope: "qwen-image-2.0-pro"
zai: "glm-image"
minimax: "image-01"
replicate: "google/nano-banana-2"
agnes: "agnes-image-2.1-flash"
batch:
max_workers: 10
provider_limits:
@@ -131,5 +142,8 @@ batch:
minimax:
concurrency: 3
start_interval_ms: 1100
agnes:
concurrency: 3
start_interval_ms: 1100
---
```
@@ -0,0 +1,55 @@
# Sapiens AI Agnes Image
Read when the user picks `--provider agnes` or sets `default_model.agnes`. Default model is `agnes-image-2.1-flash`.
## Models
**`agnes-image-2.1-flash`** (only model)
- Text-to-image and image-to-image (with `--ref`) in a single `/images/generations` endpoint
- Supports reference images as public URLs or Data URI (base64)
- Optimized for high information density, complex layouts, and rich details
- Size rules: both dimensions divisible by 32 (720px exception), long edge ≤ 2048, total pixels ≤ ~4M
- Default size: `1024x1024`; custom `--size` supports arbitrary WxH within the above rules
- `--ar` supported: computed as 2048-based size (long edge ≤ 2048, short edge proportional, both snapped to 32px); `1:1` special-cased to `1024x1024`
## Response Format
- The sync API always returns a URL
- Default (`--response-format file`): downloads the image and saves as `.png`
- Pass `--response-format url`: writes the URL string to `.txt` instead
## `--n` Behavior
The Agnes API returns a single image per request regardless of the `n` parameter. Passing `--n > 1` triggers a local error from `validateArgs` before any API call is made.
## Behavior Notes
- API key required: `AGNES_API_KEY`
- Base URL: `https://apihub.agnes-ai.com/v1` (override with `AGNES_BASE_URL`)
- Model override: `AGNES_IMAGE_MODEL` env
- `response_format` is always embedded in `extra_body` (not at request top level)
- Reference images: local files converted to Data URI base64 inline; remote URLs passed through
- Rate limit defaults: concurrency=3, startIntervalMs=1100 (override via `BAOYU_IMAGE_GEN_AGNES_CONCURRENCY` / `BAOYU_IMAGE_GEN_AGNES_START_INTERVAL_MS`)
- Timeout: 120s per request
## Size Resolution
- `--size <WxH>` wins over `--ar`
- `--ar` maps to a concrete size using the algorithm: long edge ≤ 2048, short edge proportional, both dimensions snapped to 32px
- `--ar 1:1` is special-cased to `1024x1024`
### Common `--ar` Results
| Aspect Ratio | Result |
|--------------|--------|
| `1:1` | `1024x1024` |
| `16:9` | `2048x1152` |
| `4:3` | `2048x1536` |
| `3:2` | `2048x1376` |
| `21:9` | `2048x896` |
| Unlisted ratio | Computed on the fly (portrait mirror swaps width/height) |
## Official References
- [Agnes AIGC API Hub](https://apihub.agnes-ai.com)
@@ -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 510× 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: 510× 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.
@@ -1,12 +1,12 @@
# OpenRouter
Read when the user picks `--provider openrouter`. Default model is `google/gemini-3.1-flash-image-preview`.
Read when the user picks `--provider openrouter`. Default model is `google/gemini-3.1-flash-image`.
## Common Models
Use full OpenRouter model IDs:
- `google/gemini-3.1-flash-image-preview` (recommended — supports image output and reference-image workflows)
- `google/gemini-3.1-flash-image` (recommended — supports image output and reference-image workflows)
- `google/gemini-2.5-flash-image-preview`
- `black-forest-labs/flux.2-pro`
- Any other OpenRouter image-capable model ID
@@ -34,13 +34,13 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider a
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic landscape" --image out.png --provider openai --model gpt-image-2 --size 3840x2160
# Google with explicit model
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image-preview --ref source.png
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image --ref source.png
# OpenRouter (recommended default)
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openrouter
# OpenRouter with reference
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider openrouter --model google/gemini-3.1-flash-image --ref source.png
# DashScope (default model)
${BUN_X} {baseDir}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
@@ -77,8 +77,28 @@ ${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
# Agnes (default model)
${BUN_X} {baseDir}/scripts/main.ts --prompt "A detailed infographic" --image out.png --provider agnes
# Agnes with aspect ratio and URL output
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic scene" --image out.txt --provider agnes --ar 16:9 --response-format url
# Agnes with reference image
${BUN_X} {baseDir}/scripts/main.ts --prompt "Apply this style" --image out.png --provider agnes --ref source.png
```
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 510× 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
View File
@@ -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 };
}
+23 -2
View File
@@ -8,6 +8,7 @@ import type { CliArgs, ExtendConfig } from "./types.ts";
import {
createTaskArgs,
detectProvider,
ensureDir,
getConfiguredMaxWorkers,
getConfiguredProviderRateLimits,
getWorkerCount,
@@ -142,7 +143,7 @@ default_aspect_ratio: '16:9'
default_image_size: 2K
default_image_api_dialect: ratio-metadata
default_model:
google: gemini-3-pro-image-preview
google: gemini-3-pro-image
openai: gpt-image-2
zai: glm-image
azure: image-prod
@@ -174,7 +175,7 @@ batch:
assert.equal(config.default_aspect_ratio, "16:9");
assert.equal(config.default_image_size, "2K");
assert.equal(config.default_image_api_dialect, "ratio-metadata");
assert.equal(config.default_model?.google, "gemini-3-pro-image-preview");
assert.equal(config.default_model?.google, "gemini-3-pro-image");
assert.equal(config.default_model?.openai, "gpt-image-2");
assert.equal(config.default_model?.zai, "glm-image");
assert.equal(config.default_model?.azure, "image-prod");
@@ -201,6 +202,26 @@ batch:
});
});
test("ensureDir creates nested dirs, is idempotent on an existing dir, and rethrows for a non-directory", async (t: TestContext) => {
const root = await makeTempDir("ensure-dir-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const nested = path.join(root, "a", "b", "c");
await ensureDir(nested);
assert.equal((await fs.stat(nested)).isDirectory(), true);
// Idempotent: a second call on an existing directory must not throw. This is the
// Bun-on-Windows regression the helper guards against (Bun wrongly throws EEXIST
// for mkdir(existingDir, { recursive: true })).
await ensureDir(nested);
// Rethrows when the path exists but is a file rather than a directory, so a real
// EEXIST against a non-directory is not silently swallowed.
const filePath = path.join(root, "not-a-dir");
await fs.writeFile(filePath, "x");
await assert.rejects(() => ensureDir(filePath));
});
test("loadExtendConfig renames legacy EXTEND.md when the new path is missing", async () => {
const root = await makeTempDir("baoyu-image-gen-extend-");
const cwd = path.join(root, "project");
+76 -18
View File
@@ -2,7 +2,7 @@ import path from "node:path";
import process from "node:process";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { access, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import type {
BatchFile,
BatchTaskInput,
@@ -64,6 +64,8 @@ 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 },
agnes: { concurrency: 3, startIntervalMs: 1100 },
};
function printUsage(): void {
@@ -78,13 +80,14 @@ 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|agnes 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)
--quality normal|2k Quality preset (default: 2k)
--imageSize 1K|2K|4K Image size for Google/OpenRouter (default: from quality)
--imageApiDialect <id> OpenAI-compatible image dialect: openai-native|ratio-metadata
--response-format file|url Output mode: file (download image, default) or url (return URL text)
--ref <files...> Reference images (Google, OpenAI, Azure, OpenRouter, Replicate supported families, MiniMax, Seedream 4.0/4.5/5.0, or DashScope wan2.7-image*)
--n <count> Number of images for the current task (default: 1; Replicate currently requires 1)
--json JSON output
@@ -125,8 +128,8 @@ Environment variables:
JIMENG_SECRET_ACCESS_KEY Jimeng Secret Access Key
ARK_API_KEY Seedream/Ark API key
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-2)
OPENROUTER_IMAGE_MODEL Default OpenRouter model (google/gemini-3.1-flash-image-preview)
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
OPENROUTER_IMAGE_MODEL Default OpenRouter model (google/gemini-3.1-flash-image)
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image)
DASHSCOPE_IMAGE_MODEL Default DashScope model (qwen-image-2.0-pro)
ZAI_IMAGE_MODEL Default Z.AI model (glm-image)
BIGMODEL_IMAGE_MODEL Backward-compatible alias for Z.AI model (glm-image)
@@ -154,8 +157,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`);
}
@@ -174,6 +182,7 @@ export function parseArgs(argv: string[]): CliArgs {
imageSize: null,
imageSizeSource: null,
imageApiDialect: null,
responseFormat: null,
referenceImages: [],
n: 1,
batchFile: null,
@@ -258,7 +267,9 @@ export function parseArgs(argv: string[]): CliArgs {
v !== "replicate" &&
v !== "jimeng" &&
v !== "seedream" &&
v !== "azure"
v !== "azure" &&
v !== "codex-cli" &&
v !== "agnes"
) {
throw new Error(`Invalid provider: ${v}`);
}
@@ -312,6 +323,13 @@ export function parseArgs(argv: string[]): CliArgs {
continue;
}
if (a === "--response-format") {
const v = argv[++i];
if (v !== "file" && v !== "url") throw new Error(`Invalid response-format: ${v}`);
out.responseFormat = v;
continue;
}
if (a === "--ref" || a === "--reference") {
const { items, next } = takeMany(i);
if (items.length === 0) throw new Error(`Missing files for ${a}`);
@@ -430,6 +448,8 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
jimeng: null,
seedream: null,
azure: null,
"codex-cli": null,
agnes: null,
};
currentKey = "default_model";
currentProvider = null;
@@ -458,7 +478,9 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
key === "replicate" ||
key === "jimeng" ||
key === "seedream" ||
key === "azure"
key === "azure" ||
key === "codex-cli" ||
key === "agnes"
)
) {
config.batch ??= {};
@@ -477,7 +499,9 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
key === "replicate" ||
key === "jimeng" ||
key === "seedream" ||
key === "azure"
key === "azure" ||
key === "codex-cli" ||
key === "agnes"
)
) {
const cleaned = value.replace(/['"]/g, "");
@@ -539,11 +563,25 @@ async function exists(filePath: string): Promise<boolean> {
}
}
export async function ensureDir(dir: string): Promise<void> {
try {
await mkdir(dir, { recursive: true });
} catch (err) {
// Bun on Windows incorrectly throws EEXIST for mkdir(dir, { recursive: true })
// when the directory already exists, contradicting Node's documented contract
// (mkdir with recursive: true resolves silently for an existing directory).
// Tolerate EEXIST only when the path really is a directory; rethrow otherwise
// (e.g. EEXIST raised because the path points at an existing file).
if ((err as { code?: string }).code !== "EEXIST") throw err;
if (!(await stat(dir)).isDirectory()) throw err;
}
}
async function migrateLegacyExtendConfig(cwd: string, home: string): Promise<void> {
for (const { current, legacy } of getExtendConfigPathPairs(cwd, home)) {
const [hasCurrent, hasLegacy] = await Promise.all([exists(current), exists(legacy)]);
if (hasCurrent || !hasLegacy) continue;
await mkdir(path.dirname(current), { recursive: true });
await ensureDir(path.dirname(current));
await rename(legacy, current);
}
}
@@ -630,10 +668,12 @@ 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"] },
agnes: { ...DEFAULT_PROVIDER_RATE_LIMITS.agnes },
};
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", "agnes"] as Provider[]) {
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase().replace(/-/g, "_")}`;
const extendLimit = extendConfig.batch?.provider_limits?.[provider];
configured[provider] = {
concurrency:
@@ -685,6 +725,7 @@ function inferProviderFromModel(model: string | null): Provider | null {
if (normalized.includes("seedream") || normalized.includes("seededit")) return "seedream";
if (normalized === "image-01" || normalized === "image-01-live") return "minimax";
if (normalized === "glm-image" || normalized === "cogview-4-250304") return "zai";
if (normalized.includes("agnes-image")) return "agnes";
return null;
}
@@ -699,10 +740,12 @@ 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" &&
args.provider !== "agnes"
) {
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, --provider codex-cli (Codex image_gen with references), or --provider agnes (Agnes Image)."
);
}
@@ -718,6 +761,7 @@ export function detectProvider(args: CliArgs): Provider {
const hasReplicate = !!process.env.REPLICATE_API_TOKEN;
const hasJimeng = !!(process.env.JIMENG_ACCESS_KEY_ID && process.env.JIMENG_SECRET_ACCESS_KEY);
const hasSeedream = !!process.env.ARK_API_KEY;
const hasAgnes = !!process.env.AGNES_API_KEY;
const modelProvider = inferProviderFromModel(args.model);
if (modelProvider === "seedream") {
@@ -741,6 +785,13 @@ export function detectProvider(args: CliArgs): Provider {
return "zai";
}
if (modelProvider === "agnes") {
if (!hasAgnes) {
throw new Error("Model looks like an Agnes image model, but AGNES_API_KEY is not set.");
}
return "agnes";
}
if (args.referenceImages.length > 0) {
if (hasGoogle) return "google";
if (hasOpenai) return "openai";
@@ -749,8 +800,9 @@ export function detectProvider(args: CliArgs): Provider {
if (hasReplicate) return "replicate";
if (hasSeedream) return "seedream";
if (hasMinimax) return "minimax";
if (hasAgnes) return "agnes";
throw new Error(
"Reference images require Google, OpenAI, Azure, OpenRouter, Replicate, supported Seedream models, or MiniMax. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, REPLICATE_API_TOKEN, ARK_API_KEY, or MINIMAX_API_KEY, or remove --ref."
"Reference images require Google, OpenAI, Azure, OpenRouter, Replicate, supported Seedream models, MiniMax, or Agnes. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, REPLICATE_API_TOKEN, ARK_API_KEY, MINIMAX_API_KEY, or AGNES_API_KEY, or remove --ref."
);
}
@@ -765,13 +817,14 @@ export function detectProvider(args: CliArgs): Provider {
hasReplicate && "replicate",
hasJimeng && "jimeng",
hasSeedream && "seedream",
hasAgnes && "agnes",
].filter(Boolean) as Provider[];
if (available.length === 1) return available[0]!;
if (available.length > 1) return available[0]!;
throw new Error(
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, ZAI_API_KEY, MINIMAX_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, or ARK_API_KEY.\n" +
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, ZAI_API_KEY, MINIMAX_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, ARK_API_KEY, or AGNES_API_KEY.\n" +
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
);
}
@@ -785,7 +838,7 @@ function isRemoteReferenceImage(refPath: string): boolean {
}
function shouldAllowRemoteReferenceImages(provider: Provider | null): boolean {
return provider === "dashscope";
return provider === "dashscope" || provider === "agnes";
}
export async function validateReferenceImages(
@@ -839,6 +892,8 @@ 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;
if (provider === "agnes") return (await import("./providers/agnes")) as ProviderModule;
return (await import("./providers/openai")) as ProviderModule;
}
@@ -870,6 +925,8 @@ 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"];
if (provider === "agnes" && extendConfig.default_model.agnes) return extendConfig.default_model.agnes;
}
return providerModule.getDefaultModel();
}
@@ -952,6 +1009,7 @@ export function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir
imageSize: task.imageSize ?? baseArgs.imageSize ?? null,
imageSizeSource: task.imageSize != null ? "task" : (baseArgs.imageSizeSource ?? null),
imageApiDialect: task.imageApiDialect ?? baseArgs.imageApiDialect ?? null,
responseFormat: task.responseFormat ?? baseArgs.responseFormat ?? null,
referenceImages: task.ref ? task.ref.map((filePath) => resolveBatchReferencePath(batchDir, filePath)) : [],
n: task.n ?? baseArgs.n,
batchFile: null,
@@ -1005,7 +1063,7 @@ async function prepareBatchTasks(
}
async function writeImage(outputPath: string, imageData: Uint8Array): Promise<void> {
await mkdir(path.dirname(outputPath), { recursive: true });
await ensureDir(path.dirname(outputPath));
await writeFile(outputPath, imageData);
}
@@ -1104,7 +1162,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", "agnes"] as Provider[]) {
const limit = providerRateLimits[provider];
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);
}
@@ -0,0 +1,213 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test, { type TestContext } from "node:test";
import type { CliArgs } from "../types.ts";
import {
buildRequestBody,
extractImageFromResponse,
parseAspectRatio,
resolveReferenceImages,
resolveSize,
snapDim,
validateArgs,
} from "./agnes.ts";
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: null,
imageSize: null,
imageApiDialect: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
responseFormat: null,
...overrides,
};
}
test("snapDim rounds to the nearest multiple of 32", () => {
assert.equal(snapDim(767), 768);
assert.equal(snapDim(1023), 1024);
assert.equal(snapDim(1024), 1024);
assert.equal(snapDim(32), 32);
assert.equal(snapDim(0), 32);
assert.equal(snapDim(16), 32);
assert.equal(snapDim(48), 64);
});
test("parseAspectRatio parses valid ratios and rejects invalid inputs", () => {
assert.deepEqual(parseAspectRatio("3:4"), { width: 3, height: 4 });
assert.deepEqual(parseAspectRatio("16:9"), { width: 16, height: 9 });
assert.deepEqual(parseAspectRatio("1:1"), { width: 1, height: 1 });
assert.deepEqual(parseAspectRatio("1.5:1"), { width: 1.5, height: 1 });
assert.equal(parseAspectRatio(""), null);
assert.equal(parseAspectRatio("invalid"), null);
assert.equal(parseAspectRatio("3x4"), null);
assert.equal(parseAspectRatio("0:1"), null);
assert.equal(parseAspectRatio("1:0"), null);
});
test("resolveSize returns explicit --size directly", () => {
assert.equal(resolveSize({ size: "1024x1024" }), "1024x1024");
assert.equal(resolveSize({ size: "768x1024", aspectRatio: "16:9" }), "768x1024");
});
test("resolveSize returns default 1024x1024 when no size or ratio given", () => {
assert.equal(resolveSize({}), "1024x1024");
assert.equal(resolveSize({ size: null, aspectRatio: null }), "1024x1024");
});
test("resolveSize computes 32-aligned size within 2048 max edge", () => {
assert.equal(resolveSize({ aspectRatio: "1:1" }), "1024x1024");
assert.equal(resolveSize({ aspectRatio: "16:9" }), "2048x1152");
assert.equal(resolveSize({ aspectRatio: "4:3" }), "2048x1536");
assert.equal(resolveSize({ aspectRatio: "3:4" }), "1536x2048");
assert.equal(resolveSize({ aspectRatio: "9:16" }), "1152x2048");
});
test("resolveSize aligns to 32 and respects max edge", () => {
assert.equal(resolveSize({ aspectRatio: "3:1" }), "2048x672");
assert.equal(resolveSize({ aspectRatio: "1:3" }), "672x2048");
});
test("validateArgs rejects --n > 1", () => {
assert.throws(
() => validateArgs("agnes-image-2.1-flash", makeArgs({ n: 2 })),
/returns a single image per request/,
);
assert.doesNotThrow(() =>
validateArgs("agnes-image-2.1-flash", makeArgs({ n: 1 })),
);
});
test("buildRequestBody maps prompt, model, size, and reference images", () => {
const body = buildRequestBody("a cat", "agnes-image-2.1-flash", {
size: "1024x1024",
aspectRatio: null,
referenceImages: [],
});
assert.equal(body.model, "agnes-image-2.1-flash");
assert.equal(body.prompt, "a cat");
assert.equal(body.size, "1024x1024");
assert.deepEqual(body.extra_body, { response_format: "url" });
const bodyWithRef = buildRequestBody("a cat", "agnes-image-2.1-flash", {
size: null,
aspectRatio: "3:4",
referenceImages: ["https://example.com/ref.jpg"],
});
assert.equal(bodyWithRef.size, "1536x2048");
assert.deepEqual(bodyWithRef.image, ["https://example.com/ref.jpg"]);
});
test("extractImageFromResponse decodes b64_json payloads", async () => {
const fromBase64 = await extractImageFromResponse({
data: [{ b64_json: Buffer.from("hello").toString("base64") }],
});
assert.equal(Buffer.from(fromBase64).toString("utf8"), "hello");
});
test("extractImageFromResponse downloads URL payloads", async (t) => {
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
globalThis.fetch = async () =>
new Response(Uint8Array.from([1, 2, 3]), {
status: 200,
headers: { "Content-Type": "image/png" },
});
const fromUrl = await extractImageFromResponse({
data: [{ url: "https://example.com/output.png" }],
});
assert.deepEqual([...fromUrl], [1, 2, 3]);
});
test("extractImageFromResponse throws on empty data", async () => {
await assert.rejects(
() => extractImageFromResponse({ data: [] }),
/No image/,
);
await assert.rejects(
() => extractImageFromResponse({ data: [{}] }),
/No image/,
);
});
test("resolveReferenceImages converts local files to data URIs and passes URLs through", async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agnes-ref-"));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
const localPath = path.join(dir, "ref.png");
const localBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
await fs.writeFile(localPath, localBytes);
const jpegPath = path.join(dir, "photo.jpeg");
await fs.writeFile(jpegPath, Buffer.from([0xff, 0xd8]));
const results = await resolveReferenceImages([
localPath,
"https://example.com/remote.jpg",
jpegPath,
]);
assert.equal(results.length, 3);
assert.match(results[0]!, /^data:image\/png;base64,/);
assert.match(results[1]!, /^https:\/\/example.com\/remote.jpg$/);
assert.match(results[2]!, /^data:image\/jpeg;base64,/);
});
test("resolveReferenceImages detects gif and webp mime types", async (t) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agnes-mime-"));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
const webpPath = path.join(dir, "ref.webp");
const gifPath = path.join(dir, "ref.gif");
await fs.writeFile(webpPath, Buffer.from([0x00]));
await fs.writeFile(gifPath, Buffer.from([0x00]));
const results = await resolveReferenceImages([webpPath, gifPath]);
assert.match(results[0]!, /^data:image\/webp;base64,/);
assert.match(results[1]!, /^data:image\/gif;base64,/);
});
@@ -0,0 +1,175 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { CliArgs } from "../types";
const DEFAULT_MODEL = "agnes-image-2.1-flash";
const DEFAULT_BASE_URL = "https://apihub.agnes-ai.com/v1";
const DEFAULT_SIZE = "1024x1024";
type AgnesResponse = {
created?: number;
data: Array<{ url?: string; b64_json?: string }>;
};
export function getDefaultModel(): string {
return process.env.AGNES_IMAGE_MODEL || DEFAULT_MODEL;
}
function getApiKey(): string {
const key = process.env.AGNES_API_KEY;
if (!key) {
throw new Error("AGNES_API_KEY is required. Get one from https://apihub.agnes-ai.com.");
}
return key;
}
function getBaseUrl(): string {
return (process.env.AGNES_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
}
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
if (!match) return null;
const w = parseFloat(match[1]!);
const h = parseFloat(match[2]!);
if (w <= 0 || h <= 0) return null;
return { width: w, height: h };
}
export function snapDim(n: number): number {
return Math.max(32, Math.round(n / 32) * 32);
}
export function resolveSize(args: Pick<CliArgs, "size" | "aspectRatio">): string {
if (args.size) return args.size;
if (args.aspectRatio) {
const parsed = parseAspectRatio(args.aspectRatio);
if (parsed) {
if (parsed.width === 1 && parsed.height === 1) return "1024x1024";
const maxEdge = 2048;
const scale = Math.max(1, Math.floor(maxEdge / Math.max(parsed.width, parsed.height)));
const width = parsed.width * scale;
const height = parsed.height * scale;
return `${snapDim(width)}x${snapDim(height)}`;
}
}
return DEFAULT_SIZE;
}
function isRemoteUrl(refPath: string): boolean {
return /^https?:\/\//i.test(refPath);
}
export async function resolveReferenceImages(
referenceImages: string[]
): Promise<string[]> {
const result: string[] = [];
for (const refPath of referenceImages) {
if (isRemoteUrl(refPath)) {
result.push(refPath);
continue;
}
const bytes = await readFile(refPath);
const ext = path.extname(refPath).toLowerCase();
let mime = "image/png";
if (ext === ".jpg" || ext === ".jpeg") mime = "image/jpeg";
else if (ext === ".webp") mime = "image/webp";
else if (ext === ".gif") mime = "image/gif";
const b64 = Buffer.from(bytes).toString("base64");
result.push(`data:${mime};base64,${b64}`);
}
return result;
}
export function validateArgs(_model: string, args: CliArgs): void {
if (args.n > 1) {
throw new Error("Agnes image generation currently returns a single image per request. Set --n 1 or omit --n.");
}
}
export function getDefaultOutputExtension(_model: string, args: CliArgs): string {
return args.responseFormat === "url" ? ".txt" : ".png";
}
export function buildRequestBody(
prompt: string,
model: string,
args: Pick<CliArgs, "size" | "aspectRatio" | "referenceImages">
): Record<string, unknown> {
const body: Record<string, unknown> = {
model,
prompt,
size: resolveSize(args),
};
if (args.referenceImages.length > 0) {
body.image = args.referenceImages;
}
body.extra_body = { response_format: "url" };
return body;
}
export async function extractImageFromResponse(result: AgnesResponse): Promise<Uint8Array> {
const img = result.data[0];
if (img?.b64_json) {
return Uint8Array.from(Buffer.from(img.b64_json, "base64"));
}
if (img?.url) {
const imgRes = await fetch(img.url);
if (!imgRes.ok) throw new Error(`Failed to download image from Agnes: ${imgRes.status}`);
return new Uint8Array(await imgRes.arrayBuffer());
}
throw new Error("No image in Agnes response");
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const apiKey = getApiKey();
const baseUrl = getBaseUrl();
const referenceImages = await resolveReferenceImages(args.referenceImages);
const body = buildRequestBody(prompt, model, { ...args, referenceImages });
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 120_000);
try {
const res = await fetch(`${baseUrl}/images/generations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Agnes API error (${res.status}): ${err}`);
}
const result = (await res.json()) as AgnesResponse;
if (args.responseFormat === "url") {
const url = result.data[0]?.url;
if (!url) throw new Error("No URL in Agnes response");
return new Uint8Array(Buffer.from(url, "utf-8"));
}
return extractImageFromResponse(result);
} finally {
clearTimeout(timeout);
}
}
@@ -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 }),
]);
}
}
@@ -67,6 +67,9 @@ test("Google provider helpers normalize model IDs and select image size defaults
"gemini-3.1-flash-image-preview",
);
assert.equal(isGoogleMultimodal("models/gemini-3-pro-image-preview"), true);
assert.equal(isGoogleMultimodal("gemini-3-pro-image"), true);
assert.equal(isGoogleMultimodal("gemini-3.1-flash-image"), true);
assert.equal(isGoogleMultimodal("models/gemini-3-pro-image"), true);
assert.equal(isGoogleImagen("imagen-3.0-generate-002"), true);
assert.equal(getGoogleImageSize(makeArgs({ imageSize: null, quality: "2k" })), "2K");
assert.equal(getGoogleImageSize(makeArgs({ imageSize: "4K", quality: "normal" })), "4K");
@@ -4,6 +4,8 @@ import { execFileSync } from "node:child_process";
import type { CliArgs } from "../types";
const GOOGLE_MULTIMODAL_MODELS = [
"gemini-3-pro-image",
"gemini-3.1-flash-image",
"gemini-3-pro-image-preview",
"gemini-3-flash-preview",
"gemini-3.1-flash-image-preview",
@@ -14,7 +16,7 @@ const GOOGLE_IMAGEN_MODELS = [
];
export function getDefaultModel(): string {
return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image-preview";
return process.env.GOOGLE_IMAGE_MODEL || "gemini-3-pro-image";
}
export function normalizeGoogleModelId(model: string): string {
@@ -333,7 +335,7 @@ export async function generateImage(
if (isGoogleImagen(model)) {
if (args.referenceImages.length > 0) {
throw new Error(
"Reference images are not supported with Imagen models. Use gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
"Reference images are not supported with Imagen models. Use a Gemini multimodal model such as gemini-3-pro-image, gemini-3.1-flash-image, gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
);
}
return generateWithImagen(prompt, model, args);
@@ -341,7 +343,7 @@ export async function generateImage(
if (!isGoogleMultimodal(model) && args.referenceImages.length > 0) {
throw new Error(
"Reference images are only supported with Gemini multimodal models. Use gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
"Reference images are only supported with Gemini multimodal models such as gemini-3-pro-image, gemini-3.1-flash-image, gemini-3-pro-image-preview, gemini-3-flash-preview, or gemini-3.1-flash-image-preview.",
);
}
@@ -2,7 +2,7 @@ import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview";
const DEFAULT_MODEL = "google/gemini-3.1-flash-image";
const COMMON_ASPECT_RATIOS = [
"1:1",
"2:3",
@@ -67,7 +67,10 @@ function isTextAndImageModel(model: string): boolean {
function getSupportedAspectRatios(model: string): Set<string> {
const normalized = normalizeModelId(model);
if (normalized !== "google/gemini-3.1-flash-image-preview") {
if (
normalized !== "google/gemini-3.1-flash-image" &&
normalized !== "google/gemini-3.1-flash-image-preview"
) {
return new Set(COMMON_ASPECT_RATIOS);
}
+8 -1
View File
@@ -8,9 +8,12 @@ export type Provider =
| "replicate"
| "jimeng"
| "seedream"
| "azure";
| "azure"
| "codex-cli"
| "agnes";
export type Quality = "normal" | "2k";
export type OpenAIImageApiDialect = "openai-native" | "ratio-metadata";
export type ResponseFormat = "file" | "url";
export type CliArgs = {
prompt: string | null;
@@ -25,6 +28,7 @@ export type CliArgs = {
imageSize: string | null;
imageSizeSource?: "cli" | "task" | "config" | null;
imageApiDialect: OpenAIImageApiDialect | null;
responseFormat: ResponseFormat | null;
referenceImages: string[];
n: number;
batchFile: string | null;
@@ -45,6 +49,7 @@ export type BatchTaskInput = {
quality?: Quality | null;
imageSize?: "1K" | "2K" | "4K" | null;
imageApiDialect?: OpenAIImageApiDialect | null;
responseFormat?: ResponseFormat | null;
ref?: string[];
n?: number;
};
@@ -74,6 +79,8 @@ export type ExtendConfig = {
jimeng: string | null;
seedream: string | null;
azure: string | null;
"codex-cli": string | null;
agnes: string | null;
};
batch?: {
max_workers?: number | null;
+6 -3
View File
@@ -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,8 @@ 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.
- **Cursor (`GenerateImage`)** — if the runtime exposes a native `GenerateImage` tool, you are running inside Cursor and it outranks any non-native skill the same way Codex `imagegen` does. Two hard caveats: (a) it has no aspect-ratio parameter — state the target aspect ratio / dimensions explicitly in the prompt text passed as `description`; (b) it does not accept an output directory — it saves to a tool-managed location, so after generation copy/move the file to the skill's expected output path (e.g., `outputs/.../NN-xxx.png`). Reference images go in `reference_image_paths`.
- **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.
@@ -42,7 +44,7 @@ Setting `preferred_image_backend: ask` forces the step-3 prompt every run regard
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
Concrete tool names (`imagegen`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
Concrete tool names (`imagegen`, `GenerateImage`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
## Reference Images
@@ -296,7 +298,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`.
+18 -4
View File
@@ -1,7 +1,7 @@
---
name: baoyu-markdown-to-html
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, PlantUML, footnotes, alerts, infographics, and optional bottom citations for external links. Use when user asks for "markdown to html", "convert md to html", "md 转 html", "微信外链转底部引用", or needs styled HTML output from markdown.
version: 1.56.1
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, Mermaid (rendered to PNG via headless Chrome), PlantUML, footnotes, alerts, infographics, and optional bottom citations for external links. Use when user asks for "markdown to html", "convert md to html", "md 转 html", "微信外链转底部引用", or needs styled HTML output from markdown.
version: 1.117.3
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-markdown-to-html
@@ -45,7 +45,7 @@ Check EXTEND.md in priority order — the first one found wins:
If none found, use defaults.
**EXTEND.md supports**: default theme, custom CSS variables, code block style.
**EXTEND.md supports**: default theme, custom CSS variables, code block style, mermaid defaults (`mermaid_theme`, `mermaid_scale`, `mermaid_background`).
## Workflow
@@ -124,6 +124,11 @@ ${BUN_X} {baseDir}/scripts/main.ts <markdown_file> [options]
| `--title <title>` | Override title from frontmatter | |
| `--cite` | Convert external links to bottom citations, append `引用链接` section | false (off) |
| `--keep-title` | Keep the first heading in content | false (removed) |
| `--mermaid-theme <name>` | Mermaid theme: `default`, `forest`, `dark`, `neutral`, `base` | default |
| `--mermaid-scale <N>` | Mermaid render scale (positive number ≤ 4) | 2 |
| `--mermaid-width <N>` | Mermaid target display width in CSS px; PNG is rendered at `width × scale` pixels when the diagram is narrower than this | 860 |
| `--mermaid-bg <value>` | Mermaid background: `white`, `transparent`, or `#hex` | white |
| `--no-mermaid` | Skip Mermaid PNG rendering; emit `<pre class="mermaid">` fallback | false |
| `--help` | Show help | |
**Color Presets:**
@@ -190,10 +195,19 @@ ${BUN_X} {baseDir}/scripts/main.ts article.md --title "My Article"
"localPath": "/path/to/img.png",
"originalPath": "imgs/image.png"
}
],
"mermaidImages": [
{
"hash": "a1b2c3d4e5f6",
"localPath": "/path/to/imgs/.mermaid-cache/mermaid-a1b2c3d4e5f6.png",
"cached": false
}
]
}
```
**Mermaid rendering**: Code blocks fenced as ` ```mermaid ` are rendered to PNGs via headless Chrome (CDP) and cached at `imgs/.mermaid-cache/mermaid-<hash>.png`. The cache key includes the code, theme, scale, target width, background, and mermaid version. Add `imgs/.mermaid-cache/` to `.gitignore` if you do not want generated diagrams checked in. Requires Chrome/Chromium/Edge on the system; otherwise the block falls back to `<pre class="mermaid">…</pre>` and conversion still succeeds.
## Themes
| Theme | Description |
@@ -219,7 +233,7 @@ ${BUN_X} {baseDir}/scripts/main.ts article.md --title "My Article"
| Alerts | `> [!NOTE]`, `> [!WARNING]`, etc. |
| Footnotes | `[^1]` references |
| Ruby text | `{base|annotation}` |
| Mermaid | ` ```mermaid ` diagrams |
| Mermaid | ` ```mermaid ` blocks rendered to local PNG via headless Chrome (cached under `imgs/.mermaid-cache/`); falls back to `<pre class="mermaid">` if Chrome is unavailable or rendering fails |
| PlantUML | ` ```plantuml ` diagrams |
## Frontmatter
@@ -5,7 +5,8 @@
"": {
"name": "baoyu-markdown-to-html-scripts",
"dependencies": {
"baoyu-md": "^0.1.0",
"baoyu-chrome-cdp": "^0.1.1",
"baoyu-md": "^0.1.1",
},
},
},
@@ -24,7 +25,9 @@
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"baoyu-md": ["baoyu-md@0.1.0", "", { "dependencies": { "fflate": "^0.8.2", "front-matter": "^4.0.2", "highlight.js": "^11.11.1", "juice": "^11.0.1", "marked": "^15.0.6", "reading-time": "^1.5.0", "remark-cjk-friendly": "^1.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5" } }, "sha512-urrN548VRn8XvMv/TNQsDaGPYIIHfd1O6J0dBZZ1/0HVxnHl9wEkw8WINPpZB2vjBIxco11l6RizQRiNIPZGEw=="],
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@0.1.1", "", {}, "sha512-OR3PQ7NzJDykCXl20TnkZuwvNQ0hsVC2czje93P72xQaA3vKOyPN/Q1CwEgKuYzP7Rka4Fdh4HvURj6AoNR7Tg=="],
"baoyu-md": ["baoyu-md@0.1.1", "", { "dependencies": { "fflate": "^0.8.2", "front-matter": "^4.0.2", "highlight.js": "^11.11.1", "juice": "^11.0.1", "marked": "^15.0.6", "reading-time": "^1.5.0", "remark-cjk-friendly": "^1.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5" } }, "sha512-yWM3SCFam9RnJZP5qnGMVAfeIfGGdJ9jjizKimbrsHubNu51JDy3XyDDJMASnOCPMck4qXfyOb08Vmxj57P0Qg=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@@ -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"/,
);
});
+144 -7
View File
@@ -13,6 +13,7 @@ import {
formatTimestamp,
parseArgs,
parseFrontmatter,
preprocessMermaidInMarkdown,
renderMarkdownDocument,
replaceMarkdownImagesWithPlaceholders,
resolveContentImages,
@@ -20,11 +21,19 @@ import {
stripWrappingQuotes,
} from "baoyu-md";
import type { CliOptions } from "baoyu-md";
import { closeRenderer, renderMermaidToPng } from "baoyu-chrome-cdp/mermaid";
interface ImageInfo {
placeholder: string;
localPath: string;
originalPath: string;
alt?: string;
}
interface MermaidImageInfo {
hash: string;
localPath: string;
cached: boolean;
}
interface ParsedResult {
@@ -34,12 +43,30 @@ interface ParsedResult {
htmlPath: string;
backupPath?: string;
contentImages: ImageInfo[];
mermaidImages: MermaidImageInfo[];
}
interface MermaidCliOptions {
enabled?: boolean;
theme?: string;
scale?: number;
background?: string;
minWidth?: number;
}
type ConvertMarkdownOptions = Partial<Omit<CliOptions, "inputPath">> & {
title?: string;
mermaid?: MermaidCliOptions;
};
function escapeHtmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export async function convertMarkdown(
markdownPath: string,
options?: ConvertMarkdownOptions,
@@ -70,8 +97,34 @@ export async function convertMarkdown(
? { ...frontmatter, title }
: frontmatter;
const mermaidEnabled = options?.mermaid?.enabled !== false;
const mermaidMinWidth = options?.mermaid?.minWidth ?? 860;
const { markdown: mermaidProcessedBody, images: mermaidImages } =
await preprocessMermaidInMarkdown(body, {
baseDir,
renderFn: renderMermaidToPng,
enabled: mermaidEnabled,
theme: options?.mermaid?.theme,
scale: options?.mermaid?.scale,
background: options?.mermaid?.background,
minWidth: mermaidMinWidth,
onError: (error, block) => {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[markdown-to-html] mermaid render failed (${block.code.slice(0, 40).replace(/\s+/g, " ")}…): ${message}`,
);
},
});
if (mermaidImages.length > 0) {
const fresh = mermaidImages.filter((image) => !image.cached).length;
console.error(
`[markdown-to-html] mermaid: ${mermaidImages.length} block(s), ${fresh} rendered, ${mermaidImages.length - fresh} cached`,
);
}
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
body,
mermaidProcessedBody,
"MDTOHTMLIMGPH_",
);
const rewrittenMarkdown = `${serializeFrontmatter(effectiveFrontmatter)}${rewrittenBody}`;
@@ -116,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");
@@ -130,6 +188,11 @@ export async function convertMarkdown(
htmlPath: finalHtmlPath,
backupPath,
contentImages,
mermaidImages: mermaidImages.map((image) => ({
hash: image.hash,
localPath: image.localPath,
cached: image.cached,
})),
};
}
@@ -156,6 +219,11 @@ Options:
--count Show reading time / word count
--legend <value> Image caption: title-alt, alt-title, title, alt, none
--keep-title Keep the first heading in content. Default: false (removed)
--mermaid-theme <name> Mermaid theme: default, forest, dark, neutral. Default: default
--mermaid-scale <N> Mermaid render scale: 1, 1.5, 2, 3. Default: 2
--mermaid-width <N> Mermaid target display width in CSS px. Default: 860
--mermaid-bg <value> Mermaid background: white, transparent, or #hex. Default: white
--no-mermaid Skip Mermaid rendering; emit <pre class="mermaid"> fallback
--help Show this help
Output:
@@ -215,13 +283,78 @@ function extractTitleArg(argv: string[]): { renderArgs: string[]; title?: string
return { renderArgs, title };
}
const VALID_MERMAID_THEMES = new Set(["default", "forest", "dark", "neutral", "base"]);
function extractMermaidArgs(argv: string[]): { renderArgs: string[]; mermaid: MermaidCliOptions } {
const mermaid: MermaidCliOptions = {};
const renderArgs: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
if (arg === "--no-mermaid") {
mermaid.enabled = false;
continue;
}
if (arg === "--mermaid-theme" || arg.startsWith("--mermaid-theme=")) {
const value = parseArgValue(argv, i, "--mermaid-theme");
if (!value) {
console.error("Missing value for --mermaid-theme");
printUsage(1);
}
if (!VALID_MERMAID_THEMES.has(value)) {
console.error(`Invalid --mermaid-theme: ${value} (choose one of ${[...VALID_MERMAID_THEMES].join(", ")})`);
printUsage(1);
}
mermaid.theme = value;
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--mermaid-scale" || arg.startsWith("--mermaid-scale=")) {
const value = parseArgValue(argv, i, "--mermaid-scale");
const parsed = Number.parseFloat(value ?? "");
if (!value || !Number.isFinite(parsed) || parsed <= 0 || parsed > 4) {
console.error(`Invalid --mermaid-scale: ${value} (expect a positive number ≤ 4)`);
printUsage(1);
}
mermaid.scale = parsed;
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--mermaid-width" || arg.startsWith("--mermaid-width=")) {
const value = parseArgValue(argv, i, "--mermaid-width");
const parsed = Number.parseInt(value ?? "", 10);
if (!value || !Number.isFinite(parsed) || parsed <= 0) {
console.error(`Invalid --mermaid-width: ${value} (expect a positive integer)`);
printUsage(1);
}
mermaid.minWidth = parsed;
if (!arg.includes("=")) i += 1;
continue;
}
if (arg === "--mermaid-bg" || arg.startsWith("--mermaid-bg=")) {
const value = parseArgValue(argv, i, "--mermaid-bg");
if (!value) {
console.error("Missing value for --mermaid-bg");
printUsage(1);
}
mermaid.background = value;
if (!arg.includes("=")) i += 1;
continue;
}
renderArgs.push(arg);
}
return { renderArgs, mermaid };
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
printUsage(0);
}
const { renderArgs, title } = extractTitleArg(args);
const { renderArgs: afterTitle, title } = extractTitleArg(args);
const { renderArgs, mermaid } = extractMermaidArgs(afterTitle);
const options = parseArgs(renderArgs);
if (!options) {
printUsage(1);
@@ -238,11 +371,15 @@ async function main(): Promise<void> {
process.exit(1);
}
const result = await convertMarkdown(markdownPath, { ...options, title });
const result = await convertMarkdown(markdownPath, { ...options, title, mermaid });
console.log(JSON.stringify(result, null, 2));
}
await main().catch((error) => {
try {
await main();
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
process.exitCode = 1;
} finally {
await closeRenderer();
}
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"dependencies": {
"baoyu-md": "^0.1.0"
"baoyu-chrome-cdp": "^0.1.1",
"baoyu-md": "^0.1.1"
}
}
+4 -2
View File
@@ -1,7 +1,7 @@
---
name: baoyu-post-to-wechat
description: Posts content to WeChat Official Account (微信公众号) via API or Chrome CDP. Supports article posting (文章) with HTML, markdown, or plain text input, and image-text posting (贴图, formerly 图文) with multiple images. Markdown article workflows default to converting ordinary external links into bottom citations for WeChat-friendly output. Use when user mentions "发布公众号", "post to wechat", "微信公众号", or "贴图/图文/文章".
version: 1.117.5
version: 1.118.1
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-wechat
@@ -180,6 +180,7 @@ Ask method unless specified in EXTEND.md or CLI:
| Title | Ask, or press Enter to auto-generate from content |
| Summary | Frontmatter `description``summary` → ask or auto-generate |
| Author | CLI `--author` → frontmatter `author` → EXTEND.md `default_author` |
| Source URL | CLI `--source-url` → frontmatter `sourceUrl`/`contentSourceUrl`/`content_source_url` |
Auto-generation: title = first H1/H2 or first sentence; summary = first paragraph, truncated to 120 chars.
@@ -194,7 +195,7 @@ Auto-generation: title = first H1/H2 or first sentence; summary = first paragrap
**API method** (accepts `.md` or `.html`):
```bash
${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme <theme> [--color <color>] [--title <title>] [--summary <summary>] [--author <author>] [--cover <cover_path>] [--no-cite]
${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme <theme> [--color <color>] [--title <title>] [--summary <summary>] [--author <author>] [--cover <cover_path>] [--source-url <url>] [--no-cite]
```
Always pass `--theme` even if it's `default`. Only pass `--color` when explicitly set by the user or EXTEND.md.
@@ -212,6 +213,7 @@ Any `--remote-*` flag implies `--remote`. CLI values override account-level then
- `article_type`: `news` (default) or `newspic`
- For `news`, include `thumb_media_id` (cover required)
- Always include `need_open_comment` (default `1`) and `only_fans_can_comment` (default `0`) in the request body, even if the CLI doesn't expose them
- For `news`, optionally include `content_source_url` (original article URL, shown as "阅读原文" link, max 1KB). Provide via `--source-url` CLI flag or frontmatter `sourceUrl`/`contentSourceUrl`/`content_source_url`
**Browser method** (accepts `--markdown` or `--html`):
+4 -4
View File
@@ -6,8 +6,8 @@
"name": "baoyu-post-to-wechat-scripts",
"dependencies": {
"@jsquash/webp": "^1.5.0",
"baoyu-chrome-cdp": "^0.1.0",
"baoyu-md": "^0.1.0",
"baoyu-chrome-cdp": "^0.1.1",
"baoyu-md": "^0.1.1",
"jimp": "^1.6.0",
"socks": "^2.8.9",
},
@@ -98,9 +98,9 @@
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@0.1.0", "", {}, "sha512-Hk1yolVrlIlzMCKXjc21yAJP0dttun+SaPRcW7HL9/mmwZ9kedQ6fFgxf8M91I+/Fe348sPbdYVhSAmYHzVunQ=="],
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@0.1.1", "", {}, "sha512-OR3PQ7NzJDykCXl20TnkZuwvNQ0hsVC2czje93P72xQaA3vKOyPN/Q1CwEgKuYzP7Rka4Fdh4HvURj6AoNR7Tg=="],
"baoyu-md": ["baoyu-md@0.1.0", "", { "dependencies": { "fflate": "^0.8.2", "front-matter": "^4.0.2", "highlight.js": "^11.11.1", "juice": "^11.0.1", "marked": "^15.0.6", "reading-time": "^1.5.0", "remark-cjk-friendly": "^1.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5" } }, "sha512-urrN548VRn8XvMv/TNQsDaGPYIIHfd1O6J0dBZZ1/0HVxnHl9wEkw8WINPpZB2vjBIxco11l6RizQRiNIPZGEw=="],
"baoyu-md": ["baoyu-md@0.1.1", "", { "dependencies": { "fflate": "^0.8.2", "front-matter": "^4.0.2", "highlight.js": "^11.11.1", "juice": "^11.0.1", "marked": "^15.0.6", "reading-time": "^1.5.0", "remark-cjk-friendly": "^1.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5" } }, "sha512-yWM3SCFam9RnJZP5qnGMVAfeIfGGdJ9jjizKimbrsHubNu51JDy3XyDDJMASnOCPMck4qXfyOb08Vmxj57P0Qg=="],
"bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="],
@@ -8,6 +8,7 @@ import {
extractSummaryFromBody,
extractTitleFromMarkdown,
parseFrontmatter,
preprocessMermaidInMarkdown,
renderMarkdownDocument,
replaceMarkdownImagesWithPlaceholders,
resolveColorToken,
@@ -15,11 +16,13 @@ import {
serializeFrontmatter,
stripWrappingQuotes,
} from "baoyu-md";
import { closeRenderer, renderMermaidToPng } from "baoyu-chrome-cdp/mermaid";
interface ImageInfo {
placeholder: string;
localPath: string;
originalPath: string;
alt?: string;
}
interface ParsedResult {
@@ -55,8 +58,27 @@ export async function convertMarkdown(
summary = extractSummaryFromBody(body, 120);
}
const { markdown: mermaidProcessedBody, images: mermaidImages } =
await preprocessMermaidInMarkdown(body, {
baseDir,
renderFn: renderMermaidToPng,
onError: (error, block) => {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[md-to-wechat] mermaid render failed (${block.code.slice(0, 40).replace(/\s+/g, " ")}…): ${message}`,
);
},
});
if (mermaidImages.length > 0) {
const fresh = mermaidImages.filter((image) => !image.cached).length;
console.error(
`[md-to-wechat] mermaid: ${mermaidImages.length} block(s), ${fresh} rendered, ${mermaidImages.length - fresh} cached`,
);
}
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
body,
mermaidProcessedBody,
"WECHATIMGPH_",
);
const rewrittenMarkdown = `${serializeFrontmatter(frontmatter)}${rewrittenBody}`;
@@ -166,7 +188,11 @@ async function main(): Promise<void> {
console.log(JSON.stringify(result, null, 2));
}
await main().catch((error) => {
try {
await main();
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
process.exitCode = 1;
} finally {
await closeRenderer();
}
@@ -4,8 +4,8 @@
"type": "module",
"dependencies": {
"@jsquash/webp": "^1.5.0",
"baoyu-chrome-cdp": "^0.1.0",
"baoyu-md": "^0.1.0",
"baoyu-chrome-cdp": "^0.1.1",
"baoyu-md": "^0.1.1",
"jimp": "^1.6.0",
"socks": "^2.8.9"
}
@@ -64,6 +64,7 @@ interface ArticleOptions {
content: string;
thumbMediaId: string;
articleType: ArticleType;
contentSourceUrl?: string;
imageMediaIds?: string[];
needOpenComment?: number;
onlyFansCanComment?: number;
@@ -379,6 +380,7 @@ async function publishToDraft(
};
if (options.author) article.author = options.author;
if (options.digest) article.digest = options.digest;
if (options.contentSourceUrl) article.content_source_url = options.contentSourceUrl;
}
const res = await client(url, {
@@ -479,6 +481,7 @@ Options:
--title <title> Override title
--author <name> Author name (max 16 chars)
--summary <text> Article summary/digest (max 128 chars)
--source-url <url> Original article URL ("阅读原文" link, max 1KB)
--theme <name> Theme name for markdown (default, grace, simple, modern). Default: default
--color <name|hex> Primary color (blue, green, vermilion, etc. or hex)
--cover <path> Cover image path (local or URL)
@@ -500,6 +503,7 @@ Frontmatter Fields (markdown):
title Article title
author Author name
digest/summary Article summary
sourceUrl/contentSourceUrl/content_source_url Original article URL
coverImage/featureImage/cover/image Cover image path
Comments:
@@ -517,7 +521,7 @@ Config File Locations (in priority order):
Example:
npx -y bun wechat-api.ts article.md
npx -y bun wechat-api.ts article.md --theme grace --cover cover.png
npx -y bun wechat-api.ts article.md --author "Author Name" --summary "Brief intro"
npx -y bun wechat-api.ts article.md --author "Author Name" --summary "Brief intro" --source-url "https://example.com/original"
npx -y bun wechat-api.ts article.html --title "My Article"
npx -y bun wechat-api.ts images/ --type newspic --title "Photo Album"
npx -y bun wechat-api.ts article.md --dry-run
@@ -533,6 +537,7 @@ interface CliArgs {
title?: string;
author?: string;
summary?: string;
sourceUrl?: string;
theme: string;
color?: string;
cover?: string;
@@ -578,6 +583,8 @@ function parseArgs(argv: string[]): CliArgs {
args.author = argv[++i];
} else if (arg === "--summary" && argv[i + 1]) {
args.summary = argv[++i];
} else if (arg === "--source-url" && argv[i + 1]) {
args.sourceUrl = argv[++i];
} else if (arg === "--theme" && argv[i + 1]) {
args.theme = argv[++i]!;
} else if (arg === "--color" && argv[i + 1]) {
@@ -692,6 +699,7 @@ async function main(): Promise<void> {
let title = args.title || "";
let author = args.author || "";
let digest = args.summary || "";
let sourceUrl = args.sourceUrl || "";
let htmlPath: string;
let htmlContent: string;
let frontmatter: Record<string, string> = {};
@@ -708,6 +716,7 @@ async function main(): Promise<void> {
if (!title && frontmatter.title) title = frontmatter.title;
if (!author) author = frontmatter.author || "";
if (!digest) digest = frontmatter.digest || frontmatter.summary || frontmatter.description || "";
if (!sourceUrl) sourceUrl = frontmatter.sourceUrl || frontmatter.contentSourceUrl || frontmatter.content_source_url || "";
}
if (!title) {
title = extractHtmlTitle(fs.readFileSync(htmlPath, "utf-8"));
@@ -726,6 +735,7 @@ async function main(): Promise<void> {
}
if (!author) author = frontmatter.author || "";
if (!digest) digest = frontmatter.digest || frontmatter.summary || frontmatter.description || "";
if (!sourceUrl) sourceUrl = frontmatter.sourceUrl || frontmatter.contentSourceUrl || frontmatter.content_source_url || "";
console.error(`[wechat-api] Theme: ${args.theme}${args.color ? `, color: ${args.color}` : ""}, citeStatus: ${args.citeStatus}`);
const rendered = renderMarkdownWithPlaceholders(filePath, args.theme, args.color, args.citeStatus, args.title);
@@ -754,6 +764,7 @@ async function main(): Promise<void> {
console.error(`[wechat-api] Title: ${title}`);
if (author) console.error(`[wechat-api] Author: ${author}`);
if (digest) console.error(`[wechat-api] Digest: ${digest.slice(0, 50)}...`);
if (sourceUrl) console.error(`[wechat-api] Source URL: ${sourceUrl}`);
console.error(`[wechat-api] Type: ${args.articleType}`);
const extConfig = loadWechatExtendConfig();
@@ -768,6 +779,7 @@ async function main(): Promise<void> {
title,
author: author || undefined,
digest: digest || undefined,
sourceUrl: sourceUrl || undefined,
htmlPath,
contentLength: htmlContent.length,
placeholderImageCount: contentImages.length || undefined,
@@ -839,6 +851,7 @@ async function main(): Promise<void> {
content: htmlContent,
thumbMediaId,
articleType: args.articleType,
contentSourceUrl: sourceUrl || undefined,
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
needOpenComment: resolved.need_open_comment,
onlyFansCanComment: resolved.only_fans_can_comment,
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: baoyu-post-to-weibo
description: Posts content to Weibo (微博). Supports regular posts with text, images, and videos, and headline articles (头条文章) with Markdown input via Chrome CDP. Use when user asks to "post to Weibo", "发微博", "发布微博", "publish to Weibo", "share on Weibo", "写微博", or "微博头条文章".
version: 1.56.1
version: 1.117.3
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-weibo
+4 -4
View File
@@ -5,8 +5,8 @@
"": {
"name": "baoyu-post-to-weibo-scripts",
"dependencies": {
"baoyu-chrome-cdp": "^0.1.0",
"baoyu-md": "^0.1.0",
"baoyu-chrome-cdp": "^0.1.1",
"baoyu-md": "^0.1.1",
},
},
},
@@ -25,9 +25,9 @@
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@0.1.0", "", {}, "sha512-Hk1yolVrlIlzMCKXjc21yAJP0dttun+SaPRcW7HL9/mmwZ9kedQ6fFgxf8M91I+/Fe348sPbdYVhSAmYHzVunQ=="],
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@0.1.1", "", {}, "sha512-OR3PQ7NzJDykCXl20TnkZuwvNQ0hsVC2czje93P72xQaA3vKOyPN/Q1CwEgKuYzP7Rka4Fdh4HvURj6AoNR7Tg=="],
"baoyu-md": ["baoyu-md@0.1.0", "", { "dependencies": { "fflate": "^0.8.2", "front-matter": "^4.0.2", "highlight.js": "^11.11.1", "juice": "^11.0.1", "marked": "^15.0.6", "reading-time": "^1.5.0", "remark-cjk-friendly": "^1.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5" } }, "sha512-urrN548VRn8XvMv/TNQsDaGPYIIHfd1O6J0dBZZ1/0HVxnHl9wEkw8WINPpZB2vjBIxco11l6RizQRiNIPZGEw=="],
"baoyu-md": ["baoyu-md@0.1.1", "", { "dependencies": { "fflate": "^0.8.2", "front-matter": "^4.0.2", "highlight.js": "^11.11.1", "juice": "^11.0.1", "marked": "^15.0.6", "reading-time": "^1.5.0", "remark-cjk-friendly": "^1.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5" } }, "sha512-yWM3SCFam9RnJZP5qnGMVAfeIfGGdJ9jjizKimbrsHubNu51JDy3XyDDJMASnOCPMck4qXfyOb08Vmxj57P0Qg=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@@ -8,6 +8,7 @@ import {
extractTitleFromMarkdown,
parseFrontmatter,
pickFirstString,
preprocessMermaidInMarkdown,
renderMarkdownDocument,
replaceMarkdownImagesWithPlaceholders,
resolveColorToken,
@@ -16,6 +17,7 @@ import {
serializeFrontmatter,
stripWrappingQuotes,
} from "baoyu-md";
import { closeRenderer, renderMermaidToPng } from "baoyu-chrome-cdp/mermaid";
interface ImageInfo {
placeholder: string;
@@ -69,8 +71,27 @@ export async function parseMarkdown(
|| pickFirstString(frontmatter, ["featureImage", "cover_image", "coverImage", "cover", "image"])
|| null;
const { markdown: mermaidProcessedBody, images: mermaidImages } =
await preprocessMermaidInMarkdown(body, {
baseDir,
renderFn: renderMermaidToPng,
onError: (error, block) => {
const message = error instanceof Error ? error.message : String(error);
console.error(
`[md-to-html] mermaid render failed (${block.code.slice(0, 40).replace(/\s+/g, " ")}…): ${message}`,
);
},
});
if (mermaidImages.length > 0) {
const fresh = mermaidImages.filter((image) => !image.cached).length;
console.error(
`[md-to-html] mermaid: ${mermaidImages.length} block(s), ${fresh} rendered, ${mermaidImages.length - fresh} cached`,
);
}
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
body,
mermaidProcessedBody,
"WBIMGPH_",
);
const rewrittenMarkdown = `${serializeFrontmatter(frontmatter)}${rewrittenBody}`;
@@ -163,8 +184,12 @@ Options:
}
if (import.meta.main ?? (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename ?? ""))) {
await main().catch((error) => {
try {
await main();
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
process.exitCode = 1;
} finally {
await closeRenderer();
}
}
@@ -3,7 +3,7 @@
"private": true,
"type": "module",
"dependencies": {
"baoyu-chrome-cdp": "^0.1.0",
"baoyu-md": "^0.1.0"
"baoyu-chrome-cdp": "^0.1.1",
"baoyu-md": "^0.1.1"
}
}

Some files were not shown because too many files have changed in this diff Show More