mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-14 06:39:48 +08:00
Compare commits
48 Commits
v2.1.0
...
a4e78af813
| Author | SHA1 | Date | |
|---|---|---|---|
| a4e78af813 | |||
| 1378544ea2 | |||
| c9a50cc908 | |||
| 348dc82612 | |||
| dba3d732c3 | |||
| 661bdd08ff | |||
| f490f7e5e3 | |||
| 22031aa2d2 | |||
| 51b31aa050 | |||
| 441ca307a6 | |||
| b0ac5233cc | |||
| c1f96f8421 | |||
| 4d2322fd02 | |||
| 154d0d1f52 | |||
| 7a956f6e7d | |||
| 55223daf5c | |||
| a1c4b732c5 | |||
| 894008c7f6 | |||
| 9daa6f5db3 | |||
| 53aa30bbca | |||
| 3f1120e903 | |||
| 7ea2692acd | |||
| 591614cfa5 | |||
| ad7a7a646d | |||
| ce84174bf7 | |||
| 06e84b92c3 | |||
| 67fa5cd329 | |||
| 3907281f48 | |||
| 011406036c | |||
| e6f4cd8a46 | |||
| a80eec7d75 | |||
| ec704c8afd | |||
| a85c81e8db | |||
| f06a9021a0 | |||
| d79ebe4838 | |||
| d3d1a9f7cd | |||
| 77dd193b58 | |||
| 84d817ed52 | |||
| 639e0b4193 | |||
| 84cefc2784 | |||
| 876f01ac19 | |||
| e1c0ff7c02 | |||
| 860dd36bb6 | |||
| aab0e28823 | |||
| 95cdad2a2a | |||
| 8838161729 | |||
| 49e9c46ca4 | |||
| 23fac03691 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "2.1.0"
|
||||
"version": "2.5.2"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -3,13 +3,11 @@ name: codex-imagegen tests
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'scripts/codex-imagegen/**'
|
||||
- 'scripts/codex-imagegen.sh'
|
||||
- 'packages/baoyu-codex-imagegen/**'
|
||||
- '.github/workflows/codex-imagegen-tests.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'scripts/codex-imagegen/**'
|
||||
- 'scripts/codex-imagegen.sh'
|
||||
- 'packages/baoyu-codex-imagegen/**'
|
||||
- '.github/workflows/codex-imagegen-tests.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -30,11 +28,11 @@ jobs:
|
||||
run: bun --version
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: scripts/codex-imagegen
|
||||
working-directory: packages/baoyu-codex-imagegen
|
||||
run: bun test
|
||||
|
||||
- name: Bundle smoke test (catches import/syntax errors)
|
||||
run: bun build --target=node scripts/codex-imagegen/main.ts --outfile /tmp/main-build.js
|
||||
run: bun build --target=node packages/baoyu-codex-imagegen/src/main.ts --outfile /tmp/main-build.js
|
||||
|
||||
- name: Help output smoke test
|
||||
run: bun scripts/codex-imagegen/main.ts --help
|
||||
run: bun packages/baoyu-codex-imagegen/src/main.ts --help
|
||||
|
||||
@@ -2,6 +2,73 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 2.5.2 - 2026-06-18
|
||||
|
||||
### Fixes
|
||||
- `codex-imagegen`: stop Codex from satisfying a request by copying an unrelated pre-existing image from `generated_images` instead of generating a new one. Verification now requires real evidence that `image_gen` ran in the current thread — an `image_gen` event in the stream **or** a PNG in this thread's `generated_images` dir — and the spawned-agent instruction forbids reading or reusing history images before `image_gen` is called ([#185](https://github.com/JimLiu/baoyu-skills/issues/185))
|
||||
|
||||
### Documentation
|
||||
- README: document Codex project-level install (`.agents/skills`) and project- vs user-level WeChat credential scopes
|
||||
|
||||
## 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
|
||||
|
||||
@@ -2,6 +2,73 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 2.5.2 - 2026-06-18
|
||||
|
||||
### 修复
|
||||
- `codex-imagegen`:阻止 Codex 通过复制 `generated_images` 中无关旧图片来代替真正生成的取巧行为。校验改为要求当前线程内 `image_gen` 真正运行的证据——事件流中的 `image_gen` 事件,**或**当前线程 `generated_images` 目录下新生成的 PNG——并在派生 agent 指令中禁止在调用 `image_gen` 之前读取或复用历史图片([#185](https://github.com/JimLiu/baoyu-skills/issues/185))
|
||||
|
||||
### 文档
|
||||
- README:补充 Codex 项目级安装(`.agents/skills`)以及项目级与全局公众号凭证的放置位置
|
||||
|
||||
## 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
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jim Liu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -19,6 +19,31 @@ Skills shared by Baoyu for improving daily work efficiency with AI Agents (Claud
|
||||
npx skills add jimliu/baoyu-skills
|
||||
```
|
||||
|
||||
### Codex Project-Level Install
|
||||
|
||||
If you only need a subset of skills in one project, you do not need to install the full plugin. Codex scans `.agents/skills` inside a project, so copy or symlink each needed skill as a full directory:
|
||||
|
||||
```text
|
||||
<project>/.agents/skills/baoyu-cover-image/SKILL.md
|
||||
<project>/.agents/skills/baoyu-article-illustrator/SKILL.md
|
||||
<project>/.agents/skills/baoyu-post-to-wechat/SKILL.md
|
||||
```
|
||||
|
||||
For a WeChat Official Account article workflow, the usual minimal set is:
|
||||
|
||||
- `baoyu-cover-image`
|
||||
- `baoyu-article-illustrator`
|
||||
- `baoyu-post-to-wechat`
|
||||
|
||||
You do not need to install `baoyu-markdown-to-html` separately. `baoyu-post-to-wechat` already includes the Markdown to WeChat-ready HTML conversion flow. Install `baoyu-format-markdown` only if you need to first turn raw text or drafts into structured Markdown articles with titles, summaries, headings, bold text, lists, and similar formatting.
|
||||
|
||||
Place WeChat API credentials according to the scope you want:
|
||||
|
||||
- User-level: `~/.baoyu-skills/.env`
|
||||
- Project-level: `<project>/.baoyu-skills/.env`
|
||||
|
||||
Project-level `.env` files are useful when credentials should apply only to the current project. Do not commit them to Git.
|
||||
|
||||
### Publish to ClawHub / OpenClaw
|
||||
|
||||
This repository now supports publishing each `skills/baoyu-*` directory as an individual ClawHub skill.
|
||||
@@ -95,6 +120,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 +787,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 +834,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 +864,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 +908,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 +1165,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 +1188,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 +1263,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)
|
||||
@@ -1349,7 +1387,9 @@ This project was inspired by and builds upon the following open source projects:
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Unless otherwise noted, this repository is licensed under the [MIT License](./LICENSE).
|
||||
|
||||
Published ClawHub skills follow ClawHub registry rules and are distributed under `MIT-0`. Third-party code and assets retain their original licenses where noted.
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
+62
-12
@@ -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 环境
|
||||
@@ -19,6 +29,31 @@
|
||||
npx skills add jimliu/baoyu-skills
|
||||
```
|
||||
|
||||
### Codex 项目级安装
|
||||
|
||||
如果只在某个项目中使用部分技能,不需要安装整个插件。Codex 会扫描项目里的 `.agents/skills`,可以只把需要的 skill 按整个目录复制或软链接到当前项目:
|
||||
|
||||
```text
|
||||
<project>/.agents/skills/baoyu-cover-image/SKILL.md
|
||||
<project>/.agents/skills/baoyu-article-illustrator/SKILL.md
|
||||
<project>/.agents/skills/baoyu-post-to-wechat/SKILL.md
|
||||
```
|
||||
|
||||
公众号文章发布的最小组合通常是:
|
||||
|
||||
- `baoyu-cover-image`
|
||||
- `baoyu-article-illustrator`
|
||||
- `baoyu-post-to-wechat`
|
||||
|
||||
不需要单独安装 `baoyu-markdown-to-html`,`baoyu-post-to-wechat` 已内置 Markdown 到公众号可用 HTML 的转换流程。只有需要先把原始文本或草稿整理成 Markdown 文章结构(标题、摘要、层级标题、加粗、列表等)时,再额外安装 `baoyu-format-markdown`。
|
||||
|
||||
公众号 API 凭证按作用范围放置:
|
||||
|
||||
- 全局:`~/.baoyu-skills/.env`
|
||||
- 项目:`<project>/.baoyu-skills/.env`
|
||||
|
||||
项目级 `.env` 适合只给当前项目使用,注意不要提交到 Git。
|
||||
|
||||
### 发布到 ClawHub / OpenClaw
|
||||
|
||||
现在这个仓库支持把每个 `skills/baoyu-*` 目录作为独立 ClawHub skill 发布。
|
||||
@@ -95,6 +130,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 +705,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 +788,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 +835,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 使用完整模型 ID;Z.AI 使用 `glm-image`;MiniMax 使用 `image-01` / `image-01-live` |
|
||||
| `--ar` | 宽高比(如 `16:9`、`1:1`、`4:3`) |
|
||||
| `--size` | 尺寸(如 `1024x1024`;`gpt-image-2` 支持最长边不超过 3840px 的有效自定义尺寸) |
|
||||
@@ -818,8 +865,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 +909,9 @@ AI 驱动的生成后端。
|
||||
|
||||
**服务商自动选择**:
|
||||
1. 如果指定了 `--provider` → 使用指定的
|
||||
2. 如果传了 `--ref` 且未指定 provider → 依次尝试 Google、OpenAI、Azure、OpenRouter、Replicate、Seedream,最后是 MiniMax
|
||||
2. 如果传了 `--ref` 且未指定 provider → 依次尝试 Google、OpenAI、Azure、OpenRouter、Replicate、Seedream、MiniMax,最后是 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 +1166,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 +1189,7 @@ AI 驱动的生成后端。
|
||||
**特性**:
|
||||
- 话题提取,带归属和引言
|
||||
- 发言排行榜和群友画像
|
||||
- 群级事实记忆:群友指正过的事实跨期生效(内置防注入规则)
|
||||
- 增量模式(从上次摘要断点继续)
|
||||
- 大批量消息自动按天分割
|
||||
- 正常版和毒舌版两种风格
|
||||
@@ -1216,14 +1264,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(阿里通义万相)
|
||||
@@ -1340,7 +1388,9 @@ HTTP_PROXY=http://127.0.0.1:7890 HTTPS_PROXY=http://127.0.0.1:7890 /baoyu-danger
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
除另有说明外,本仓库采用 [MIT License](./LICENSE) 授权。
|
||||
|
||||
发布到 ClawHub 的 skill 根据 ClawHub registry 规则以 `MIT-0` 分发。第三方代码和素材按其注明的原始许可授权。
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+19
-1
@@ -5,6 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "baoyu-skills",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -1787,6 +1788,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,13 +5018,25 @@
|
||||
}
|
||||
},
|
||||
"packages/baoyu-chrome-cdp": {
|
||||
"version": "0.1.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.2.0"
|
||||
}
|
||||
},
|
||||
"packages/baoyu-codex-imagegen": {
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"codex-imagegen": "src/main.ts"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.2.0"
|
||||
}
|
||||
},
|
||||
"packages/baoyu-fetch": {
|
||||
"version": "0.1.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"chrome-launcher": "^1.2.1",
|
||||
@@ -5047,7 +5064,8 @@
|
||||
}
|
||||
},
|
||||
"packages/baoyu-md": {
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
"front-matter": "^4.0.2",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "baoyu-skills",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "baoyu-chrome-cdp",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# baoyu-codex-imagegen
|
||||
|
||||
Generate images via Codex CLI's built-in `image_gen` tool from non-Codex runtimes (e.g., Claude Code). The wrapper spawns `codex exec --json` and lets the user's existing Codex subscription drive image generation — **no `OPENAI_API_KEY` required**.
|
||||
|
||||
This package implements the `preferred_image_backend: codex-imagegen` config key referenced across the `baoyu-skills` plugin and is the engine behind `baoyu-image-gen --provider codex-cli`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
packages/baoyu-codex-imagegen/
|
||||
├── src/
|
||||
│ ├── main.ts # CLI orchestrator (executable via `#!/usr/bin/env bun`)
|
||||
│ ├── spawn.ts # codex exec child-process wrapper
|
||||
│ ├── parser.ts # JSONL event-stream parser
|
||||
│ ├── validator.ts # Output PNG / image_gen-invocation verification
|
||||
│ ├── cache.ts # SHA256 idempotency cache + file lock
|
||||
│ ├── logger.ts # Structured JSONL logging
|
||||
│ ├── types.ts # Shared types and `GenError`
|
||||
│ └── *.test.ts # Bun unit tests
|
||||
└── package.json # `bin` points to `src/main.ts`
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
npm install -g @openai/codex
|
||||
codex login # signs in with your OpenAI account (subscription)
|
||||
codex --version # confirm >= 0.130
|
||||
```
|
||||
|
||||
`bun` is required for running the wrapper:
|
||||
|
||||
```bash
|
||||
brew install oven-sh/bun/bun
|
||||
```
|
||||
|
||||
If `bun` is not on `PATH`, `npx -y bun src/main.ts …` works as a fallback.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Inline prompt (executes via shebang once bun is on PATH)
|
||||
./src/main.ts \
|
||||
--image /tmp/cat.png \
|
||||
--prompt "A friendly orange cat, watercolor"
|
||||
|
||||
# Or invoke bun explicitly
|
||||
bun src/main.ts \
|
||||
--image cover.png \
|
||||
--prompt-file prompts/01-cover.md \
|
||||
--aspect 16:9 \
|
||||
--cache-dir ~/.cache/baoyu-codex-imagegen
|
||||
|
||||
# Without bun installed
|
||||
npx -y bun src/main.ts --image cover.png --prompt "..."
|
||||
```
|
||||
|
||||
Stdout emits a single JSON line:
|
||||
|
||||
```json
|
||||
{"status":"ok","path":"…","bytes":1234567,"elapsed_seconds":62,"thread_id":"…","attempts":1,"cached":false,"usage":{…}}
|
||||
```
|
||||
|
||||
On failure:
|
||||
|
||||
```json
|
||||
{"status":"error","path":"…","bytes":0,"error":"…","error_kind":"timeout"}
|
||||
```
|
||||
|
||||
`error_kind` values: `codex_not_installed`, `invalid_args`, `prompt_file_missing`, `spawn_failed`, `timeout`, `no_image_gen_tool_use`, `output_missing`, `invalid_png`, `agent_refused`, `lock_busy`.
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--image <path>` | Output PNG path (required) |
|
||||
| `--prompt <text>` | Prompt text |
|
||||
| `--prompt-file <path>` | Read prompt from file (mutually exclusive with `--prompt`) |
|
||||
| `--aspect <ratio>` | Aspect ratio (`1:1`, `16:9`, `9:16`, `4:3`, `2.35:1`). Default: `1:1` |
|
||||
| `--ref <file>` | Reference image (repeatable) |
|
||||
| `--timeout <ms>` | Codex exec timeout in ms. Default: `300000` |
|
||||
| `--retries <n>` | Retry attempts on retryable errors. Default: `2` |
|
||||
| `--retry-delay <ms>` | Base retry delay (exponential). Default: `1500` |
|
||||
| `--cache-dir <path>` | Enable idempotency cache. Disabled by default. |
|
||||
| `--log-file <path>` | Append structured JSONL log |
|
||||
| `-v, --verbose` | Verbose stderr logging |
|
||||
| `-h, --help` | Show help |
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd packages/baoyu-codex-imagegen
|
||||
bun test
|
||||
```
|
||||
|
||||
## Trade-offs
|
||||
|
||||
- 5–10× slower than direct OpenAI API calls (except on cache hits)
|
||||
- Uses your Codex subscription — programmatic use of `codex exec` falls into the same terms as interactive use
|
||||
- Requires `codex` CLI and active login session
|
||||
|
||||
See [`docs/codex-imagegen-backend.md`](../../docs/codex-imagegen-backend.md) for the full background.
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "baoyu-codex-imagegen",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"description": "Generate images via Codex CLI's built-in image_gen tool from non-Codex runtimes (Claude Code, Hermes, …).",
|
||||
"bin": {
|
||||
"codex-imagegen": "./src/main.ts"
|
||||
},
|
||||
"files": [
|
||||
"src/**/*.ts",
|
||||
"!src/**/*.test.ts"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/main.ts",
|
||||
"default": "./src/main.ts"
|
||||
},
|
||||
"./src/*": "./src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"smoke": "bun src/main.ts --help"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/JimLiu/baoyu-skills.git",
|
||||
"directory": "packages/baoyu-codex-imagegen"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/JimLiu/baoyu-skills/issues"
|
||||
},
|
||||
"homepage": "https://github.com/JimLiu/baoyu-skills/tree/main/packages/baoyu-codex-imagegen#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.2.0"
|
||||
}
|
||||
}
|
||||
Regular → Executable
+16
-9
@@ -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";
|
||||
@@ -5,7 +6,7 @@ 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 { hasImageGenEvidence, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts";
|
||||
import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts";
|
||||
import { JsonLogger } from "./logger.ts";
|
||||
|
||||
@@ -124,7 +125,7 @@ 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.
|
||||
return `You have an internal tool called image_gen for image generation. You MUST call it before doing anything else.
|
||||
|
||||
TASK: Generate an image with the spec below, then save to disk.
|
||||
|
||||
@@ -136,12 +137,15 @@ 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}
|
||||
2. Move or copy ONLY the image produced by that image_gen call 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 search for, find, inspect, reuse, or copy any pre-existing files from $CODEX_HOME/generated_images/ or any other directory.
|
||||
- Do NOT run ls/find/rg/grep/glob over $CODEX_HOME/generated_images/ before image_gen has been called.
|
||||
- You MUST call image_gen first. Only after image_gen completes may you copy the newly created file from this turn.
|
||||
- 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.`;
|
||||
@@ -174,13 +178,16 @@ async function attemptGenerate(
|
||||
throw new GenError("agent_refused", "No thread id in event stream");
|
||||
}
|
||||
|
||||
// verify: image_gen was actually invoked (check $CODEX_HOME/generated_images/{threadId}/)
|
||||
// verify image_gen ran in THIS thread. A PNG in this thread's
|
||||
// generated_images dir is the real signal (image_gen does not surface as a
|
||||
// stream item); the stream check is a forward-compatible fallback. The #185
|
||||
// shortcut (copying an unrelated history image) yields neither.
|
||||
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}`);
|
||||
}
|
||||
if (!hasImageGenEvidence(run.toolCalls, ver.ok)) {
|
||||
throw new GenError(
|
||||
"no_image_gen_tool_use",
|
||||
`image_gen was not invoked (no image_gen event in stream; ${ver.reason})`,
|
||||
);
|
||||
}
|
||||
|
||||
// verify output
|
||||
+13
-4
@@ -33,17 +33,26 @@ test("parseEventStream tolerates malformed lines", () => {
|
||||
expect(r.usage?.input).toBe(1);
|
||||
});
|
||||
|
||||
test("hasImageGenInvocation finds shell calls touching generated_images", () => {
|
||||
test("hasImageGenInvocation does not infer image_gen from shell copies", () => {
|
||||
const r = parseEventStream(REAL_PoC_STREAM);
|
||||
// image_gen itself is not an event; inferred via generated_images cp path
|
||||
// this test only verifies parser behavior; driver logic lives in validator
|
||||
const hasCp = r.toolCalls.some((tc) => tc.command?.includes("generated_images"));
|
||||
expect(hasCp).toBe(true);
|
||||
expect(hasImageGenInvocation(r.toolCalls)).toBe(false);
|
||||
});
|
||||
|
||||
test("hasImageGenInvocation (proper) returns false when no image_gen tool", () => {
|
||||
test("hasImageGenInvocation detects real image_gen tool calls only", () => {
|
||||
expect(hasImageGenInvocation([{ id: "1", tool: "shell", status: "completed" }])).toBe(false);
|
||||
expect(
|
||||
hasImageGenInvocation([{ id: "1", tool: "image_gen", status: "completed" }]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("parseEventStream normalizes an image_generation item to image_gen", () => {
|
||||
const stream = `{"type":"thread.started","thread_id":"t1"}
|
||||
{"type":"item.started","item":{"id":"g0","type":"image_generation","status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"g0","type":"image_generation","status":"completed"}}
|
||||
{"type":"item.completed","item":{"id":"m0","type":"agent_message","text":"done"}}`;
|
||||
const r = parseEventStream(stream);
|
||||
expect(r.toolCalls.some((tc) => tc.tool === "image_gen")).toBe(true);
|
||||
expect(hasImageGenInvocation(r.toolCalls)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { verifyOutput, verifyImageGenWasInvoked, hasImageGenEvidence } from "./validator.ts";
|
||||
import { parseEventStream } from "./parser.ts";
|
||||
import { GenError } from "./types.ts";
|
||||
|
||||
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
// Condensed + sanitized capture of a REAL successful `codex exec --json` run
|
||||
// (thread 019edc1c…, 16:9 maple-tree image, single attempt). The image_gen
|
||||
// tool leaves NO stream item — success shows only reasoning / command_execution
|
||||
// / agent_message, and a `cp` from generated_images/<FULL-thread-id>/ig_*.png.
|
||||
const REAL_SUCCESS_STREAM = `{"type":"thread.started","thread_id":"019edc1c-e7a3-74f0-a276-13bea71d32d6"}
|
||||
{"type":"turn.started"}
|
||||
{"type":"item.completed","item":{"id":"r0","type":"reasoning"}}
|
||||
{"type":"item.completed","item":{"id":"r1","type":"reasoning"}}
|
||||
{"type":"item.completed","item":{"id":"m0","type":"agent_message","text":"Image generation is complete. Locating the newly produced image and copying it to the requested path."}}
|
||||
{"type":"item.started","item":{"id":"c0","type":"command_execution","command":"ls $CODEX_HOME/generated_images","status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"c0","type":"command_execution","command":"ls $CODEX_HOME/generated_images","exit_code":1,"status":"failed"}}
|
||||
{"type":"item.started","item":{"id":"c1","type":"command_execution","command":"cp $CODEX_HOME/generated_images/019edc1c-e7a3-74f0-a276-13bea71d32d6/ig_03eda661.png /Users/x/out/maple.png","status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"cp $CODEX_HOME/generated_images/019edc1c-e7a3-74f0-a276-13bea71d32d6/ig_03eda661.png /Users/x/out/maple.png","exit_code":0,"status":"completed"}}
|
||||
{"type":"item.completed","item":{"id":"m1","type":"agent_message","text":"{\\"status\\":\\"ok\\",\\"path\\":\\"/Users/x/out/maple.png\\",\\"bytes\\":1317377}"}}
|
||||
{"type":"turn.completed","usage":{"input_tokens":49489,"cached_input_tokens":34432,"output_tokens":2463,"reasoning_output_tokens":1990}}`;
|
||||
|
||||
test("real success stream carries no image_gen item — gating on the stream alone would false-negative (#185)", () => {
|
||||
const r = parseEventStream(REAL_SUCCESS_STREAM);
|
||||
expect(r.threadId).toBe("019edc1c-e7a3-74f0-a276-13bea71d32d6");
|
||||
// success path tools: reasoning / shell / agent_message — never image_gen
|
||||
expect(r.toolCalls.map((tc) => tc.tool).sort()).toEqual([
|
||||
"agent_message",
|
||||
"agent_message",
|
||||
"reasoning",
|
||||
"reasoning",
|
||||
"shell",
|
||||
"shell",
|
||||
]);
|
||||
expect(r.toolCalls.some((tc) => tc.tool === "image_gen")).toBe(false);
|
||||
// filesystem evidence (PNG in this thread's dir) is what must let it through
|
||||
expect(hasImageGenEvidence(r.toolCalls, true)).toBe(true);
|
||||
// with no filesystem evidence and no stream item, it is rejected (the shortcut)
|
||||
expect(hasImageGenEvidence(r.toolCalls, false)).toBe(false);
|
||||
});
|
||||
|
||||
test("hasImageGenEvidence accepts a real image_gen stream item even without a dir PNG", () => {
|
||||
expect(hasImageGenEvidence([{ id: "1", tool: "image_gen", status: "completed" }], false)).toBe(true);
|
||||
expect(hasImageGenEvidence([{ id: "1", tool: "shell", status: "completed" }], false)).toBe(false);
|
||||
});
|
||||
|
||||
test("verifyOutput passes for valid PNG", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cig-val-"));
|
||||
try {
|
||||
const p = path.join(dir, "good.png");
|
||||
await writeFile(p, Buffer.concat([PNG_HEADER, Buffer.alloc(5000)]));
|
||||
const r = await verifyOutput(p);
|
||||
expect(r.bytes).toBeGreaterThan(1000);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyOutput rejects missing file", async () => {
|
||||
await expect(verifyOutput("/no/such/file.png")).rejects.toBeInstanceOf(GenError);
|
||||
});
|
||||
|
||||
test("verifyOutput rejects tiny file", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cig-val-"));
|
||||
try {
|
||||
const p = path.join(dir, "tiny.png");
|
||||
await writeFile(p, "tiny");
|
||||
await expect(verifyOutput(p)).rejects.toThrow(/too small/);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyOutput rejects non-PNG magic", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cig-val-"));
|
||||
try {
|
||||
const p = path.join(dir, "fake.png");
|
||||
await writeFile(p, Buffer.concat([Buffer.from("GIF89a"), Buffer.alloc(5000)]));
|
||||
await expect(verifyOutput(p)).rejects.toThrow(/not a valid PNG/);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyImageGenWasInvoked false when no thread directory", async () => {
|
||||
const orig = process.env.CODEX_HOME;
|
||||
const tempHome = await mkdtemp(path.join(tmpdir(), "cig-home-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
try {
|
||||
const r = await verifyImageGenWasInvoked("no-such-thread");
|
||||
expect(r.ok).toBe(false);
|
||||
} finally {
|
||||
process.env.CODEX_HOME = orig;
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyImageGenWasInvoked true when PNG exists in thread dir", async () => {
|
||||
const orig = process.env.CODEX_HOME;
|
||||
const tempHome = await mkdtemp(path.join(tmpdir(), "cig-home-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
try {
|
||||
const threadDir = path.join(tempHome, "generated_images", "thread-xyz");
|
||||
await mkdir(threadDir, { recursive: true });
|
||||
await writeFile(path.join(threadDir, "ig_abc.png"), Buffer.alloc(100));
|
||||
const r = await verifyImageGenWasInvoked("thread-xyz");
|
||||
expect(r.ok).toBe(true);
|
||||
} finally {
|
||||
process.env.CODEX_HOME = orig;
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+10
-9
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { GenError } from "./types.ts";
|
||||
import type { ToolCall } from "./types.ts";
|
||||
import { hasImageGenInvocation } from "./parser.ts";
|
||||
|
||||
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
@@ -23,15 +24,15 @@ export async function verifyImageGenWasInvoked(threadId: string | null): Promise
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
);
|
||||
// Real evidence that image_gen ran in THIS thread. Codex's image_gen tool does
|
||||
// not surface as a stream item, so a successful run shows only reasoning/shell/
|
||||
// agent_message — `dirHasImage` (a PNG in this thread's generated_images dir) is
|
||||
// what proves it. The stream check is kept as a forward-compatible signal in
|
||||
// case a future Codex version emits the item. The #185 shortcut (copying an
|
||||
// unrelated history image, which lives under a different thread id) yields
|
||||
// neither, so it is correctly rejected.
|
||||
export function hasImageGenEvidence(toolCalls: ToolCall[], dirHasImage: boolean): boolean {
|
||||
return dirHasImage || hasImageGenInvocation(toolCalls);
|
||||
}
|
||||
|
||||
export async function verifyOutput(outputPath: string): Promise<{ bytes: number }> {
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "baoyu-fetch",
|
||||
"version": "0.1.2",
|
||||
"license": "MIT",
|
||||
"description": "Read URLs into high-quality Markdown or JSON with Chrome CDP and site adapters.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+83
-9
@@ -72694,15 +72694,32 @@ var import_node_path6 = __toESM(require("node:path"));
|
||||
function replaceMarkdownImagesWithPlaceholders(markdown2, placeholderPrefix) {
|
||||
const images = [];
|
||||
let imageCounter = 0;
|
||||
const rewritten = markdown2.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => {
|
||||
let lastIndex = 0;
|
||||
let rewritten = "";
|
||||
const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)|!\[\[([^\]\n]+)\]\]/g;
|
||||
for (const match of markdown2.matchAll(imagePattern)) {
|
||||
const fullMatch = match[0];
|
||||
const matchIndex = match.index ?? 0;
|
||||
const markdownAlt = match[1];
|
||||
const markdownSrc = match[2];
|
||||
const wikilinkTarget = match[3];
|
||||
const wikilinkImage = wikilinkTarget ? parseObsidianImageWikilink(wikilinkTarget) : null;
|
||||
if (wikilinkTarget && !wikilinkImage) {
|
||||
continue;
|
||||
}
|
||||
const originalPath = wikilinkImage?.originalPath ?? markdownSrc ?? "";
|
||||
const alt = wikilinkImage?.alt ?? markdownAlt ?? "";
|
||||
const placeholder = `${placeholderPrefix}${++imageCounter}`;
|
||||
rewritten += markdown2.slice(lastIndex, matchIndex);
|
||||
images.push({
|
||||
alt,
|
||||
originalPath: src,
|
||||
originalPath,
|
||||
placeholder
|
||||
});
|
||||
return placeholder;
|
||||
});
|
||||
rewritten += placeholder;
|
||||
lastIndex = matchIndex + fullMatch.length;
|
||||
}
|
||||
rewritten += markdown2.slice(lastIndex);
|
||||
return { images, markdown: rewritten };
|
||||
}
|
||||
function getImageExtension(urlOrPath) {
|
||||
@@ -72757,8 +72774,7 @@ async function resolveImagePath(imagePath, baseDir, tempDir, logLabel = "baoyu-m
|
||||
}
|
||||
return localPath;
|
||||
}
|
||||
const resolved = import_node_path6.default.isAbsolute(imagePath) ? imagePath : import_node_path6.default.resolve(baseDir, imagePath);
|
||||
return resolveLocalWithFallback(resolved, logLabel);
|
||||
return resolveLocalImagePath(imagePath, baseDir, logLabel);
|
||||
}
|
||||
async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") {
|
||||
const resolved = [];
|
||||
@@ -72770,10 +72786,50 @@ async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
function resolveLocalWithFallback(resolved, logLabel) {
|
||||
function parseObsidianImageWikilink(target) {
|
||||
const separatorIndex = target.indexOf("|");
|
||||
const originalPath = (separatorIndex === -1 ? target : target.slice(0, separatorIndex)).trim();
|
||||
const alt = separatorIndex === -1 ? "" : target.slice(separatorIndex + 1).trim();
|
||||
if (!hasExplicitImageExtension(originalPath)) {
|
||||
return null;
|
||||
}
|
||||
return { originalPath, alt };
|
||||
}
|
||||
function hasExplicitImageExtension(value2) {
|
||||
return /\.(?:jpe?g|png|gif|webp)(?:[?#].*)?$/i.test(value2);
|
||||
}
|
||||
function resolveLocalImagePath(imagePath, baseDir, logLabel) {
|
||||
const decoded = safeDecodeImagePath(imagePath);
|
||||
const decodedResolved = resolveAgainstBaseDir(decoded, baseDir);
|
||||
const decodedWithFallback = resolveLocalWithFallback(decodedResolved, logLabel, buildAttachmentFallbackPath(decoded, baseDir));
|
||||
if (decoded === imagePath || import_node_fs5.default.existsSync(decodedWithFallback)) {
|
||||
return decodedWithFallback;
|
||||
}
|
||||
return resolveLocalWithFallback(resolveAgainstBaseDir(imagePath, baseDir), logLabel, buildAttachmentFallbackPath(imagePath, baseDir));
|
||||
}
|
||||
function resolveLocalWithFallback(resolved, logLabel, attachmentResolved) {
|
||||
if (import_node_fs5.default.existsSync(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
if (attachmentResolved && import_node_fs5.default.existsSync(attachmentResolved)) {
|
||||
logImageFallback(resolved, attachmentResolved, logLabel);
|
||||
return attachmentResolved;
|
||||
}
|
||||
const originalAlternative = findExtensionFallback(resolved);
|
||||
if (originalAlternative) {
|
||||
logImageFallback(resolved, originalAlternative, logLabel);
|
||||
return originalAlternative;
|
||||
}
|
||||
if (attachmentResolved) {
|
||||
const attachmentAlternative = findExtensionFallback(attachmentResolved);
|
||||
if (attachmentAlternative) {
|
||||
logImageFallback(resolved, attachmentAlternative, logLabel);
|
||||
return attachmentAlternative;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
function findExtensionFallback(resolved) {
|
||||
const ext = import_node_path6.default.extname(resolved);
|
||||
const base = ext ? resolved.slice(0, -ext.length) : resolved;
|
||||
const alternatives = [
|
||||
@@ -72788,10 +72844,28 @@ function resolveLocalWithFallback(resolved, logLabel) {
|
||||
for (const alternative of alternatives) {
|
||||
if (!import_node_fs5.default.existsSync(alternative))
|
||||
continue;
|
||||
console.error(`[${logLabel}] Image fallback: ${import_node_path6.default.basename(resolved)} -> ${import_node_path6.default.basename(alternative)}`);
|
||||
return alternative;
|
||||
}
|
||||
return resolved;
|
||||
return null;
|
||||
}
|
||||
function logImageFallback(fromPath, toPath, logLabel) {
|
||||
console.error(`[${logLabel}] Image fallback: ${import_node_path6.default.basename(fromPath)} -> ${import_node_path6.default.basename(toPath)}`);
|
||||
}
|
||||
function safeDecodeImagePath(imagePath) {
|
||||
try {
|
||||
return decodeURIComponent(imagePath);
|
||||
} catch {
|
||||
return imagePath;
|
||||
}
|
||||
}
|
||||
function resolveAgainstBaseDir(imagePath, baseDir) {
|
||||
return import_node_path6.default.isAbsolute(imagePath) ? imagePath : import_node_path6.default.resolve(baseDir, imagePath);
|
||||
}
|
||||
function buildAttachmentFallbackPath(imagePath, baseDir) {
|
||||
if (import_node_path6.default.isAbsolute(imagePath)) {
|
||||
return;
|
||||
}
|
||||
return import_node_path6.default.resolve(baseDir, "Attachments", imagePath);
|
||||
}
|
||||
// src/mermaid-preprocess.ts
|
||||
var import_node_fs6 = __toESM(require("node:fs"));
|
||||
|
||||
Vendored
+83
-9
@@ -72620,15 +72620,32 @@ import path5 from "node:path";
|
||||
function replaceMarkdownImagesWithPlaceholders(markdown2, placeholderPrefix) {
|
||||
const images = [];
|
||||
let imageCounter = 0;
|
||||
const rewritten = markdown2.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => {
|
||||
let lastIndex = 0;
|
||||
let rewritten = "";
|
||||
const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)|!\[\[([^\]\n]+)\]\]/g;
|
||||
for (const match of markdown2.matchAll(imagePattern)) {
|
||||
const fullMatch = match[0];
|
||||
const matchIndex = match.index ?? 0;
|
||||
const markdownAlt = match[1];
|
||||
const markdownSrc = match[2];
|
||||
const wikilinkTarget = match[3];
|
||||
const wikilinkImage = wikilinkTarget ? parseObsidianImageWikilink(wikilinkTarget) : null;
|
||||
if (wikilinkTarget && !wikilinkImage) {
|
||||
continue;
|
||||
}
|
||||
const originalPath = wikilinkImage?.originalPath ?? markdownSrc ?? "";
|
||||
const alt = wikilinkImage?.alt ?? markdownAlt ?? "";
|
||||
const placeholder = `${placeholderPrefix}${++imageCounter}`;
|
||||
rewritten += markdown2.slice(lastIndex, matchIndex);
|
||||
images.push({
|
||||
alt,
|
||||
originalPath: src,
|
||||
originalPath,
|
||||
placeholder
|
||||
});
|
||||
return placeholder;
|
||||
});
|
||||
rewritten += placeholder;
|
||||
lastIndex = matchIndex + fullMatch.length;
|
||||
}
|
||||
rewritten += markdown2.slice(lastIndex);
|
||||
return { images, markdown: rewritten };
|
||||
}
|
||||
function getImageExtension(urlOrPath) {
|
||||
@@ -72683,8 +72700,7 @@ async function resolveImagePath(imagePath, baseDir, tempDir, logLabel = "baoyu-m
|
||||
}
|
||||
return localPath;
|
||||
}
|
||||
const resolved = path5.isAbsolute(imagePath) ? imagePath : path5.resolve(baseDir, imagePath);
|
||||
return resolveLocalWithFallback(resolved, logLabel);
|
||||
return resolveLocalImagePath(imagePath, baseDir, logLabel);
|
||||
}
|
||||
async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-md") {
|
||||
const resolved = [];
|
||||
@@ -72696,10 +72712,50 @@ async function resolveContentImages(images, baseDir, tempDir, logLabel = "baoyu-
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
function resolveLocalWithFallback(resolved, logLabel) {
|
||||
function parseObsidianImageWikilink(target) {
|
||||
const separatorIndex = target.indexOf("|");
|
||||
const originalPath = (separatorIndex === -1 ? target : target.slice(0, separatorIndex)).trim();
|
||||
const alt = separatorIndex === -1 ? "" : target.slice(separatorIndex + 1).trim();
|
||||
if (!hasExplicitImageExtension(originalPath)) {
|
||||
return null;
|
||||
}
|
||||
return { originalPath, alt };
|
||||
}
|
||||
function hasExplicitImageExtension(value2) {
|
||||
return /\.(?:jpe?g|png|gif|webp)(?:[?#].*)?$/i.test(value2);
|
||||
}
|
||||
function resolveLocalImagePath(imagePath, baseDir, logLabel) {
|
||||
const decoded = safeDecodeImagePath(imagePath);
|
||||
const decodedResolved = resolveAgainstBaseDir(decoded, baseDir);
|
||||
const decodedWithFallback = resolveLocalWithFallback(decodedResolved, logLabel, buildAttachmentFallbackPath(decoded, baseDir));
|
||||
if (decoded === imagePath || fs5.existsSync(decodedWithFallback)) {
|
||||
return decodedWithFallback;
|
||||
}
|
||||
return resolveLocalWithFallback(resolveAgainstBaseDir(imagePath, baseDir), logLabel, buildAttachmentFallbackPath(imagePath, baseDir));
|
||||
}
|
||||
function resolveLocalWithFallback(resolved, logLabel, attachmentResolved) {
|
||||
if (fs5.existsSync(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
if (attachmentResolved && fs5.existsSync(attachmentResolved)) {
|
||||
logImageFallback(resolved, attachmentResolved, logLabel);
|
||||
return attachmentResolved;
|
||||
}
|
||||
const originalAlternative = findExtensionFallback(resolved);
|
||||
if (originalAlternative) {
|
||||
logImageFallback(resolved, originalAlternative, logLabel);
|
||||
return originalAlternative;
|
||||
}
|
||||
if (attachmentResolved) {
|
||||
const attachmentAlternative = findExtensionFallback(attachmentResolved);
|
||||
if (attachmentAlternative) {
|
||||
logImageFallback(resolved, attachmentAlternative, logLabel);
|
||||
return attachmentAlternative;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
function findExtensionFallback(resolved) {
|
||||
const ext = path5.extname(resolved);
|
||||
const base = ext ? resolved.slice(0, -ext.length) : resolved;
|
||||
const alternatives = [
|
||||
@@ -72714,10 +72770,28 @@ function resolveLocalWithFallback(resolved, logLabel) {
|
||||
for (const alternative of alternatives) {
|
||||
if (!fs5.existsSync(alternative))
|
||||
continue;
|
||||
console.error(`[${logLabel}] Image fallback: ${path5.basename(resolved)} -> ${path5.basename(alternative)}`);
|
||||
return alternative;
|
||||
}
|
||||
return resolved;
|
||||
return null;
|
||||
}
|
||||
function logImageFallback(fromPath, toPath, logLabel) {
|
||||
console.error(`[${logLabel}] Image fallback: ${path5.basename(fromPath)} -> ${path5.basename(toPath)}`);
|
||||
}
|
||||
function safeDecodeImagePath(imagePath) {
|
||||
try {
|
||||
return decodeURIComponent(imagePath);
|
||||
} catch {
|
||||
return imagePath;
|
||||
}
|
||||
}
|
||||
function resolveAgainstBaseDir(imagePath, baseDir) {
|
||||
return path5.isAbsolute(imagePath) ? imagePath : path5.resolve(baseDir, imagePath);
|
||||
}
|
||||
function buildAttachmentFallbackPath(imagePath, baseDir) {
|
||||
if (path5.isAbsolute(imagePath)) {
|
||||
return;
|
||||
}
|
||||
return path5.resolve(baseDir, "Attachments", imagePath);
|
||||
}
|
||||
// src/mermaid-preprocess.ts
|
||||
import fs6 from "node:fs";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "baoyu-md",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
|
||||
@@ -28,6 +28,32 @@ test("replaceMarkdownImagesWithPlaceholders rewrites markdown and tracks image m
|
||||
]);
|
||||
});
|
||||
|
||||
test("replaceMarkdownImagesWithPlaceholders supports Obsidian image wikilinks in document order", () => {
|
||||
const result = replaceMarkdownImagesWithPlaceholders(
|
||||
`Intro\n\n![[a.png]]\n\n\n\n![[c.webp|C alt]]\n\n![[note]]`,
|
||||
"IMG_",
|
||||
);
|
||||
|
||||
assert.equal(result.markdown, `Intro\n\nIMG_1\n\nIMG_2\n\nIMG_3\n\n![[note]]`);
|
||||
assert.deepEqual(result.images, [
|
||||
{ alt: "", originalPath: "a.png", placeholder: "IMG_1" },
|
||||
{ alt: "B", originalPath: "b.jpg", placeholder: "IMG_2" },
|
||||
{ alt: "C alt", originalPath: "c.webp", placeholder: "IMG_3" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("replaceMarkdownImagesWithPlaceholders supports Obsidian image wikilinks with paths", () => {
|
||||
const result = replaceMarkdownImagesWithPlaceholders(
|
||||
`![[Attachments/screenshot.png]]`,
|
||||
"IMG_",
|
||||
);
|
||||
|
||||
assert.equal(result.markdown, `IMG_1`);
|
||||
assert.deepEqual(result.images, [
|
||||
{ alt: "", originalPath: "Attachments/screenshot.png", placeholder: "IMG_1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("image extension and local fallback resolution handle common path variants", async (t) => {
|
||||
assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg");
|
||||
assert.equal(getImageExtension("/tmp/figure"), "png");
|
||||
@@ -45,6 +71,70 @@ test("image extension and local fallback resolution handle common path variants"
|
||||
assert.equal(resolved, path.join(baseDir, "figure.webp"));
|
||||
});
|
||||
|
||||
test("resolveImagePath falls back to Attachments subdirectory before extension variants", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-attachments-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
const attachmentsDir = path.join(baseDir, "Attachments");
|
||||
await fs.mkdir(attachmentsDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "figure.webp"), "webp");
|
||||
await fs.writeFile(path.join(attachmentsDir, "figure.png"), "png");
|
||||
|
||||
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
|
||||
assert.equal(resolved, path.join(attachmentsDir, "figure.png"));
|
||||
});
|
||||
|
||||
test("resolveImagePath prefers original path before Attachments fallback", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-attachments-original-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
const attachmentsDir = path.join(baseDir, "Attachments");
|
||||
await fs.mkdir(attachmentsDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "figure.png"), "png");
|
||||
await fs.writeFile(path.join(attachmentsDir, "figure.png"), "attachment png");
|
||||
|
||||
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
|
||||
assert.equal(resolved, path.join(baseDir, "figure.png"));
|
||||
});
|
||||
|
||||
test("resolveImagePath decodes URL-encoded filenames with spaces", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-urlencoded-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "Pasted image 20260524.png"), "png");
|
||||
|
||||
const resolved = await resolveImagePath("Pasted%20image%2020260524.png", baseDir, tempDir, "test");
|
||||
assert.equal(resolved, path.join(baseDir, "Pasted image 20260524.png"));
|
||||
});
|
||||
|
||||
test("resolveImagePath keeps literal percent filenames usable", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-percent-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "100% complete.png"), "png");
|
||||
await fs.writeFile(path.join(baseDir, "diagram%23hash.png"), "png");
|
||||
|
||||
const malformedPercent = await resolveImagePath("100% complete.png", baseDir, tempDir, "test");
|
||||
assert.equal(malformedPercent, path.join(baseDir, "100% complete.png"));
|
||||
|
||||
const literalEncodedPercent = await resolveImagePath("diagram%23hash.png", baseDir, tempDir, "test");
|
||||
assert.equal(literalEncodedPercent, path.join(baseDir, "diagram%23hash.png"));
|
||||
});
|
||||
|
||||
test("resolveContentImages resolves image placeholders against the content directory", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-content-images-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
+123
-13
@@ -23,16 +23,39 @@ export function replaceMarkdownImagesWithPlaceholders(
|
||||
} {
|
||||
const images: ImagePlaceholder[] = [];
|
||||
let imageCounter = 0;
|
||||
let lastIndex = 0;
|
||||
let rewritten = "";
|
||||
|
||||
const rewritten = markdown.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => {
|
||||
const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)|!\[\[([^\]\n]+)\]\]/g;
|
||||
for (const match of markdown.matchAll(imagePattern)) {
|
||||
const fullMatch = match[0];
|
||||
const matchIndex = match.index ?? 0;
|
||||
const markdownAlt = match[1];
|
||||
const markdownSrc = match[2];
|
||||
const wikilinkTarget = match[3];
|
||||
const wikilinkImage = wikilinkTarget
|
||||
? parseObsidianImageWikilink(wikilinkTarget)
|
||||
: null;
|
||||
|
||||
if (wikilinkTarget && !wikilinkImage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalPath = wikilinkImage?.originalPath ?? markdownSrc ?? "";
|
||||
const alt = wikilinkImage?.alt ?? markdownAlt ?? "";
|
||||
const placeholder = `${placeholderPrefix}${++imageCounter}`;
|
||||
|
||||
rewritten += markdown.slice(lastIndex, matchIndex);
|
||||
images.push({
|
||||
alt,
|
||||
originalPath: src,
|
||||
originalPath,
|
||||
placeholder,
|
||||
});
|
||||
return placeholder;
|
||||
});
|
||||
rewritten += placeholder;
|
||||
lastIndex = matchIndex + fullMatch.length;
|
||||
}
|
||||
|
||||
rewritten += markdown.slice(lastIndex);
|
||||
|
||||
return { images, markdown: rewritten };
|
||||
}
|
||||
@@ -103,10 +126,7 @@ export async function resolveImagePath(
|
||||
return localPath;
|
||||
}
|
||||
|
||||
const resolved = path.isAbsolute(imagePath)
|
||||
? imagePath
|
||||
: path.resolve(baseDir, imagePath);
|
||||
return resolveLocalWithFallback(resolved, logLabel);
|
||||
return resolveLocalImagePath(imagePath, baseDir, logLabel);
|
||||
}
|
||||
|
||||
export async function resolveContentImages(
|
||||
@@ -127,11 +147,79 @@ export async function resolveContentImages(
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveLocalWithFallback(resolved: string, logLabel: string): string {
|
||||
function parseObsidianImageWikilink(target: string): {
|
||||
originalPath: string;
|
||||
alt: string;
|
||||
} | null {
|
||||
const separatorIndex = target.indexOf("|");
|
||||
const originalPath = (separatorIndex === -1
|
||||
? target
|
||||
: target.slice(0, separatorIndex)).trim();
|
||||
const alt = separatorIndex === -1 ? "" : target.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (!hasExplicitImageExtension(originalPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { originalPath, alt };
|
||||
}
|
||||
|
||||
function hasExplicitImageExtension(value: string): boolean {
|
||||
return /\.(?:jpe?g|png|gif|webp)(?:[?#].*)?$/i.test(value);
|
||||
}
|
||||
|
||||
function resolveLocalImagePath(imagePath: string, baseDir: string, logLabel: string): string {
|
||||
const decoded = safeDecodeImagePath(imagePath);
|
||||
const decodedResolved = resolveAgainstBaseDir(decoded, baseDir);
|
||||
const decodedWithFallback = resolveLocalWithFallback(
|
||||
decodedResolved,
|
||||
logLabel,
|
||||
buildAttachmentFallbackPath(decoded, baseDir),
|
||||
);
|
||||
|
||||
if (decoded === imagePath || fs.existsSync(decodedWithFallback)) {
|
||||
return decodedWithFallback;
|
||||
}
|
||||
|
||||
return resolveLocalWithFallback(
|
||||
resolveAgainstBaseDir(imagePath, baseDir),
|
||||
logLabel,
|
||||
buildAttachmentFallbackPath(imagePath, baseDir),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLocalWithFallback(
|
||||
resolved: string,
|
||||
logLabel: string,
|
||||
attachmentResolved?: string,
|
||||
): string {
|
||||
if (fs.existsSync(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
if (attachmentResolved && fs.existsSync(attachmentResolved)) {
|
||||
logImageFallback(resolved, attachmentResolved, logLabel);
|
||||
return attachmentResolved;
|
||||
}
|
||||
|
||||
const originalAlternative = findExtensionFallback(resolved);
|
||||
if (originalAlternative) {
|
||||
logImageFallback(resolved, originalAlternative, logLabel);
|
||||
return originalAlternative;
|
||||
}
|
||||
|
||||
if (attachmentResolved) {
|
||||
const attachmentAlternative = findExtensionFallback(attachmentResolved);
|
||||
if (attachmentAlternative) {
|
||||
logImageFallback(resolved, attachmentAlternative, logLabel);
|
||||
return attachmentAlternative;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function findExtensionFallback(resolved: string): string | null {
|
||||
const ext = path.extname(resolved);
|
||||
const base = ext ? resolved.slice(0, -ext.length) : resolved;
|
||||
const alternatives = [
|
||||
@@ -146,11 +234,33 @@ function resolveLocalWithFallback(resolved: string, logLabel: string): string {
|
||||
|
||||
for (const alternative of alternatives) {
|
||||
if (!fs.existsSync(alternative)) continue;
|
||||
console.error(
|
||||
`[${logLabel}] Image fallback: ${path.basename(resolved)} -> ${path.basename(alternative)}`,
|
||||
);
|
||||
return alternative;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
return null;
|
||||
}
|
||||
|
||||
function logImageFallback(fromPath: string, toPath: string, logLabel: string): void {
|
||||
console.error(
|
||||
`[${logLabel}] Image fallback: ${path.basename(fromPath)} -> ${path.basename(toPath)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function safeDecodeImagePath(imagePath: string): string {
|
||||
try {
|
||||
return decodeURIComponent(imagePath);
|
||||
} catch {
|
||||
return imagePath;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAgainstBaseDir(imagePath: string, baseDir: string): string {
|
||||
return path.isAbsolute(imagePath) ? imagePath : path.resolve(baseDir, imagePath);
|
||||
}
|
||||
|
||||
function buildAttachmentFallbackPath(imagePath: string, baseDir: string): string | undefined {
|
||||
if (path.isAbsolute(imagePath)) {
|
||||
return undefined;
|
||||
}
|
||||
return path.resolve(baseDir, "Attachments", imagePath);
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/bin/bash
|
||||
# codex-imagegen: generate images via Codex CLI's built-in image_gen tool
|
||||
# Thin shell wrapper — implementation in codex-imagegen/main.ts (Bun TypeScript)
|
||||
#
|
||||
# Usage: ./codex-imagegen.sh --help
|
||||
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if command -v bun &>/dev/null; then
|
||||
BUN_X="bun"
|
||||
elif command -v npx &>/dev/null; then
|
||||
BUN_X="npx -y bun"
|
||||
else
|
||||
echo "Error: bun or npx required. Install: brew install oven-sh/bun/bun" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec $BUN_X "$SCRIPT_DIR/codex-imagegen/main.ts" "$@"
|
||||
@@ -1,98 +0,0 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { verifyOutput, verifyImageGenWasInvoked, findCpToTarget } from "./validator.ts";
|
||||
import { GenError } from "./types.ts";
|
||||
|
||||
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
test("verifyOutput passes for valid PNG", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cig-val-"));
|
||||
try {
|
||||
const p = path.join(dir, "good.png");
|
||||
await writeFile(p, Buffer.concat([PNG_HEADER, Buffer.alloc(5000)]));
|
||||
const r = await verifyOutput(p);
|
||||
expect(r.bytes).toBeGreaterThan(1000);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyOutput rejects missing file", async () => {
|
||||
await expect(verifyOutput("/no/such/file.png")).rejects.toBeInstanceOf(GenError);
|
||||
});
|
||||
|
||||
test("verifyOutput rejects tiny file", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cig-val-"));
|
||||
try {
|
||||
const p = path.join(dir, "tiny.png");
|
||||
await writeFile(p, "tiny");
|
||||
await expect(verifyOutput(p)).rejects.toThrow(/too small/);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyOutput rejects non-PNG magic", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cig-val-"));
|
||||
try {
|
||||
const p = path.join(dir, "fake.png");
|
||||
await writeFile(p, Buffer.concat([Buffer.from("GIF89a"), Buffer.alloc(5000)]));
|
||||
await expect(verifyOutput(p)).rejects.toThrow(/not a valid PNG/);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyImageGenWasInvoked false when no thread directory", async () => {
|
||||
const orig = process.env.CODEX_HOME;
|
||||
const tempHome = await mkdtemp(path.join(tmpdir(), "cig-home-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
try {
|
||||
const r = await verifyImageGenWasInvoked("no-such-thread");
|
||||
expect(r.ok).toBe(false);
|
||||
} finally {
|
||||
process.env.CODEX_HOME = orig;
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyImageGenWasInvoked true when PNG exists in thread dir", async () => {
|
||||
const orig = process.env.CODEX_HOME;
|
||||
const tempHome = await mkdtemp(path.join(tmpdir(), "cig-home-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
try {
|
||||
const threadDir = path.join(tempHome, "generated_images", "thread-xyz");
|
||||
await mkdir(threadDir, { recursive: true });
|
||||
await writeFile(path.join(threadDir, "ig_abc.png"), Buffer.alloc(100));
|
||||
const r = await verifyImageGenWasInvoked("thread-xyz");
|
||||
expect(r.ok).toBe(true);
|
||||
} finally {
|
||||
process.env.CODEX_HOME = orig;
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("findCpToTarget detects cp from generated_images", () => {
|
||||
expect(
|
||||
findCpToTarget(
|
||||
[
|
||||
{
|
||||
id: "1",
|
||||
tool: "shell",
|
||||
status: "completed",
|
||||
command: "cp ~/.codex/generated_images/thread/ig_x.png /tmp/out.png",
|
||||
},
|
||||
],
|
||||
"/tmp/out.png",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
findCpToTarget(
|
||||
[{ id: "1", tool: "shell", status: "completed", command: "ls /tmp" }],
|
||||
"/tmp/out.png",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# Sync packages/baoyu-codex-imagegen/src/*.ts (excluding tests) into
|
||||
# skills/baoyu-image-gen/scripts/codex-imagegen/ so the skill stays
|
||||
# self-contained (no `../../../../packages/...` lookups at runtime).
|
||||
#
|
||||
# Run this whenever packages/baoyu-codex-imagegen/src/ changes,
|
||||
# and always before tagging a release.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SRC_DIR="$REPO_ROOT/packages/baoyu-codex-imagegen/src"
|
||||
DST_DIR="$REPO_ROOT/skills/baoyu-image-gen/scripts/codex-imagegen"
|
||||
|
||||
if [[ ! -d "$SRC_DIR" ]]; then
|
||||
echo "Error: source dir missing: $SRC_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DST_DIR"
|
||||
|
||||
changed=0
|
||||
for f in cache.ts logger.ts main.ts parser.ts spawn.ts types.ts validator.ts; do
|
||||
src="$SRC_DIR/$f"
|
||||
dst="$DST_DIR/$f"
|
||||
if [[ ! -f "$src" ]]; then
|
||||
echo "Error: missing source file: $src" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$dst" ]] || ! cmp -s "$src" "$dst"; then
|
||||
cp "$src" "$dst"
|
||||
echo "synced: $f"
|
||||
changed=$((changed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
chmod +x "$DST_DIR/main.ts"
|
||||
|
||||
# Drop any stale .ts files in DST that don't exist in SRC.
|
||||
for dst in "$DST_DIR"/*.ts; do
|
||||
[[ -e "$dst" ]] || continue
|
||||
name="$(basename "$dst")"
|
||||
case "$name" in
|
||||
*.test.ts) rm -f "$dst"; echo "removed test artifact: $name" ;;
|
||||
*)
|
||||
if [[ ! -f "$SRC_DIR/$name" ]]; then
|
||||
rm -f "$dst"
|
||||
echo "removed stale: $name"
|
||||
changed=$((changed + 1))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$changed" -eq 0 ]]; then
|
||||
echo "codex-imagegen sync: up to date"
|
||||
else
|
||||
echo "codex-imagegen sync: $changed file(s) updated"
|
||||
fi
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-article-illustrator
|
||||
description: Analyzes article structure, identifies positions requiring visual aids, generates illustrations with Type × Style × Palette three-dimension approach. Use when user asks to "illustrate article", "add images", "generate images for article", or "为文章配图".
|
||||
version: 1.117.3
|
||||
version: 1.117.4
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-article-illustrator
|
||||
@@ -29,6 +29,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`.
|
||||
@@ -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`.
|
||||
@@ -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,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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,19 +456,19 @@ export class GeminiClient extends GemMixin {
|
||||
const generated_images: GeneratedImage[] = [];
|
||||
const wants_generated =
|
||||
get_nested_value(candidate, [12, 7, 0], null) != null ||
|
||||
/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/.test(text);
|
||||
/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/.test(text) ||
|
||||
// Gemini 3 no longer emits the legacy marker in the candidate text; the generated
|
||||
// image arrives in a later response part. Detect it from the raw response instead.
|
||||
/\/gg-dl\//.test(txt) ||
|
||||
/image_generation_content\/\d+/.test(txt);
|
||||
|
||||
if (wants_generated) {
|
||||
const image_part = find_generated_image_part(response_json as unknown[], body_index, candidate_index, 1);
|
||||
const img_body = image_part?.body ?? null;
|
||||
|
||||
if (!img_body) {
|
||||
throw new ImageGenerationError(
|
||||
'Failed to parse generated images. Please update gemini_webapi to the latest version. If the error persists and is caused by the package, please report it on GitHub.',
|
||||
);
|
||||
}
|
||||
|
||||
const img_candidate = get_nested_value<unknown[]>(img_body, [4, candidate_index], []);
|
||||
// Not every candidate carries a generated image (e.g. multi-candidate responses).
|
||||
// If this candidate has no image part, skip extraction instead of failing the whole call.
|
||||
const img_candidate = img_body ? get_nested_value<unknown[]>(img_body, [4, candidate_index], []) : [];
|
||||
const finished = get_nested_value<string | null>(img_candidate, [1, 0], null);
|
||||
if (finished) {
|
||||
text = finished.replace(/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/g, '').trimEnd();
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "^0.1.0"
|
||||
"baoyu-chrome-cdp": "^0.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 5–10× slower than direct OpenAI / Google API calls (except on cache hits).
|
||||
|
||||
## Supported flags
|
||||
|
||||
| Flag | Behavior |
|
||||
|------|----------|
|
||||
| `--prompt <text>` / `--promptfiles <files>` | Required. Written to a temp file and passed to the wrapper as `--prompt-file`. |
|
||||
| `--image <path>` | Required. Final output PNG location. |
|
||||
| `--ar <ratio>` | Mapped to wrapper's `--aspect`. Supported by Codex: `1:1` (default), `16:9`, `9:16`, `4:3`, `2.35:1`. |
|
||||
| `--ref <files...>` | Mapped to wrapper's repeated `--ref`. Codex's `image_gen` accepts reference images for style/composition guidance. |
|
||||
| `--n` | Must be `1`. `validateArgs` throws if `n > 1` because Codex `image_gen` returns a single image per call. |
|
||||
| `--imageApiDialect` | Not applicable. Throws if set to a non-default value. |
|
||||
| `--size`, `--imageSize`, `--quality` | Silently ignored — Codex picks pixel dimensions from the aspect ratio. |
|
||||
| `--model`, `-m` | Logical label only. The wrapper does not forward a model selector to Codex; the underlying engine is whichever model Codex's `image_gen` currently uses. Default label: `codex-image-gen`. |
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Effect |
|
||||
|----------|--------|
|
||||
| `BAOYU_CODEX_IMAGEGEN_BIN` | Override the wrapper path. Default: bundled `scripts/codex-imagegen/main.ts` resolved relative to this skill's installed location. Accepts a `.ts` file (spawned with `bun`) or a legacy `.sh`/binary (spawned directly). |
|
||||
| `BAOYU_CODEX_IMAGEGEN_CACHE_DIR` | Enable the wrapper's idempotency cache. Disabled by default; set to e.g. `~/.cache/baoyu-codex-imagegen` for high-value reuse. |
|
||||
| `BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS` | Per-attempt `codex exec` timeout in ms. Default: `300000` (5 min). Raise for slow networks or large prompts. |
|
||||
| `BAOYU_CODEX_IMAGEGEN_RETRIES` | Wrapper-side retry attempts on retryable errors. Default: `2` (3 total attempts). |
|
||||
| `BAOYU_CODEX_IMAGEGEN_LOG_FILE` | Append a structured JSONL diagnostic log. Useful when triaging timeouts or `agent_refused` errors. |
|
||||
| `BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY` | Batch-mode concurrency for the `codex-cli` provider. Default: `1` — Codex exec is a heavy single-process workflow; raising this rarely helps. |
|
||||
| `BAOYU_IMAGE_GEN_CODEX_CLI_START_INTERVAL_MS` | Batch-mode minimum start-gap. Default: `2000` ms. |
|
||||
|
||||
## Error model
|
||||
|
||||
The wrapper emits a single JSON line on stdout. On failure:
|
||||
|
||||
```json
|
||||
{"status":"error","path":"...","bytes":0,"error":"...","error_kind":"..."}
|
||||
```
|
||||
|
||||
The provider re-throws each wrapper error as `Invalid codex-cli result (<error_kind>): <message>`. The `"Invalid "` prefix triggers `isRetryableGenerationError` to mark it **non-retryable** in baoyu-image-gen's outer retry loop — the wrapper has already retried internally per `BAOYU_CODEX_IMAGEGEN_RETRIES`, so re-spawning Codex from main.ts would only multiply latency without changing the outcome.
|
||||
|
||||
`error_kind` values to expect:
|
||||
|
||||
| Kind | Cause | Action |
|
||||
|------|-------|--------|
|
||||
| `codex_not_installed` | `codex` not on `PATH` or unreadable | `npm install -g @openai/codex`, then `codex login`. |
|
||||
| `invalid_args` | Programmer error in the spawn invocation | Inspect provider source; usually a path-injection guard fired. |
|
||||
| `prompt_file_missing` | Temp prompt file vanished mid-call | Retry once; check `$TMPDIR` permissions. |
|
||||
| `spawn_failed` | OS / process-launch failure | Verify `bun` or `npx` is installed; check filesystem permissions. |
|
||||
| `timeout` | `codex exec` exceeded `--timeout` | Raise `BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS`; check network. |
|
||||
| `no_image_gen_tool_use` | Codex agent answered without calling `image_gen` | Often transient — retry. If persistent, refine the prompt. |
|
||||
| `output_missing` / `invalid_png` | Agent reported success but file is absent or not a valid PNG | Retry; check disk space. |
|
||||
| `agent_refused` | Codex agent refused (policy or content) | Adjust the prompt; surface the refusal to the user. |
|
||||
| `lock_busy` | Another `codex-imagegen` invocation holds the file lock | Wait or set a distinct `--cache-dir` per concurrent caller. |
|
||||
|
||||
## Trade-offs
|
||||
|
||||
- Slow: 5–10× direct OpenAI API latency (except cache hits).
|
||||
- Subject to the same TOS as interactive `codex exec` use — programmatic invocation from baoyu-image-gen is the same usage class.
|
||||
- Stateful: requires `codex login` to be live; an expired session manifests as `codex_not_installed` or `agent_refused`.
|
||||
|
||||
## See also
|
||||
|
||||
- `references/codex-oauth-vs-openai-api-key.md` — why Codex OAuth is not interchangeable with `OPENAI_API_KEY`.
|
||||
- `references/codex-image2-fallback.md` — when to fall back to `codex-cli` from a failed `openai` provider call.
|
||||
@@ -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 5–10× slower than direct OpenAI / Google API calls (except on cache hits). Tune via `BAOYU_CODEX_IMAGEGEN_TIMEOUT_MS`, `BAOYU_CODEX_IMAGEGEN_RETRIES`, and `BAOYU_CODEX_IMAGEGEN_CACHE_DIR`.
|
||||
|
||||
## Batch Mode
|
||||
|
||||
```bash
|
||||
|
||||
@@ -28,7 +28,8 @@ type PromptReference = {
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`Usage:
|
||||
npx -y tsx scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
|
||||
bun <baseDir>/scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
|
||||
npx -y tsx <baseDir>/scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
|
||||
|
||||
Options:
|
||||
--outline <path> Path to outline.md
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile, copyFile, stat } from "node:fs/promises";
|
||||
import { existsSync, openSync, closeSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
export function cacheKey(prompt: string, aspect: string, refs: string[]): string {
|
||||
const h = createHash("sha256");
|
||||
h.update(prompt);
|
||||
h.update("|");
|
||||
h.update(aspect);
|
||||
h.update("|");
|
||||
for (const r of [...refs].sort()) h.update(r);
|
||||
return h.digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export async function lookupCache(cacheDir: string, key: string): Promise<string | null> {
|
||||
const entry = path.join(cacheDir, `${key}.png`);
|
||||
try {
|
||||
const s = await stat(entry);
|
||||
if (s.size > 1000) return entry;
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function storeCache(cacheDir: string, key: string, sourcePath: string): Promise<void> {
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
const entry = path.join(cacheDir, `${key}.png`);
|
||||
await copyFile(sourcePath, entry);
|
||||
}
|
||||
|
||||
export class FileLock {
|
||||
private fd: number | null = null;
|
||||
constructor(private lockPath: string) {}
|
||||
|
||||
async acquire(timeoutMs = 30_000): Promise<void> {
|
||||
const start = Date.now();
|
||||
await mkdir(path.dirname(this.lockPath), { recursive: true });
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
this.fd = openSync(this.lockPath, "wx");
|
||||
return;
|
||||
} catch (e: any) {
|
||||
if (e.code !== "EEXIST") throw e;
|
||||
if (await this.isStale()) {
|
||||
try {
|
||||
await this.release(true);
|
||||
} catch {}
|
||||
continue;
|
||||
}
|
||||
await delay(200);
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to acquire lock at ${this.lockPath} within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
private async isStale(): Promise<boolean> {
|
||||
try {
|
||||
const s = await stat(this.lockPath);
|
||||
return Date.now() - s.mtimeMs > 10 * 60 * 1000;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async release(force = false): Promise<void> {
|
||||
if (this.fd != null) {
|
||||
try {
|
||||
closeSync(this.fd);
|
||||
} catch {}
|
||||
this.fd = null;
|
||||
}
|
||||
if (existsSync(this.lockPath) || force) {
|
||||
const { unlink } = await import("node:fs/promises");
|
||||
try {
|
||||
await unlink(this.lockPath);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export interface LogEntry {
|
||||
ts: string;
|
||||
level: "info" | "warn" | "error";
|
||||
event: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export class JsonLogger {
|
||||
constructor(private logFile: string | null, public verbose: boolean) {}
|
||||
|
||||
async log(level: LogEntry["level"], event: string, extra: Record<string, unknown> = {}): Promise<void> {
|
||||
const entry: LogEntry = { ts: new Date().toISOString(), level, event, ...extra };
|
||||
const line = JSON.stringify(entry);
|
||||
if (this.verbose) process.stderr.write(`[${level}] ${event} ${jsonExtras(extra)}\n`);
|
||||
if (this.logFile) {
|
||||
await mkdir(path.dirname(this.logFile), { recursive: true });
|
||||
await appendFile(this.logFile, line + "\n", "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
info(event: string, extra?: Record<string, unknown>) {
|
||||
return this.log("info", event, extra);
|
||||
}
|
||||
warn(event: string, extra?: Record<string, unknown>) {
|
||||
return this.log("warn", event, extra);
|
||||
}
|
||||
error(event: string, extra?: Record<string, unknown>) {
|
||||
return this.log("error", event, extra);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonExtras(extra: Record<string, unknown>): string {
|
||||
const entries = Object.entries(extra);
|
||||
if (entries.length === 0) return "";
|
||||
return entries.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" ");
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
#!/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 { hasImageGenEvidence, 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. You MUST call it before doing anything else.
|
||||
|
||||
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 ONLY the image produced by that image_gen call 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 search for, find, inspect, reuse, or copy any pre-existing files from $CODEX_HOME/generated_images/ or any other directory.
|
||||
- Do NOT run ls/find/rg/grep/glob over $CODEX_HOME/generated_images/ before image_gen has been called.
|
||||
- You MUST call image_gen first. Only after image_gen completes may you copy the newly created file from this turn.
|
||||
- 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 ran in THIS thread. A PNG in this thread's
|
||||
// generated_images dir is the real signal (image_gen does not surface as a
|
||||
// stream item); the stream check is a forward-compatible fallback. The #185
|
||||
// shortcut (copying an unrelated history image) yields neither.
|
||||
const ver = await verifyImageGenWasInvoked(run.threadId);
|
||||
if (!hasImageGenEvidence(run.toolCalls, ver.ok)) {
|
||||
throw new GenError(
|
||||
"no_image_gen_tool_use",
|
||||
`image_gen was not invoked (no image_gen event in stream; ${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,56 @@
|
||||
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";
|
||||
import { hasImageGenInvocation } from "./parser.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}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Real evidence that image_gen ran in THIS thread. Codex's image_gen tool does
|
||||
// not surface as a stream item, so a successful run shows only reasoning/shell/
|
||||
// agent_message — `dirHasImage` (a PNG in this thread's generated_images dir) is
|
||||
// what proves it. The stream check is kept as a forward-compatible signal in
|
||||
// case a future Codex version emits the item. The #185 shortcut (copying an
|
||||
// unrelated history image, which lives under a different thread id) yields
|
||||
// neither, so it is correctly rejected.
|
||||
export function hasImageGenEvidence(toolCalls: ToolCall[], dirHasImage: boolean): boolean {
|
||||
return dirHasImage || hasImageGenInvocation(toolCalls);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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`.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-markdown-to-html
|
||||
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.57.0
|
||||
version: 1.117.3
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-markdown-to-html
|
||||
|
||||
@@ -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"/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ interface ImageInfo {
|
||||
placeholder: string;
|
||||
localPath: string;
|
||||
originalPath: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
interface MermaidImageInfo {
|
||||
@@ -58,6 +59,14 @@ type ConvertMarkdownOptions = Partial<Omit<CliOptions, "inputPath">> & {
|
||||
mermaid?: MermaidCliOptions;
|
||||
};
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export async function convertMarkdown(
|
||||
markdownPath: string,
|
||||
options?: ConvertMarkdownOptions,
|
||||
@@ -160,7 +169,12 @@ export async function convertMarkdown(
|
||||
|
||||
let finalContent = fs.readFileSync(finalHtmlPath, "utf-8");
|
||||
for (const image of contentImages) {
|
||||
const imgTag = `<img src="${image.originalPath}" data-local-path="${image.localPath}" style="display: block; width: 100%; margin: 1.5em auto;">`;
|
||||
const altAttr = image.alt !== undefined
|
||||
? ` alt="${escapeHtmlAttribute(image.alt)}"`
|
||||
: "";
|
||||
const imgTag = `<img src="${escapeHtmlAttribute(image.originalPath)}" `
|
||||
+ `data-local-path="${escapeHtmlAttribute(image.localPath)}"${altAttr} `
|
||||
+ `style="display: block; width: 100%; margin: 1.5em auto;">`;
|
||||
finalContent = finalContent.replace(image.placeholder, imgTag);
|
||||
}
|
||||
fs.writeFileSync(finalHtmlPath, finalContent, "utf-8");
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.118.0
|
||||
version: 1.118.2
|
||||
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`):
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ interface ImageInfo {
|
||||
placeholder: string;
|
||||
localPath: string;
|
||||
originalPath: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
interface ParsedResult {
|
||||
|
||||
@@ -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,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.57.0
|
||||
version: 1.117.3
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-weibo
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-post-to-x
|
||||
description: Posts content and articles to X (Twitter). Supports regular posts with images/videos and X Articles (long-form Markdown). In Codex, honor explicit requests for the Codex Chrome plugin/@chrome by using the Chrome Extension workflow; otherwise use Chrome Computer Use when available and fall back to real Chrome CDP scripts only when allowed. Use when user asks to "post to X", "tweet", "publish to Twitter", or "share on X".
|
||||
version: 1.58.0
|
||||
version: 1.58.1
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-x
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"": {
|
||||
"name": "baoyu-post-to-x-scripts",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "^0.1.0",
|
||||
"baoyu-chrome-cdp": "^0.1.1",
|
||||
"baoyu-md": "^0.1.1",
|
||||
"front-matter": "^4.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6",
|
||||
@@ -25,14 +26,30 @@
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"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.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=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="],
|
||||
|
||||
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
|
||||
|
||||
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
||||
|
||||
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||
|
||||
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
@@ -41,20 +58,42 @@
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||
|
||||
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||
|
||||
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||
|
||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"escape-goat": ["escape-goat@3.0.0", "", {}, "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
|
||||
|
||||
"front-matter": ["front-matter@4.0.2", "", { "dependencies": { "js-yaml": "^3.13.1" } }, "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
||||
|
||||
"juice": ["juice@11.1.1", "", { "dependencies": { "cheerio": "1.0.0", "commander": "^12.1.0", "entities": "^7.0.0", "mensch": "^0.3.4", "slick": "^1.12.2", "web-resource-inliner": "^8.0.0" }, "bin": { "juice": "bin/juice" } }, "sha512-4SBfZqKcc6DrIS+5b/WiGoWaZsdUPBH+e6SbRlNjJpaIRtfoBhYReAtobIEW6mcLeFFDXLBJMuZwkJLkBJjs2w=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
@@ -67,6 +106,8 @@
|
||||
|
||||
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"mensch": ["mensch@0.3.4", "", {}, "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
@@ -113,18 +154,36 @@
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
|
||||
|
||||
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
|
||||
|
||||
"reading-time": ["reading-time@1.5.0", "", {}, "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg=="],
|
||||
|
||||
"remark-cjk-friendly": ["remark-cjk-friendly@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"slick": ["slick@1.12.2", "", {}, "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
@@ -135,10 +194,24 @@
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"valid-data-url": ["valid-data-url@3.0.1", "", {}, "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"web-resource-inliner": ["web-resource-inliner@8.0.0", "", { "dependencies": { "ansi-colors": "^4.1.1", "escape-goat": "^3.0.0", "htmlparser2": "^9.1.0", "mime": "^2.4.6", "valid-data-url": "^3.0.0" } }, "sha512-Ezr98sqXW/+OCGoUEXuOKVR+oVFlSdn1tIySEEJdiSAw4IjrW8hQkwARSSBJTSB5Us5dnytDgL0ZDliAYBhaNA=="],
|
||||
|
||||
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseMarkdown } from './md-to-html.ts';
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test('parseMarkdown preserves mixed markdown and Obsidian wikilink image order', async (t) => {
|
||||
const root = await makeTempDir('x-md-to-html-wikilinks-');
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const articleDir = path.join(root, 'article');
|
||||
const attachmentsDir = path.join(articleDir, 'Attachments');
|
||||
const tempDir = path.join(root, 'tmp');
|
||||
await fs.mkdir(attachmentsDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(articleDir, 'a.png'), 'a');
|
||||
await fs.writeFile(path.join(articleDir, 'b.jpg'), 'b');
|
||||
await fs.writeFile(path.join(attachmentsDir, 'c.webp'), 'c');
|
||||
|
||||
const markdownPath = path.join(articleDir, 'post.md');
|
||||
await fs.writeFile(
|
||||
markdownPath,
|
||||
[
|
||||
'# Title',
|
||||
'',
|
||||
'![[a.png]]',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'![[c.webp|C alt]]',
|
||||
'',
|
||||
'![[note]]',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await parseMarkdown(markdownPath, { tempDir });
|
||||
|
||||
assert.deepEqual(
|
||||
result.contentImages.map(({ placeholder, originalPath, alt, localPath }) => ({
|
||||
placeholder,
|
||||
originalPath,
|
||||
alt,
|
||||
localPath,
|
||||
})),
|
||||
[
|
||||
{
|
||||
placeholder: 'XIMGPH_1',
|
||||
originalPath: 'a.png',
|
||||
alt: '',
|
||||
localPath: path.join(articleDir, 'a.png'),
|
||||
},
|
||||
{
|
||||
placeholder: 'XIMGPH_2',
|
||||
originalPath: 'b.jpg',
|
||||
alt: 'B alt',
|
||||
localPath: path.join(articleDir, 'b.jpg'),
|
||||
},
|
||||
{
|
||||
placeholder: 'XIMGPH_3',
|
||||
originalPath: 'c.webp',
|
||||
alt: 'C alt',
|
||||
localPath: path.join(attachmentsDir, 'c.webp'),
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.match(result.html, /XIMGPH_1[\s\S]*XIMGPH_2[\s\S]*XIMGPH_3/);
|
||||
assert.match(result.html, /!\[\[note\]\]/);
|
||||
});
|
||||
|
||||
test('parseMarkdown resolves encoded spaces and literal percent image paths', async (t) => {
|
||||
const root = await makeTempDir('baoyu-post-to-x-images-');
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const articlePath = path.join(root, 'article.md');
|
||||
const tempDir = path.join(root, 'tmp');
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(root, 'Pasted image.png'), 'png');
|
||||
await fs.writeFile(path.join(root, '100%.png'), 'png');
|
||||
await fs.writeFile(
|
||||
articlePath,
|
||||
[
|
||||
'# Title',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await parseMarkdown(articlePath, { tempDir });
|
||||
|
||||
assert.equal(result.contentImages[0]?.localPath, path.join(root, 'Pasted image.png'));
|
||||
assert.equal(result.contentImages[1]?.localPath, path.join(root, '100%.png'));
|
||||
});
|
||||
|
||||
test('parseMarkdown renders CJK-adjacent bold and italics (no literal asterisks)', async (t) => {
|
||||
const root = await makeTempDir('x-md-to-html-cjk-bold-');
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const markdownPath = path.join(root, 'post.md');
|
||||
const tempDir = path.join(root, 'tmp');
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
markdownPath,
|
||||
[
|
||||
'# 标题',
|
||||
'',
|
||||
'分工在变细。**国际大厂卷基础设施,中文项目卷场景落地。**这其实是生态成熟的表现。',
|
||||
'',
|
||||
'半角场景 **Top 10 里平均有 8 个** 项目。',
|
||||
'',
|
||||
'斜体 *数据来源 GitHub* 收尾。',
|
||||
'',
|
||||
'参考 **[docs][d]** 了解更多。',
|
||||
'',
|
||||
'[d]: https://example.com',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await parseMarkdown(markdownPath, { tempDir });
|
||||
|
||||
// Bold directly adjacent to CJK (closing ** followed by CJK) must render.
|
||||
assert.match(result.html, /<strong>国际大厂卷基础设施,中文项目卷场景落地。<\/strong>/);
|
||||
assert.match(result.html, /<strong>Top 10 里平均有 8 个<\/strong>/);
|
||||
// Italics.
|
||||
assert.match(result.html, /<em>数据来源 GitHub<\/em>/);
|
||||
// Reference-style links inside emphasis must render as links, not plain text.
|
||||
assert.match(result.html, /<strong><a href="https:\/\/example\.com" rel="noopener noreferrer nofollow">docs<\/a><\/strong>/);
|
||||
// No literal emphasis delimiters should leak into the output.
|
||||
assert.doesNotMatch(result.html, /\*\*/);
|
||||
assert.doesNotMatch(result.html, /(?<!\*)\*(?!\*)[^*\n]+\*(?!\*)/);
|
||||
});
|
||||
|
||||
test('parseMarkdown does not decode author-written literal HTML entities into tags', async (t) => {
|
||||
const root = await makeTempDir('x-md-to-html-entities-');
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const markdownPath = path.join(root, 'post.md');
|
||||
const tempDir = path.join(root, 'tmp');
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
markdownPath,
|
||||
[
|
||||
'# 标题',
|
||||
'',
|
||||
'正文中写 <b>literal</b> 想显示字面标签。**加粗**收尾。',
|
||||
'',
|
||||
'代码里写 `<b>` 同样保留。',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await parseMarkdown(markdownPath, { tempDir });
|
||||
|
||||
// CJK-adjacent bold still renders.
|
||||
assert.match(result.html, /<strong>加粗<\/strong>/);
|
||||
// Author-written literal entities must NOT be decoded into real tags.
|
||||
assert.doesNotMatch(result.html, /<b>literal<\/b>/);
|
||||
assert.match(result.html, /<b>literal<\/b>/);
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import https from 'node:https';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import frontMatter from 'front-matter';
|
||||
@@ -15,7 +13,11 @@ import remarkCjkFriendly from 'remark-cjk-friendly';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkStringify from 'remark-stringify';
|
||||
|
||||
import { preprocessMermaidInMarkdown } from 'baoyu-md';
|
||||
import {
|
||||
preprocessMermaidInMarkdown,
|
||||
replaceMarkdownImagesWithPlaceholders,
|
||||
resolveImagePath,
|
||||
} from 'baoyu-md';
|
||||
import { closeRenderer, renderMermaidToPng } from 'baoyu-chrome-cdp/mermaid';
|
||||
|
||||
interface ImageInfo {
|
||||
@@ -23,6 +25,7 @@ interface ImageInfo {
|
||||
localPath: string;
|
||||
originalPath: string;
|
||||
blockIndex: number;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
interface ParsedMarkdown {
|
||||
@@ -109,85 +112,6 @@ function extractTitleFromMarkdown(markdown: string): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function downloadFile(url: string, destPath: string, maxRedirects = 5): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!url.startsWith('https://')) {
|
||||
reject(new Error(`Refusing non-HTTPS download: ${url}`));
|
||||
return;
|
||||
}
|
||||
if (maxRedirects <= 0) {
|
||||
reject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
const file = fs.createWriteStream(destPath);
|
||||
|
||||
const request = https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (response) => {
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
const redirectUrl = response.headers.location;
|
||||
if (redirectUrl) {
|
||||
file.close();
|
||||
fs.unlinkSync(destPath);
|
||||
downloadFile(redirectUrl, destPath, maxRedirects - 1).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close();
|
||||
fs.unlinkSync(destPath);
|
||||
reject(new Error(`Failed to download: ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
response.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', (err) => {
|
||||
file.close();
|
||||
fs.unlink(destPath, () => {});
|
||||
reject(err);
|
||||
});
|
||||
|
||||
request.setTimeout(30000, () => {
|
||||
request.destroy();
|
||||
reject(new Error('Download timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getImageExtension(urlOrPath: string): string {
|
||||
const match = urlOrPath.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i);
|
||||
return match ? match[1]!.toLowerCase() : 'png';
|
||||
}
|
||||
|
||||
async function resolveImagePath(imagePath: string, baseDir: string, tempDir: string): Promise<string> {
|
||||
if (imagePath.startsWith('http://')) {
|
||||
console.error(`[md-to-html] Skipping non-HTTPS image: ${imagePath}`);
|
||||
return '';
|
||||
}
|
||||
if (imagePath.startsWith('https://')) {
|
||||
const hash = createHash('md5').update(imagePath).digest('hex').slice(0, 8);
|
||||
const ext = getImageExtension(imagePath);
|
||||
const localPath = path.join(tempDir, `remote_${hash}.${ext}`);
|
||||
|
||||
if (!fs.existsSync(localPath)) {
|
||||
console.error(`[md-to-html] Downloading: ${imagePath}`);
|
||||
await downloadFile(imagePath, localPath);
|
||||
}
|
||||
return localPath;
|
||||
}
|
||||
|
||||
if (path.isAbsolute(imagePath)) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
return path.resolve(baseDir, imagePath);
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
@@ -197,6 +121,10 @@ function escapeHtml(text: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function highlightCode(code: string, lang: string): string {
|
||||
try {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
@@ -208,6 +136,21 @@ function highlightCode(code: string, lang: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize CJK-adjacent emphasis so `marked` renders it correctly.
|
||||
//
|
||||
// `marked`'s emphasis tokenizer treats a closing `**`/`*` directly followed by a
|
||||
// CJK character as not right-flanking, so it leaves the delimiters literal
|
||||
// (e.g. `**加粗**这` renders as plain text with the asterisks intact). We round-trip
|
||||
// the markdown through `remark-cjk-friendly`, whose stringify serializes the
|
||||
// boundary character as an HTML entity (`这`); the entity is treated as
|
||||
// punctuation by `marked`'s flanking rules, so emphasis parses as expected.
|
||||
//
|
||||
// We deliberately do NOT decode the entities afterward. They are valid HTML
|
||||
// character references that render correctly when the article HTML is pasted into
|
||||
// the X editor, and `marked` only emits them for characters outside the emphasis
|
||||
// span (the boundary char), never inside it. A blanket decode of the rendered
|
||||
// HTML would risk turning author-written literal entities (e.g. `<b>`
|
||||
// meant to display `<b>` as text) into real tags, so we leave them intact.
|
||||
function preprocessCjkMarkdown(markdown: string): string {
|
||||
try {
|
||||
const processor = unified()
|
||||
@@ -215,14 +158,13 @@ function preprocessCjkMarkdown(markdown: string): string {
|
||||
.use(remarkCjkFriendly)
|
||||
.use(remarkStringify);
|
||||
|
||||
const result = String(processor.processSync(markdown));
|
||||
return result.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)));
|
||||
return String(processor.processSync(markdown));
|
||||
} catch {
|
||||
return markdown;
|
||||
}
|
||||
}
|
||||
|
||||
function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } {
|
||||
function convertMarkdownToHtml(markdown: string): { html: string; totalBlocks: number } {
|
||||
const preprocessedMarkdown = preprocessCjkMarkdown(markdown);
|
||||
const blockTokens = Lexer.lex(preprocessedMarkdown, { gfm: true, breaks: true });
|
||||
|
||||
@@ -254,7 +196,7 @@ function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, al
|
||||
|
||||
image({ href, text }: Tokens.Image): string {
|
||||
if (!href) return '';
|
||||
return imageCallback(href, text ?? '');
|
||||
return escapeHtml(text ?? '');
|
||||
},
|
||||
|
||||
link({ href, title, tokens, text }: Tokens.Link): string {
|
||||
@@ -340,22 +282,20 @@ export async function parseMarkdown(
|
||||
);
|
||||
}
|
||||
|
||||
const images: Array<{ src: string; alt: string; blockIndex: number }> = [];
|
||||
let imageCounter = 0;
|
||||
|
||||
const { html, totalBlocks } = convertMarkdownToHtml(mermaidProcessedBody, (src, alt) => {
|
||||
const placeholder = `XIMGPH_${++imageCounter}`;
|
||||
images.push({ src, alt, blockIndex: -1 });
|
||||
return placeholder;
|
||||
});
|
||||
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
|
||||
mermaidProcessedBody,
|
||||
'XIMGPH_',
|
||||
);
|
||||
const { html, totalBlocks } = convertMarkdownToHtml(rewrittenBody);
|
||||
|
||||
const htmlLines = html.split('\n');
|
||||
const imageBlockIndexes = new Map<string, number>();
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const placeholder = `XIMGPH_${i + 1}`;
|
||||
const placeholder = images[i]!.placeholder;
|
||||
for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) {
|
||||
const regex = new RegExp(`\\b${placeholder}\\b`);
|
||||
const regex = new RegExp(`\\b${escapeRegExp(placeholder)}\\b`);
|
||||
if (regex.test(htmlLines[lineIndex]!)) {
|
||||
images[i]!.blockIndex = lineIndex;
|
||||
imageBlockIndexes.set(placeholder, lineIndex);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -366,17 +306,18 @@ export async function parseMarkdown(
|
||||
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const img = images[i]!;
|
||||
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
|
||||
const localPath = await resolveImagePath(img.originalPath, baseDir, tempDir, 'md-to-html');
|
||||
|
||||
if (i === 0 && !coverImagePath) {
|
||||
firstImageAsCover = localPath;
|
||||
}
|
||||
|
||||
contentImages.push({
|
||||
placeholder: `XIMGPH_${i + 1}`,
|
||||
placeholder: img.placeholder,
|
||||
localPath,
|
||||
originalPath: img.src,
|
||||
blockIndex: img.blockIndex,
|
||||
originalPath: img.originalPath,
|
||||
alt: img.alt,
|
||||
blockIndex: imageBlockIndexes.get(img.placeholder) ?? -1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -384,7 +325,7 @@ export async function parseMarkdown(
|
||||
|
||||
let resolvedCoverImage: string | null = null;
|
||||
if (coverImagePath) {
|
||||
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
|
||||
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir, 'md-to-html');
|
||||
} else if (firstImageAsCover) {
|
||||
resolvedCoverImage = firstImageAsCover;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"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",
|
||||
"front-matter": "^4.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-slide-deck
|
||||
description: Generates professional slide deck images from content. Creates outlines with style instructions, then generates individual slide images. Use when user asks to "create slides", "make a presentation", "generate deck", "slide deck", or "PPT".
|
||||
version: 1.117.3
|
||||
version: 1.117.4
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-slide-deck
|
||||
@@ -33,6 +33,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-slide-[slug].md`) BEFORE invoking any backend. 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
|
||||
|
||||
@@ -297,6 +299,7 @@ Display the prompts index (`# | Filename | Slide Title`) and ask: proceed / edit
|
||||
### Step 7: Generate Images
|
||||
|
||||
1. Resolve the image backend via the Image Generation Tools rule at the top — ask once if multiple are installed.
|
||||
- **`codex-imagegen` invocation**: when the rule resolves to `codex-imagegen`, see [references/codex-imagegen.md](references/codex-imagegen.md) for the invocation contract (preferred `baoyu-image-gen --provider codex-cli` path, runtime wrapper discovery, parameter notes, stdout schema, batch semantics — n=1 per call so slide batches must dispatch one wrapper call per slide).
|
||||
2. Confirm every `prompts/NN-slide-{slug}.md` exists (hard requirement; prompt files are the reproducibility record regardless of backend).
|
||||
3. Session ID: `slides-{topic-slug}-{timestamp}` — pass to the backend only if it supports sessions.
|
||||
4. Build a task list for selected slides with each slide's prompt file, output PNG path, aspect ratio, session ID, and verified direct references.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# `codex-imagegen` Wrapper Invocation
|
||||
|
||||
Load this reference only when the [Image Generation Tools](../SKILL.md#image-generation-tools) rule has resolved to `codex-imagegen` — i.e., the current runtime exposes no native `imagegen` skill but `codex` CLI is on `PATH` with an active `codex login`.
|
||||
|
||||
## Preferred path: route through `baoyu-image-gen`
|
||||
|
||||
If the `baoyu-image-gen` skill is available in this runtime, **always** invoke through it rather than calling the wrapper directly. It handles retry/cache/batch/EXTEND.md preferences uniformly with every other provider.
|
||||
|
||||
```bash
|
||||
${BUN_X} <baoyu-image-gen-base>/scripts/main.ts \
|
||||
--provider codex-cli \
|
||||
--image <ABSOLUTE_output> \
|
||||
--promptfiles <ABSOLUTE_prompts/NN-slide-{slug}.md> \
|
||||
--ar <ratio> \
|
||||
[--ref <ABSOLUTE_file>]...
|
||||
```
|
||||
|
||||
Resolve `<baoyu-image-gen-base>` the same way you resolve any sibling skill — through your runtime's skill registry (`Skill` tool, plugin marketplace, or `$HOME/.baoyu-skills/baoyu-image-gen/`).
|
||||
|
||||
## Fallback: spawn the wrapper directly
|
||||
|
||||
Only when `baoyu-image-gen` is NOT installed in the current runtime. Discover the wrapper's location at runtime — do NOT hard-code `../../packages/...` from this skill:
|
||||
|
||||
1. **Honor explicit override**: if `$BAOYU_CODEX_IMAGEGEN_BIN` is set and points to a real file, use that path. It may be `.ts` (spawn `bun <path>`) or `.sh`/binary (spawn directly).
|
||||
2. **Search the plugin root**: walk up from this skill's directory looking for `packages/baoyu-codex-imagegen/src/main.ts`. If found, that is the wrapper. Spawn it with `bun`.
|
||||
3. **Last resort**: tell the user that `codex-imagegen` is not available in this runtime and ask whether to install the `baoyu-skills` plugin (or set `BAOYU_CODEX_IMAGEGEN_BIN`) or pick another backend.
|
||||
|
||||
Once located, the invocation shape is:
|
||||
|
||||
```bash
|
||||
bun <WRAPPER>/main.ts \
|
||||
--image <ABSOLUTE_output> \
|
||||
--prompt-file <ABSOLUTE_prompts/NN-slide-{slug}.md> \
|
||||
--aspect <ratio> \
|
||||
[--ref <ABSOLUTE_file>]... \
|
||||
[--timeout <ms>] \
|
||||
[--cache-dir ~/.cache/baoyu-codex-imagegen] \
|
||||
[--log-file <ABSOLUTE_jsonl_log_path>]
|
||||
```
|
||||
|
||||
If `bun` is missing, `npx -y bun <WRAPPER>/main.ts ...` works as a fallback.
|
||||
|
||||
## Parameter notes
|
||||
|
||||
- **All filesystem inputs** are auto-resolved against `process.cwd()` when relative, but agents should pass absolute paths to be robust against cwd drift.
|
||||
- **`--timeout`** defaults to `300000` (5 min) per `codex exec` attempt. Raise (e.g. `--timeout 600000` for 10 min) on slow networks or large prompts.
|
||||
- **`--cache-dir`** is off by default. Enable for repeatable runs to skip redundant generations of the same prompt+aspect+refs.
|
||||
- **Authentication**: the wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` is read or sent.
|
||||
|
||||
## Stdout contract
|
||||
|
||||
Single JSON line:
|
||||
|
||||
- Success: `{"status":"ok","path":"...","bytes":N,"elapsed_seconds":N,"thread_id":"...","attempts":N,"cached":bool,...}`
|
||||
- Failure: `{"status":"error","path":"...","bytes":0,"error":"...","error_kind":"..."}`
|
||||
|
||||
`error_kind` values: `codex_not_installed`, `invalid_args`, `prompt_file_missing`, `spawn_failed`, `timeout`, `no_image_gen_tool_use`, `output_missing`, `invalid_png`, `agent_refused`, `lock_busy`.
|
||||
|
||||
On retryable errors (timeout, spawn_failed, no_image_gen_tool_use, output_missing, invalid_png, agent_refused), ask the user whether to retry or fall back to another backend.
|
||||
|
||||
## Batch semantics
|
||||
|
||||
- Codex `image_gen` returns **one image per call** (`n=1` only). Multi-image jobs must dispatch one wrapper call per image.
|
||||
- The wrapper does NOT accept a `--sessionId` flag. Chain/scene consistency must come from `--ref` reference images.
|
||||
- `--size` and `--quality` are silently ignored — Codex picks pixel dimensions from `--aspect`.
|
||||
@@ -1,7 +1,12 @@
|
||||
---
|
||||
name: baoyu-translate
|
||||
description: Translates articles and documents between languages with three modes - quick (direct), normal (analyze then translate), and refined (analyze, translate, review, polish). Supports custom glossaries and terminology consistency via EXTEND.md. Use when user asks to "translate", "翻译", "精翻", "translate article", "translate to Chinese/English", "改成中文", "改成英文", "convert to Chinese", "localize", "本地化", or needs any document translation. Also triggers for "refined translation", "精细翻译", "proofread translation", "快速翻译", "快翻", "这篇文章翻译一下", or when a URL or file is provided with translation intent.
|
||||
version: 1.59.0
|
||||
description: >-
|
||||
This skill should be used when the user asks to "translate", "翻译", "精翻", "translate article",
|
||||
"translate to Chinese", "translate to English", "改成中文", "改成英文", "convert to Chinese",
|
||||
"localize", "本地化", "refined translation", "精细翻译", "proofread translation", "快速翻译", "快翻",
|
||||
"这篇文章翻译一下", or provides a URL/file with translation intent. Supports three modes
|
||||
(quick/normal/refined) with custom glossary support.
|
||||
version: 1.117.3
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-translate
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user