Compare commits

..

152 Commits

Author SHA1 Message Date
Jim Liu 宝玉 6b7a2e4175 更新 baoyu-wechat-summary 技能:调整输出顺序并拆分环境/群记忆参考文件 2026-07-03 20:22:55 -05:00
Jim Liu 宝玉 a4e78af813 Merge branch 'main' of https://github.com/JimLiu/baoyu-skills 2026-07-01 23:41:41 -05:00
Jim Liu 宝玉 1378544ea2 baoyu-wechat-summary v1.118.0: 归因校验、群主查证、memory 强制留痕、机械计数 2026-07-01 21:34:41 -05:00
wangruofeng c9a50cc908 fix(baoyu-post-to-x): 修复紧贴中文的加粗/斜体在 X Articles 渲染为字面 ** 的问题 (#189)
* fix(baoyu-post-to-x): render CJK-adjacent bold/italics in articles

md-to-html.ts preprocessed markdown with remark + remark-cjk-friendly, then re-stringified and re-parsed with marked. remark-cjk-friendly parses emphasis adjacent to CJK correctly, but remark-stringify re-emits the **bold*/*italic* markers, which marked then FAILS to parse: its flanking test does not treat a CJK character after the closing delimiter as valid punctuation/whitespace. Result: literal **...** asterisks leak into the pasted X Article body whenever the closing delimiter is directly followed by a CJK character.

Fix: convert strong/emphasis mdast nodes (already identified correctly by remark-cjk-friendly) into raw inline <strong>/<em> HTML before stringify, so marked passes them through untouched. A small recursive serializer preserves nested links, inline code, images, etc.

- add remarkStrongEmToHtml remark plugin + inline mdast serializer
- add unist-util-visit as an explicit dependency
- add regression test covering CJK-adjacent bold and italics

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(baoyu-post-to-x): 修复紧贴中文的加粗/斜体在 X Articles 渲染为字面 ** 的问题

按维护者建议改用 remark-cjk-friendly 标准用法,替代自定义插件/序列化器:

- 删除 remarkStrongEmToHtml 插件与 serializeMdastNode 序列化器
- preprocessCjkMarkdown 简化为 remarkParse + remarkCjkFriendly + remarkStringify 往返
- 移除原 markdown 阶段的 &#x...; 实体解码(该解码会还原 cjk-friendly 为
  骗过 marked flanking 判定而生成的实体,正是 CJK 加粗失败的根因)
- 改在 marked 渲染出最终 HTML 后再 decodeHtmlEntities,此时 markdown 语法
  已不存在,解码安全且保持输出干净
- 移除不再使用的 unist-util-visit 显式依赖
- 测试补充引用式链接 **[docs][d]** 在强调内的回归断言(顺带修复 Codex
  指出的 linkReference 渲染为纯文本的 P2 问题)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(baoyu-post-to-x): 移除渲染后无差别实体解码,避免篡改作者字面实体

回应 Codex review (P2):decodeHtmlEntities 对整个渲染 HTML 做无差别
&#x...; 解码,会把作者为显示字面 HTML 而写的实体(如 &#x3C;b&#x3E;)
解码成真实标签,存在内容篡改与 HTML 注入风险。

该解码本就只是为了让输出 HTML「好看」,而 remark-cjk-friendly 引入的
实体是合法 HTML 字符引用,粘进 X 编辑器时能正确渲染;且这些实体只会
出现在强调 span 的外侧(边界字符),不会出现在 span 内部。因此直接
移除解码,既消除过度解码风险,又无功能损失。

- 删除 decodeHtmlEntities 及其在 convertMarkdownToHtml 的调用
- 新增测试:作者字面实体 &#x3C;b&#x3E; 不被解码为真实 <b>,CJK 加粗
  仍正常渲染
- bun test 8 个用例全部通过;真实文章端到端验证 17 处加粗、0 字面 **、
  0 真实标签注入

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: ruofeng <ruofeng.wang@rd.group>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-28 01:42:07 -05:00
Jim Liu 宝玉 348dc82612 Add explicit MIT license 2026-06-19 23:01:55 -05:00
Jim Liu 宝玉 dba3d732c3 Merge pull request #187 from jiji262/fix/gemini3-image-extraction
fix(gemini-web): recover generated images on Gemini 3
2026-06-19 22:45:31 -05:00
jiji262 661bdd08ff fix(gemini-web): recover generated images on Gemini 3
Gemini 3 stopped emitting the legacy `image_generation_content` marker in
the candidate text and moved the generated image into a later response
part. The `wants_generated` gate only checked the candidate text / a single
structured field, so on Gemini 3 it was never true and the image was
silently dropped: generate_content returned an empty result even though the
gg-dl image URL was present in the raw response.

- Broaden `wants_generated` to also fire when the raw response contains a
  `gg-dl/` generated-image URL or an `image_generation_content` marker.
- When a candidate has no image part, skip extraction instead of throwing
  ImageGenerationError, so multi-candidate responses don't fail wholesale.

Verified against a live Gemini 3 account: image generation returns the
generated image again with the default gemini-3-pro model.
2026-06-20 11:37:12 +08:00
Jim Liu 宝玉 f490f7e5e3 chore: release v2.5.2 2026-06-18 14:14:54 -05:00
Jim Liu 宝玉 22031aa2d2 docs(project): document Codex project-level install and WeChat credential scopes 2026-06-18 14:13:43 -05:00
Jim Liu 宝玉 51b31aa050 fix(baoyu-image-gen): require real image_gen evidence in 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 a PNG in this thread's generated_images dir (or an image_gen
stream item, kept as a forward-compatible signal), and the spawned-agent
instruction forbids reading or reusing history images before image_gen is
called. Removes the findCpToTarget fallback that allowed the shortcut.

Validated against a real codex exec run: image_gen leaves no stream item,
so the filesystem check is the load-bearing signal; locked in via a
condensed real-stream regression fixture.

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

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

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

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

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

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

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

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

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

New env vars: BAOYU_CODEX_IMAGEGEN_{BIN,CACHE_DIR,TIMEOUT_MS,RETRIES,LOG_FILE}.
Hyphenated provider names now resolve under-scored env vars (e.g.,
BAOYU_IMAGE_GEN_CODEX_CLI_CONCURRENCY). codex-cli is never auto-selected
— pin via --provider or default_provider in EXTEND.md.
2026-05-25 00:18:27 -05:00
Jim Liu 宝玉 23fac03691 refactor(codex-imagegen): extract backend to packages/baoyu-codex-imagegen workspace
Move the codex-imagegen wrapper out of scripts/ into its own workspace
package alongside baoyu-md, baoyu-chrome-cdp, and baoyu-fetch. Drop the
bash shim — src/main.ts now carries a `#\!/usr/bin/env bun` shebang and
serves as the sole entrypoint. Add scripts/sync-codex-imagegen.sh so
skills/baoyu-image-gen/scripts/codex-imagegen/ can be regenerated from
packages/baoyu-codex-imagegen/src/ for skill self-containment. Update
CLAUDE.md, docs/codex-imagegen-backend.md, and the CI workflow paths
accordingly.
2026-05-25 00:18:16 -05:00
Jim Liu 宝玉 66160abb9f chore: release v2.1.0 2026-05-24 21:27:57 -05:00
Jim Liu 宝玉 b34e19daad feat(baoyu-post-to-wechat,baoyu-post-to-weibo,baoyu-post-to-x): cascade Mermaid PNG preprocessing into publishing pipelines 2026-05-24 21:27:53 -05:00
Jim Liu 宝玉 cda76e20fd feat(baoyu-markdown-to-html): render Mermaid code blocks to PNG via headless Chrome 2026-05-24 21:27:43 -05:00
Jim Liu 宝玉 8d2fd7aae0 feat(baoyu-md): add Mermaid preprocessing and utility modules 2026-05-24 21:27:38 -05:00
Jim Liu 宝玉 f60702cc8e feat(baoyu-chrome-cdp): add Mermaid-to-PNG rendering via CDP 2026-05-24 21:27:33 -05:00
Jim Liu 宝玉 613bee6bb8 feat(build): support multi-entry output in shared package build script 2026-05-24 21:27:28 -05:00
Jim Liu 宝玉 e63e21786d chore: release v2.0.1 2026-05-24 18:59:16 -05:00
Jim Liu 宝玉 b3a3b632c8 fix(baoyu-post-to-wechat): repair htmlToPlainText syntax error and entity decoding
Follow-up to the previous newspic conversion commit:

- `&ldquo;` / `&rdquo;` mapped to `"""` (three straight double-quotes),
  producing an unterminated string literal that prevented wechat-api.ts
  from parsing. Replaced with the intended curly Unicode characters.
- `&lsquo;` / `&rsquo;` similarly mapped to straight `'`; switched to the
  curly singles to match the entity meaning.
- Numeric entities used `String.fromCharCode`, which mangles code points
  above 0xFFFF (e.g. emoji like `&#128512;` → empty string). Switched to
  `String.fromCodePoint`.
- Added hexadecimal numeric entity support (`&#x1F600;` etc.); the
  previous regex only matched decimal forms.
- Anchored the entity sub-patterns with `^...$` so the callback cannot
  accidentally re-match a substring of the captured entity.
2026-05-24 18:57:01 -05:00
Go1dFinger 3aafe60463 fix(baoyu-post-to-wechat): improve newspic plain text conversion for WeChat API
The previous fix used a simple regex to strip HTML tags for newspic
content, but it had several issues:

1. HTML entities (&nbsp;, &lt;, &gt;, &amp;, etc.) were not decoded
2. Block-level tags (</p>, </div>, </h1> etc.) did not produce line breaks
3. Multiple consecutive whitespace characters were not collapsed

This commit introduces a dedicated htmlToPlainText() function that:
- Converts <br> and block-level closing tags to line breaks
- Strips all remaining HTML tags
- Decodes common HTML entities (including numeric entities)
- Collapses consecutive whitespace into single spaces
- Trims whitespace from each line

This ensures the content field for newspic articles is properly
formatted plain text that complies with WeChat API requirements,
preventing error 45166 (invalid content) especially when publishing
with multiple images.

Closes #163
Merges #164
2026-05-24 18:55:59 -05:00
Jim Liu 宝玉 246e90bf28 chore: align baoyu-{article-illustrator,comic,infographic,slide-deck} SKILL.md versions with ClawHub 2026-05-24 18:36:45 -05:00
Jim Liu 宝玉 c3cbce9ce3 feat\!: rename baoyu-imagine→baoyu-image-gen, baoyu-image-cards→baoyu-xhs-images (v2.0.0)
BREAKING CHANGE: removed `baoyu-imagine` and `baoyu-image-cards`. All
functionality now lives under `baoyu-image-gen` and `baoyu-xhs-images`
respectively. Cross-skill `## Image Generation Tools` examples updated
across baoyu-article-illustrator, baoyu-comic, baoyu-cover-image,
baoyu-infographic, and baoyu-slide-deck.

Migration: existing `~/.baoyu-skills/baoyu-imagine/EXTEND.md` configs are
auto-renamed to `…/baoyu-image-gen/EXTEND.md` on first run via the
legacy-path resolver in `scripts/main.ts`.
2026-05-24 18:35:05 -05:00
Jim Liu 宝玉 bc303345f4 chore(baoyu-imagine): align SKILL.md version with ClawHub 2026-05-24 17:14:11 -05:00
Jim Liu 宝玉 17d7d6105c chore(baoyu-cover-image): align SKILL.md version with ClawHub 2026-05-24 17:14:11 -05:00
Jim Liu 宝玉 50c6c72944 docs(readme): warn users to install only the skills they need
The marketplace bundles 20+ skills into one plugin, and bulk-installing
all of them adds context overhead to the AI agent on every run. Add a
short tip at the top of the Installation section so users browse the
available list and pick what they actually need.
2026-05-24 17:05:49 -05:00
Jim Liu 宝玉 ad1755fa23 chore: release v1.119.0 2026-05-24 17:00:39 -05:00
Jim Liu 宝玉 3cc1225b18 feat(baoyu-electron-extract): add new skill to extract Electron app sources
Extracts resources and JavaScript from any installed Electron app's
app.asar. When .js.map files embed sourcesContent, restores the
original source tree (TypeScript/JSX included); otherwise formats
minified JS/CSS with Prettier in place. Always skips node_modules
and webpack/runtime/* entries.

Source-map paths are resolved relative to each .js.map file first,
so bundler-relative entries like ../../src/main.ts restore to
readable paths under restored/ instead of hashed placeholders.

Auto-discovers apps on macOS (/Applications, ~/Applications) and
Windows (%LOCALAPPDATA%\Programs, %PROGRAMFILES%, %PROGRAMFILES(X86)%,
%APPDATA%). On other platforms pass --asar <path> explicitly.

Safety: refuses to write to /, the user home, or the current working
directory, and refuses non-empty existing output dirs without --force.
2026-05-24 17:00:31 -05:00
Jim Liu 宝玉 460bd087c6 Merge pull request #161 from xiaoyaner0201/docs/baoyu-imagine-codex-image2-notes
docs(baoyu-imagine): clarify Codex image2 fallback
2026-05-23 11:23:19 -05:00
千乘妍 (Xiaoyaner) daf0fb7bec docs(baoyu-imagine): clarify Codex image2 fallback 2026-05-22 16:45:30 +08:00
Jim Liu 宝玉 adbfa3036b chore: release v1.118.0 2026-05-21 16:56:17 -05:00
Jim Liu 宝玉 6026b619f0 Merge pull request #158 from yelban/feat/codex-imagegen-backend
feat: add codex-imagegen backend for non-Codex runtimes
2026-05-21 16:45:22 -05:00
Jim Liu 宝玉 1406a85331 chore: release v1.117.5 2026-05-21 08:55:07 -05:00
Jim Liu 宝玉 dac5867f8a chore: release v1.117.4 2026-05-21 08:52:09 -05:00
Jim Liu 宝玉 49829e7f98 Merge pull request #159 from JimLiu/feat/wechat-remote-api-bun-fix
feat(baoyu-post-to-wechat): remote-api publishing via SSH SOCKS5 tunnel
2026-05-21 08:46:11 -05:00
Yelban df16bd5d1a feat(codex-imagegen): cwd-drift immunity (path resolve + skip-git-repo-check)
Two improvements so the wrapper works the same regardless of caller cwd
or whether cwd is a git repo:

1. main.ts: resolve all filesystem path args (--prompt-file, --ref,
   --cache-dir, --log-file) to absolute paths up front. Previously only
   --image was resolved. Agents passing relative paths from arbitrary
   working directories (e.g. when SKILL.md is interpreted from a plugin
   install dir) now produce correct file lookups.

2. spawn.ts: pass --skip-git-repo-check to 'codex exec'. Without it,
   codex refuses to run outside a trusted directory:
   'Not inside a trusted directory and --skip-git-repo-check was not
   specified.' This made the wrapper fail when invoked from /tmp or
   from a non-git project tree.

3. baoyu-cover-image/SKILL.md: explicit path resolution note —
   scripts/codex-imagegen.sh lives at plugin/repo root (not shell
   cwd-relative). From the skill base directory it is at
   '../../scripts/codex-imagegen.sh'. Agents should resolve to an
   absolute path before invoking.

Verified:
- 16 unit tests pass
- e2e from /tmp (non-git, relative-path args) → 45s, 1.6MB PNG, status:ok

Composes with the existing cover-image wiring (commit e3932e4).
2026-05-21 16:26:03 +08:00
Yelban 9596d39e7b feat(baoyu-cover-image): wire SKILL.md to call codex-imagegen wrapper
Per review feedback on #158: a backend wrapper alone is not enough —
skills need explicit invocation guidance so the agent can find and
call it.

Updates skills/baoyu-cover-image/SKILL.md in two places:

1. ## Image Generation Tools — new bullet under the backend resolution
   list: 'Codex via codex exec (codex-imagegen)' with the exact command
   shape (--image / --prompt-file / --aspect / --ref / --cache-dir /
   --log-file), JSON output handling, and prerequisites.

2. ### Step 4: Generate Image, step 5 — added a concrete example
   command for the codex-imagegen branch alongside the existing generic
   instruction.

End-to-end verified: with EXTEND.md set to preferred_image_backend:
codex-imagegen, calling the command shape written in SKILL.md produces
a 1672x941 PNG in 83s (status: ok, attempts: 1, cached: false).
Output: /tmp/cover-e2e/cover.png. Structured log:
/tmp/cover-e2e/run.jsonl.

baoyu-cover-image is the validation skill; if reviewers want the same
guidance in baoyu-article-illustrator / baoyu-comic / baoyu-image-cards
/ baoyu-infographic / baoyu-slide-deck, happy to mirror the change.
2026-05-21 15:23:42 +08:00
Jim Liu 宝玉 156f8627c2 ci: install baoyu-post-to-wechat script deps before running tests
`wechat-socks-http.test.ts` (and `wechat-remote-publish.test.ts`
transitively) import `socks` from `wechat-socks-http.ts`. The CI
`npm ci` only installs root deps, so module resolution failed with
`ERR_MODULE_NOT_FOUND: Cannot find package 'socks'`.

Add a scoped `npm install` step for `skills/baoyu-post-to-wechat/scripts/`
with `--ignore-scripts` to skip postinstalls. Other skills with
private script package.json files don't yet have tests that import
their deps, so leaving them out keeps the install fast.

Co-authored-by: Dame5211 <1079825614@qq.com>
2026-05-21 02:16:31 -05:00
Jim Liu 宝玉 e0b861c148 fix(baoyu-post-to-wechat): make remote-api work under Bun & validate config strictly
The initial remote-api implementation (3b29f3c) relied on
`https.request({ agent: SocksProxyAgent })` to route token/upload/draft
calls through the SSH tunnel. Bun's `https.request` does not honor
Node's `http.Agent` contract, so the agent was silently bypassed and
requests still originated from the local IP — defeating the entire
IP-allowlist purpose. Two follow-on issues compounded it: tests read
the real `~/.baoyu-skills/.env` because Bun's `os.homedir()` ignores
test-time `process.env.HOME` mutations, and invalid config values were
silently coerced to defaults.

P1 — Bun-portable SOCKS routing:
- Drop `socks-proxy-agent` dependency. Add `socks` direct dep.
- New `wechat-socks-http.ts`: raw TCP via `SocksClient.createConnection`
  + `tls.connect({ socket, servername })` + hand-built HTTP/1.1 (status
  line parser, case-insensitive headers, chunked & content-length body
  framing). Works identically under Node and Bun because it avoids
  `http.Agent` entirely.
- Rewrite `wechat-http.ts` as a fetch-based local client and expose
  a `WechatClient = (url, init?) => Promise<WechatHttpResponse>`
  functional abstraction.
- `wechat-api.ts`: replace `agent?: http.Agent` with
  `client: WechatClient = wechatHttp` on the five HTTP-touching
  functions; `withSshTunnel` now yields a `WechatClient`.
- New `wechat-socks-http.test.ts` stands up a real SOCKS5 server
  stub + HTTP echo server and asserts `connectionCount === 1`,
  proving bytes actually traverse the proxy under both runtimes.

P2 — `HOME` honored under Bun:
- `homeDir()` reads `process.env.HOME` / `USERPROFILE` first, falling
  back to `os.homedir()`. `loadWechatExtendConfig` and `loadCredentials`
  use it, restoring test isolation.

P3 — Strict config validation:
- Replace lenient `toOptional*` helpers with `parsePort` /
  `parsePositiveInt` / `parseStrictHostKeyChecking` that throw with
  the key name. `loadWechatExtendConfig` only catches file-read
  errors so parse errors surface to the caller. Flip the corresponding
  test cases.

Verification:
- `npm test`: 261/261 pass.
- `bun test` in `scripts/`: 39/39 pass.

Co-authored-by: Dame5211 <1079825614@qq.com>
2026-05-21 02:10:23 -05:00
Jim Liu 宝玉 3b29f3c57c feat(baoyu-post-to-wechat): add remote-api publishing via SSH SOCKS5 tunnel
Re-implements PR #156 (remote WeChat API publishing for IP-allowlist
constraints) with a fully local code path: spawn a background `ssh -N -D`
SOCKS5 dynamic forward, wrap a `SocksProxyAgent`, and thread it through
the existing token/upload/draft flow. No Python helper, no SCP, no
remote files, no `AppSecret` ever leaving the local process.

Reusing `uploadImagesInHtml` (which replaces the matched `<img>` fullTag
rather than substring-replacing `src`) naturally avoids the original
PR's `html.replace(src, url)` HTML-replacement bug.

Changes:
- New `wechat-remote-publish.ts`: typed-whitelist SSH args, free-port
  allocation, SOCKS readiness polling, signal-handler-backed cleanup.
- New `wechat-http.ts`: minimal `https.request`-backed HTTP helper so a
  `SocksProxyAgent` can be passed through (undici `fetch` ignores agent).
- New `wechat-image-loader.ts`: extracts `loadUploadAsset` for reuse.
- `wechat-api.ts`: thread optional `agent?` through token/upload/draft;
  add `--remote*` CLI flags; dispatch via `withSshTunnel` when remote
  mode is selected by flag or `default_publish_method: remote-api`.
- `wechat-extend-config.ts`: add typed `remote_publish_*` keys with
  account-over-global fallback and value validation.
- Docs: SKILL.md, references/multi-account.md, references/config/
  first-time-setup.md, README.md, README.zh.md.
- Tests: 17 new node:test cases covering config parsing, SSH arg
  whitelisting, free-port allocation, multipart assembly, and HTTP
  agent threading.

Co-authored-by: Dame5211 <1079825614@qq.com>
2026-05-21 00:59:36 -05:00
Jim Liu 宝玉 f8fb457f36 chore: release v1.117.3 2026-05-20 07:58:44 -05:00
Jim Liu 宝玉 174e472a39 docs(baoyu-wechat-summary): restructure profile fields
Split aliases into group_nicknames (user's own prior names) and
aliases (nicknames from other members). Add tags field for
cross-cutting attributes. Sync SKILL.md version.
2026-05-20 07:58:20 -05:00
Jim Liu 宝玉 8b99fa7af0 fix(baoyu-post-to-wechat): sync SKILL.md version to 1.117.3 2026-05-20 07:58:18 -05:00
Jim Liu 宝玉 6699b802c0 fix(baoyu-diagram): add version field to SKILL.md 2026-05-20 07:58:17 -05:00
Jim Liu 宝玉 edcdc19ac5 feat(ci): add skill release commit validation
Add CI check to ensure commits touching skills/<name>/** use
Conventional Commit subjects. Also validates SKILL.md version
alignment during publish/sync.
2026-05-20 07:58:14 -05:00
Yelban e98fa33bc2 feat: add codex-imagegen backend for non-Codex runtimes
Implements the preferred_image_backend: codex-imagegen config key
already referenced in several SKILL.md files but with no corresponding
backend. Bridges non-Codex runtimes (Claude Code, etc.) to Codex CLI's
built-in image_gen tool via 'codex exec'. Uses the user's Codex
subscription — no OPENAI_API_KEY required.

Reliability:
- Retry with exponential backoff (default 2 retries, configurable)
- 9 classified error kinds (retryable vs non-retryable)
- Node child_process timeout with SIGTERM→SIGKILL ladder

Correctness:
- Verifies image_gen was actually invoked (not bypassed) by checking
  $CODEX_HOME/generated_images/{thread_id}/ for a PNG
- PNG magic byte validation
- Output file size sanity check

Observability:
- Structured JSONL logging with --log-file
- Verbose stderr mode with --verbose
- Returns thread_id, tool_calls, token usage in result JSON
- Raw codex event stream preserved per attempt for debugging

Efficiency:
- Idempotency cache via --cache-dir (sha256 of prompt+aspect+refs)
- Cache hit returns in <0.3s vs 50-90s cold

Features:
- --ref reference images (repeatable, passed through to codex exec --image)
- --retries, --retry-delay, --timeout all configurable
- File lock serializes concurrent invocations

Testing:
- 16 Bun unit tests across parser, cache, validator
- Path-filtered CI workflow at .github/workflows/codex-imagegen-tests.yml
- Composes with existing test.yml without conflicts

Architecture:
- scripts/codex-imagegen.sh: thin bash entrypoint (bun → npx -y bun fallback)
- scripts/codex-imagegen/: TypeScript module
  - main.ts (orchestrator), types.ts (CliOptions/GenerateResult/GenError),
    spawn.ts (child_process), parser.ts (JSONL events), validator.ts
    (image_gen check + PNG magic), cache.ts (sha256 cache + FileLock),
    logger.ts (JSONL structured logger)

Trade-offs documented in docs/codex-imagegen-backend.md:
- 5-10x slower than direct OpenAI API (or near-free on cache hit)
- ToS gray area: image_gen is documented for interactive use; non-
  interactive invocation via codex exec is not explicitly addressed.
  Suitable for personal low-volume use; users are responsible for
  ensuring their usage complies with applicable terms of service.
2026-05-20 00:50:24 +08:00
Jim Liu 宝玉 5d2a39c636 chore: release v1.117.2 2026-05-17 21:10:55 -05:00
Jim Liu 宝玉 db58bdee8c docs(skills): ban programmatic text repair on generated bitmaps
Add a text-correction policy to all raster-image skills: title/subtitle/labels/dialogue/etc. inside generated bitmaps must NOT be patched with ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR overlays, or any other programmatic painter. On error, regenerate from a corrected prompt or switch to a lower-text variant.

Synced to: baoyu-cover-image, baoyu-article-illustrator, baoyu-comic, baoyu-image-cards, baoyu-xhs-images, baoyu-infographic, baoyu-slide-deck.
2026-05-17 21:10:50 -05:00
Jim Liu 宝玉 38cc497748 chore: release v1.117.1 2026-05-16 23:09:11 -05:00
Jim Liu 宝玉 0f95d12a09 Merge pull request #155 from JimLiu/codex/wechat-image-upload-fallback-webp
[codex] Fix WeChat image upload fallback and WebP clipboard
2026-05-16 23:07:09 -05:00
Jim Liu 宝玉 7fd9e51fc2 Fix WeChat image upload fallback and WebP clipboard 2026-05-16 22:33:07 -05:00
Jim Liu 宝玉 a5d227b4e8 Merge pull request #154 from zhangga/codex/fix-wechat-browser-article-publish
Fix WeChat browser article publishing reliability
2026-05-16 22:29:28 -05:00
zeqiang.zhang 81377416b4 Fix WeChat browser article publishing 2026-05-16 22:47:41 +08:00
Jim Liu 宝玉 a44bb08360 chore: release v1.117.0 2026-05-16 00:51:34 -05:00
Jim Liu 宝玉 a07669136c feat(baoyu-xhs-images): sync batch generation policy from baoyu-image-cards 2026-05-16 00:50:36 -05:00
Jim Liu 宝玉 b7298a60c6 feat(baoyu-slide-deck): add batch generation policy with configurable batch size 2026-05-16 00:50:33 -05:00
Jim Liu 宝玉 309f078efb feat(baoyu-image-cards): add batch generation policy with configurable batch size 2026-05-16 00:50:30 -05:00
Jim Liu 宝玉 8958ba5409 feat(baoyu-comic): add batch generation policy with configurable batch size 2026-05-16 00:50:28 -05:00
Jim Liu 宝玉 e6612628dc feat(baoyu-article-illustrator): add batch generation policy with configurable batch size 2026-05-16 00:50:25 -05:00
Jim Liu 宝玉 bd5745f837 chore: release v1.116.5 2026-05-14 01:49:35 -05:00
Jim Liu 宝玉 cb26732559 refactor(baoyu-post-to-wechat): harden Telegram QR notification
- Add 10s timeout to Telegram fetch so unreachable api.telegram.org
  doesn't block waitForLogin indefinitely.
- Move 2s QR-render wait inside sendQrToTelegram, after the env-var
  early return, so the no-op path doesn't pay the delay.
- Use viewport screenshot (captureBeyondViewport: false) as fallback
  to reduce payload size and keep the QR scannable.
2026-05-14 01:45:58 -05:00
孙斌锋 9baf570caa feat(baoyu-post-to-wechat): send WeChat login QR code to Telegram (#150)
When `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` env vars are set,
the browser login flow automatically sends the WeChat QR code image
to the configured Telegram chat so headless / remote runs don't need
a screen to scan the code.

Strategy (in priority order):
1. Extract QR img.src from known DOM selectors
2. If URL-based src: re-fetch inside Chrome to carry session cookies
3. Fallback: full-page CDP screenshot

Feature is fully opt-in: skipped silently when env vars are absent.

Co-authored-by: Before SUN <beforesun@BeforedeMac-mini.local>
2026-05-14 01:21:45 -05:00
Jim Liu 宝玉 f99815cec9 chore: release v1.116.4 2026-05-14 01:10:54 -05:00
Jim Liu 宝玉 8f0663d515 refactor(baoyu-wechat-summary): streamline roast version prompts 2026-05-14 01:10:51 -05:00
Jim Liu 宝玉 20ebf6126c chore: release v1.116.3 2026-05-13 22:33:29 -05:00
Jim Liu 宝玉 64db328e61 docs: replace Claude Code references with Agent in READMEs 2026-05-13 22:33:16 -05:00
Jim Liu 宝玉 a3819b8e30 chore: release v1.116.2 2026-05-13 22:30:54 -05:00
Jim Liu 宝玉 234c2a832b docs(baoyu-wechat-summary): update example group name in SKILL.md 2026-05-13 22:30:38 -05:00
Jim Liu 宝玉 7943eb7b05 chore: release v1.116.1 2026-05-13 22:28:34 -05:00
Jim Liu 宝玉 4b4a4d3863 feat(baoyu-wechat-summary): add data_root to first-time setup flow 2026-05-13 22:28:13 -05:00
Jim Liu 宝玉 919849c863 chore: release v1.116.0 2026-05-13 22:18:01 -05:00
Jim Liu 宝玉 1faee80da4 feat(baoyu-wechat-summary): add WeChat group chat summary skill 2026-05-13 22:16:39 -05:00
Jim Liu 宝玉 dea9322d0d chore: release v1.115.4 2026-05-11 18:45:31 -05:00
Jim Liu 宝玉 86b1be83b8 docs(image-generation): emphasize Codex imagegen priority and forbid SVG/HTML substitution
Strengthen the shared image-generation backend selection rule:
- Codex `imagegen` MUST be used when listed in the available-skills inventory;
  invoke via the `Skill` tool with `skill: "imagegen"`.
- Never substitute SVG, HTML, canvas, or other code-based rendering when no
  raster backend can be resolved — fall through and ask the user instead.

Applied to docs/image-generation-tools.md and inlined into baoyu-article-illustrator
(plus its references/workflow.md), baoyu-comic, baoyu-cover-image, baoyu-image-cards,
baoyu-infographic, baoyu-slide-deck, baoyu-xhs-images per the self-containment rule.
2026-05-11 18:45:13 -05:00
Jim Liu 宝玉 a1b935b2d8 chore: release v1.115.3 2026-05-11 16:24:14 -05:00
Jim Liu 宝玉 9968d79a26 fix(baoyu-post-to-x): use toolbar media upload instead of image clipboard paste 2026-05-11 16:23:17 -05:00
Jim Liu 宝玉 6f75fb17e4 chore: add .codex-tmp and outputs to .gitignore 2026-05-11 16:23:09 -05:00
Jim Liu 宝玉 adba24281b Merge pull request #149 from fengxiaodong28/fix-browser-copy-paste
fix(browser): ensure tab activation before copy/paste in WeChat editor
2026-05-10 11:37:40 -05:00
Jim Liu 宝玉 61342ecfea chore: release v1.115.2 2026-05-10 02:49:50 -05:00
Jim Liu 宝玉 5f753dd584 fix(baoyu-post-to-x): respect Chrome plugin mode 2026-05-10 02:49:42 -05:00
Jim Liu 宝玉 076192d58e chore: release v1.115.1 2026-05-09 22:10:54 -05:00
Jim Liu 宝玉 d6d434e714 fix(image): update MiniMax default endpoint 2026-05-09 22:07:26 -05:00
Jim Liu 宝玉 6f3600d8e5 chore: release v1.115.0 2026-05-09 22:05:27 -05:00
Jim Liu 宝玉 045fe5e57e feat(baoyu-post-to-x): add Chrome Computer Use as preferred execution mode
In Codex, prefer the bundled Chrome Computer Use path for all X UI
actions (compose, article, quote, video). CDP scripts become a fallback
when Computer Use is unavailable or explicitly not requested.
2026-05-09 22:04:58 -05:00
FENG/XIAODONG 0b3b7d13b5 fix(browser): ensure tab activation before copy/paste in WeChat editor
When posting articles via browser automation, the HTML preview tab
needs to be active before copying content, and the editor tab needs
to be active before pasting. Without explicit Target.activateTarget
calls, AppleScript Cmd+C/Cmd+V would act on the wrong tab, causing
the editor body to remain empty after paste.

This fix adds Target.activateTarget before both copy and paste
operations, ensuring the correct tab is in focus for system-level
clipboard operations.
2026-05-09 22:13:06 +08:00
Jim Liu 宝玉 aa1a967a9f chore: release v1.114.1 2026-05-08 17:45:03 -05:00
Jim Liu 宝玉 d643bad53c test(baoyu-danger-gemini-web): cover generated image response fallback 2026-05-08 17:43:31 -05:00
Jim Liu 宝玉 0d977787b5 Merge pull request #146 from evilstar2016/fix/gemini-generated-image-detection
fix(gemini-webapi): add fallback scan for generated images
2026-05-08 17:40:45 -05:00
evilstar2016 516803feb4 fix(gemini-webapi): add fallback scan for generated images when wants_generated fails
The `wants_generated` detection checks `candidate[12][7][0]` and an old
`googleusercontent.com/image_generation_content/` URL pattern in the response
text. Both are no longer present in the current Gemini Web API response format,
causing the entire generated-image extraction block to be skipped even when
Gemini successfully generates images — resulting in "No image returned in
response" errors.

Generated image URLs now appear as `https://lh3.googleusercontent.com/gg-dl/`
somewhere in the response parts. This commit adds an unconditional fallback that
scans all response parts for those URLs when `generated_images` is still empty
after the existing `wants_generated` block, reusing the already-present
`collect_strings` helper and `GeneratedImage` constructor.

The existing code path is untouched — the fallback only runs when no images
were found through the original logic, so old response formats continue to work.
2026-05-06 18:48:08 +09:00
Jim Liu 宝玉 4af0506fa3 chore: release v1.114.0 2026-05-05 11:17:55 -05:00
Jim Liu 宝玉 80a1c2970a docs(release-skills): add GitHub release publishing workflow 2026-05-05 11:17:45 -05:00
Jim Liu 宝玉 cdfa0dbff9 feat(baoyu-infographic): add retro popup pop style 2026-05-05 11:17:42 -05:00
Jim Liu 宝玉 505a7e10ce chore: release v1.113.0 2026-04-25 15:03:22 -05:00
Jim Liu 宝玉 6d063734ae feat(baoyu-imagine): add DashScope Wan 2.7 image model support (#141)
* feat(baoyu-imagine): add DashScope Wan 2.7 image model support

Closes #139.

Adds the new `wan2.7-image-pro` and `wan2.7-image` model family to the
DashScope provider so users can call Wan 2.7 directly through the
official Aliyun (Bailian) API instead of going through Replicate.

- Register `wan2.7-image-pro` and `wan2.7-image` as a new `wan27` family
  in the DashScope provider with their own size resolution rules:
  pixel range `[768*768, 4096*4096]` for `wan2.7-image-pro` text-to-image,
  `[768*768, 2048*2048]` for `wan2.7-image-pro` with refs and for the
  base `wan2.7-image` model in any mode, with aspect ratios validated
  against the documented `[1:8, 8:1]` band.
- Allow up to 9 reference images per request (image editing /
  multi-image fusion). Local files are inlined as base64 data URLs;
  `http(s)://` paths are forwarded as-is. Other DashScope models still
  reject `--ref` with a hint to switch to a wan2.7 model or another
  provider.
- Drop `prompt_extend` from the request body for the Wan 2.7 family
  (not part of the Wan 2.7 API surface) and skip the Qwen-only negative
  prompt for this family.
- Allow `--provider dashscope --ref ...` in `detectProvider` so users
  can opt into Wan 2.7 reference workflows, while keeping Wan 2.7 out
  of the auto-detect ref priority list.
- Add provider, reference, and usage-example documentation, plus
  unit tests covering family routing, size derivation across the
  three pixel-budget modes, ratio rejection, explicit-size validation,
  and the new `--provider dashscope` ref opt-in path.

Made-with: Cursor

* fix(baoyu-imagine): force n=1 for DashScope wan2.7 to avoid silent multi-image billing

Cross-checked the implementation against the official Wan 2.7 image
generation & editing API reference and found that the API defaults
`parameters.n` to 4 in non-collage mode (1-4 range, billed per image).
baoyu-imagine has single-image save semantics — only the first image
in the response is kept — so without an explicit `n: 1` users would
silently pay for 3 discarded images per request.

- Always send `parameters.n: 1` in the wan2.7 request body
- Reject `--n > 1` for wan2.7 with a clear error pointing at the
  single-image save semantics
- Add tests asserting the request body shape (n=1, no prompt_extend,
  no negative_prompt) and the --n>1 rejection
- Document the defaults-vs-skill mismatch in the dashscope reference

Made-with: Cursor

* Fix DashScope Wan 2.7 review feedback
2026-04-25 14:54:08 -05:00
Jim Liu 宝玉 31d728b505 chore: release v1.112.0 2026-04-24 02:15:57 -05:00
Jim Liu 宝玉 f6d5df0594 fix(baoyu-post-to-x): add entry point guard to md-to-html.ts for module import compatibility
Wrap main() in an import.meta.url check so that importing parseMarkdown
from x-article.ts no longer triggers the CLI entry point. Mirrors the
same fix applied to baoyu-post-to-weibo.
2026-04-24 02:15:30 -05:00
Jim Liu 宝玉 4bd5fe573e feat(baoyu-article-illustrator): default to sketch-notes educational infographic style
Make `hand-drawn-edu` (infographic + sketch-notes + macaron) the universal
fallback preset when content analysis surfaces no strong signal. Rework
sketch-notes style spec around warm cream paper + black hand-drawn lines +
soft pastel section blocks.

- Change `hand-drawn-edu` preset type from flowchart to infographic; add
  `hand-drawn-edu-flow` (flowchart) and `hand-drawn-edu-compare` (comparison)
  as variants for users who need those layouts in the same warm style
- Elevate `sketch-notes` to primary style across infographic / flowchart /
  comparison / framework auto-selection; add sketch-notes column to
  Type x Style compatibility matrix
- Rewrite sketch-notes.md: macaron pastel palette, canonical single-page
  layout (title / sectioned boxes / takeaway), diagram-only rule
- Add infographic + sketch-notes + macaron prompt block to
  prompt-construction.md
- Update workflow, style-presets, and first-time-setup defaults to match
2026-04-24 02:15:25 -05:00
Jim Liu 宝玉 8c17d77209 chore: release v1.111.1 2026-04-21 16:59:35 -05:00
Jim Liu 宝玉 fb749aae36 docs(baoyu-article-illustrator): add Confirmation Policy
State that defaults / signals / EXTEND.md preferences are recommendation
inputs only — Step 3 is mandatory unless the current request opts out
explicitly.
2026-04-21 16:59:11 -05:00
Jim Liu 宝玉 a23f2e6a4b docs(baoyu-xhs-images): add Confirmation Policy
Sync Confirmation Policy with baoyu-image-cards (deprecated skill kept
functional per CLAUDE.md).
2026-04-21 16:59:11 -05:00
Jim Liu 宝玉 ae32c56a3c docs(baoyu-image-cards): add Confirmation Policy
State that defaults / signals / EXTEND.md preferences are recommendation
inputs only — Step 2 is mandatory unless the current request opts out via
`--yes` / equivalent wording.
2026-04-21 16:59:07 -05:00
Jim Liu 宝玉 4b2ad3ad18 docs(baoyu-slide-deck): add Confirmation Policy
State that defaults / signals / EXTEND.md preferences are recommendation
inputs only — Step 2 is mandatory unless the current request opts out
explicitly.
2026-04-21 16:59:07 -05:00
Jim Liu 宝玉 512b6d2e15 docs(baoyu-cover-image): add Confirmation Policy
State that defaults / keywords / EXTEND.md preferences are recommendation
inputs only — Step 2 is mandatory unless the current request opts out via
`--quick` / `quick_mode: true` / equivalent wording.
2026-04-21 16:59:03 -05:00
Jim Liu 宝玉 60d0b9c95b docs(baoyu-infographic): add Confirmation Policy as single source of truth
Consolidate the confirmation rule into one section so defaults, keywords,
and EXTEND.md preferences are treated as recommendation inputs only — never
as authorization to skip Step 4. Remove the repeated reminders that were
scattered across Step 5, Step 6, Default combination, Keyword Shortcuts,
and the preferences docs.
2026-04-21 16:58:58 -05:00
Jim Liu 宝玉 acf1d42a64 chore: release v1.111.0 2026-04-21 16:44:01 -05:00
Jim Liu 宝玉 adb783ae6d refactor: unify image-backend resolution with preferred_image_backend preference
Replaces the stateless ask-once backend rule with a 4-step resolution
(current-request override > saved preference > auto-select > ask) and
adds a single `preferred_image_backend` field (auto | ask | <backend-id>)
to every image-consuming skill's EXTEND.md schema. Runtime-native tools
(Codex `imagegen`, Hermes `image_generate`, ...) win by default; absent
field equals `auto` so existing user EXTEND.md files stay valid with no
schema version bump.

Each image-consuming skill also gains a top-level `## Changing
Preferences` section as a first-class surface for pinning a backend and
editing common one-line preferences.

Applies across:
  - baoyu-infographic (reverts in-progress two-field image_backend_mode design)
  - baoyu-comic
  - baoyu-cover-image
  - baoyu-image-cards
  - baoyu-article-illustrator
  - baoyu-slide-deck
  - baoyu-xhs-images
  - docs/image-generation-tools.md (canonical author-side doc)
2026-04-21 16:43:58 -05:00
Jim Liu 宝玉 a4bd37c390 chore: release v1.110.0 2026-04-21 15:15:19 -05:00
Jim Liu 宝玉 f8e434d2a0 chore: release v1.109.0 2026-04-21 13:39:42 -05:00
Jim Liu 宝玉 bb66585c00 fix(project): include vendored skill files in releases 2026-04-21 13:39:13 -05:00
Jim Liu 宝玉 d6442e1ab4 feat(baoyu-url-to-markdown): vendor baoyu-fetch runtime 2026-04-21 13:39:09 -05:00
Jim Liu 宝玉 2f09f48726 chore(deps): upgrade Defuddle extraction stack 2026-04-21 13:39:05 -05:00
Jim Liu 宝玉 fe26c2b639 fix(baoyu-fetch): extract X video variants 2026-04-21 13:39:01 -05:00
Jim Liu 宝玉 d7f623158c chore: release v1.108.0 2026-04-19 00:51:23 -05:00
Jim Liu 宝玉 2c800c670a [codex] Refactor skills into focused references (#135)
* docs: add runtime-neutral User Input Tools convention across skills

Introduce docs/user-input-tools.md as the author-side canonical source
and inline the tool-selection rule into every SKILL.md that prompts
the user. Also add Skill Self-Containment and User Input Tools sections
to CLAUDE.md and the copy-verbatim template to docs/creating-skills.md,
so skills stay portable across Claude Code, Codex, Hermes, and other
runtimes.

* feat: runtime-neutral image generation convention across skills

- Introduce inline `## Image Generation Tools` rule in every rendering SKILL.md so skills delegate backend choice instead of hard-coding one; author-side canonical copy lives in docs/image-generation-tools.md.
- Add `## Reference Images` support (`--ref`, frontmatter `references:` with direct/style/palette usage) to all seven image-rendering skills.
- Move build-batch.ts (with ref propagation into batch JSON) from baoyu-article-illustrator to baoyu-imagine so non-backend skills don't own backend-specific scripts; update baoyu-image-gen stub in sync and relax the CLAUDE.md deprecation note accordingly.

* refactor: slim heavy SKILL.md files and move detail to references/

Trim the four largest active skills and move presets, option tables, and
confirmation scripts into per-skill references/ so SKILL.md stays focused
on the decision flow.

- baoyu-slide-deck: 761→258, + styles-gallery.md, confirmation.md
- baoyu-image-cards: 657→280, + gallery.md, confirmation.md
- baoyu-post-to-wechat: 518→267, + multi-account.md, api-setup.md
- baoyu-imagine: 500→230, + providers/, usage-examples.md

Also un-deprecate baoyu-image-gen (drop stub warning) so it stays functional
alongside baoyu-imagine, and update CLAUDE.md to reflect that both
superseded skills are kept in sync rather than stubbed.

* refactor: slim four medium SKILL.md files into references/

Continue the P2 pattern on the next tier of skills — move option catalogs,
per-provider/adapter detail, and repeated EXTEND.md path boilerplate into
their own references so SKILL.md stays focused on the decision flow.

- baoyu-comic: 380→297 (art/tone/preset tables → auto-selection.md;
  Step 7 expanded detail → workflow.md)
- baoyu-infographic: 312→207 (layouts/styles/combinations/keywords →
  gallery.md; ASCII box tables → markdown tables)
- baoyu-format-markdown: 376→296 (title + summary generation →
  title-summary.md; ASCII box tables → markdown tables)
- baoyu-url-to-markdown: 334→169 (quality gate + recovery →
  quality-gate.md; adapters + media download → adapters.md)

* chore: sync deprecated skills with their replacements

Per project policy, baoyu-xhs-images and baoyu-image-gen are kept
functional alongside the active skills they were superseded by. Sync
their SKILL.md bodies and references/ to the slimmed baoyu-image-cards
and baoyu-imagine versions respectively, so cross-cutting fixes stay
consistent. Only the frontmatter (name, description, version, homepage)
differs — content is identical.

- baoyu-xhs-images: 657→281 (synced with baoyu-image-cards + new
  confirmation.md, gallery.md)
- baoyu-image-gen: 408→231 (synced with baoyu-imagine + new
  providers/, usage-examples.md)

* refactor: collapse EXTEND.md boilerplate into priority tables

Replace the dual bash/powershell existence-check blocks and ASCII box
art with a single markdown priority table across nine SKILL.md files.
The runtime-neutral phrasing removes shell-specific snippets without
losing the priority semantics.

* fix: address refactor-skills branch review findings

- image-gen: restore EXTEND.md paths to baoyu-image-gen (were pointing at
  baoyu-imagine) and mark descriptions of both deprecated skills as
  [Deprecated].
- xhs-images: sync neon/warm palettes with image-cards to add the
  "do not render color names/hex as visible text" safety sentence.
- infographic: restore Layout Gallery (21), Style Gallery (21),
  Recommended Combinations, and Keyword Shortcuts inline (previous
  refactor split them out but SKILL.md still depended on them), and add
  the missing references/config/first-time-setup.md + preferences-schema.md.
- image-cards / xhs-images / slide-deck / format-markdown: restore the
  sections that got over-slimmed into references/ (galleries, presets,
  dimensions, auto-selection, style x layout matrix, title/summary flow)
  and drop the now-empty shell files.
- docs/image-generation-tools.md: note that backend skills themselves
  (baoyu-imagine, baoyu-image-gen, baoyu-danger-gemini-web) are exempt
  from the ## Image Generation Tools section requirement.

* feat(image-gen): sync Z.AI GLM-Image provider from baoyu-imagine

Add Z.AI as a full provider in the deprecated baoyu-image-gen skill so
it stays in sync with baoyu-imagine's provider list.

- new scripts/providers/zai.ts + zai.test.ts (verbatim port; test
  factory trimmed to match image-gen's CliArgs shape).
- types.ts: "zai" added to Provider union and default_model.
- main.ts: rate-limit defaults, provider help text, env var help,
  --provider validation, loadProviderModule, detectProvider
  auto-detect chain, getModelForProvider, YAML parser allow-lists.
- references/config: Q2e Z.AI model question + zai slot in the
  preferences schema and batch.provider_limits.

Scope is intentionally limited to the Z.AI chain; unrelated drift
between image-gen and imagine (OpenAI image-API dialect,
aspectRatioSource, imageSizeSource) is left alone.

* docs: align inline-convention wording and note backend-skill exemption

- docs/user-input-tools.md: fix stale "links here" wording so it
  matches the inline convention already enforced everywhere else.
- CLAUDE.md §Image Generation Tools: inline the backend-skill
  exemption so readers don't need to cross-reference
  docs/image-generation-tools.md.
2026-04-19 00:48:44 -05:00
Jim Liu 宝玉 5b20f9a746 Use npm packages for shared skill code (#136) 2026-04-18 21:09:58 -05:00
645 changed files with 171927 additions and 31347 deletions
+5 -3
View File
@@ -6,7 +6,7 @@
}, },
"metadata": { "metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency", "description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.107.0" "version": "2.5.2"
}, },
"plugins": [ "plugins": [
{ {
@@ -22,8 +22,9 @@
"./skills/baoyu-danger-gemini-web", "./skills/baoyu-danger-gemini-web",
"./skills/baoyu-danger-x-to-markdown", "./skills/baoyu-danger-x-to-markdown",
"./skills/baoyu-diagram", "./skills/baoyu-diagram",
"./skills/baoyu-electron-extract",
"./skills/baoyu-format-markdown", "./skills/baoyu-format-markdown",
"./skills/baoyu-imagine", "./skills/baoyu-image-gen",
"./skills/baoyu-infographic", "./skills/baoyu-infographic",
"./skills/baoyu-markdown-to-html", "./skills/baoyu-markdown-to-html",
"./skills/baoyu-post-to-weibo", "./skills/baoyu-post-to-weibo",
@@ -32,7 +33,8 @@
"./skills/baoyu-slide-deck", "./skills/baoyu-slide-deck",
"./skills/baoyu-translate", "./skills/baoyu-translate",
"./skills/baoyu-url-to-markdown", "./skills/baoyu-url-to-markdown",
"./skills/baoyu-image-cards", "./skills/baoyu-wechat-summary",
"./skills/baoyu-xhs-images",
"./skills/baoyu-youtube-transcript" "./skills/baoyu-youtube-transcript"
] ]
} }
+84 -7
View File
@@ -1,12 +1,22 @@
--- ---
name: release-skills name: release-skills
description: Universal release workflow. Auto-detects version files and changelogs. Supports Node.js, Python, Rust, Claude Plugin, and generic projects. Use when user says "release", "发布", "new version", "bump version", "push", "推送". description: Universal release workflow. Auto-detects version files and changelogs. Supports Node.js, Python, Rust, Claude Plugin, GitHub Releases, annotated tags, historical release backfill, and generic projects. Use when user says "release", "发布", "new version", "bump version", "push", "推送", "release notes", "GitHub Release", or "回填 Release".
--- ---
# Release Skills # Release Skills
Universal release workflow supporting any project type with multi-language changelog. Universal release workflow supporting any project type with multi-language changelog.
## User Input Tools
When this skill prompts the user, follow this tool-selection rule (priority order):
1. **Prefer built-in user-input tools** exposed by the current agent runtime — e.g., `AskUserQuestion`, `request_user_input`, `clarify`, `ask_user`, or any equivalent.
2. **Fallback**: if no such tool exists, emit a numbered plain-text message and ask the user to reply with the chosen number/answer for each question.
3. **Batching**: if the tool supports multiple questions per call, combine all applicable questions into a single call; if only single-question, ask them one at a time in priority order.
Concrete `AskUserQuestion` references below are examples — substitute the local equivalent in other runtimes.
## Quick Start ## Quick Start
Just run `/release-skills` - auto-detects your project configuration. Just run `/release-skills` - auto-detects your project configuration.
@@ -29,6 +39,7 @@ Just run `/release-skills` - auto-detects your project configuration.
| `--major` | Force major version bump | | `--major` | Force major version bump |
| `--minor` | Force minor version bump | | `--minor` | Force minor version bump |
| `--patch` | Force patch version bump | | `--patch` | Force patch version bump |
| `--backfill-releases` | Create missing GitHub Releases for existing tags from changelog sections |
## Workflow ## Workflow
@@ -47,7 +58,11 @@ Just run `/release-skills` - auto-detects your project configuration.
- `HISTORY*.md` - `HISTORY*.md`
- `CHANGES*.md` - `CHANGES*.md`
4. Identify language of each changelog by filename suffix 4. Identify language of each changelog by filename suffix
5. Display detected configuration 5. Detect GitHub release support:
- Check whether `origin` points to GitHub
- Check whether `gh` is installed and authenticated
- Check existing releases with `gh release list --limit 5` when available
6. Display detected configuration
**Project Hook Contract**: **Project Hook Contract**:
@@ -300,6 +315,14 @@ git commit -m "docs(project): update architecture documentation"
- Read version file (JSON/TOML/text) - Read version file (JSON/TOML/text)
- Update version number - Update version number
- Write back (preserve formatting) - Write back (preserve formatting)
3. **Create release notes file**:
- Prefer the new version section from `CHANGELOG.md`
- If no English/default changelog exists, use the first detected changelog
- Extract only the exact `## {VERSION} - {YYYY-MM-DD}` section through the next `##`
- Match both plain version and tag-prefixed headings when needed, e.g. `1.2.3` and `v1.2.3`
- Keep breaking changes near the top; if needed, add a short highlight before other sections
- Write notes to a UTF-8 temp file and reuse it for annotated tag messages, GitHub Releases, and `publish_artifact`
- In normal mode, stop rather than creating an empty tag or GitHub Release when notes cannot be found
**Version Paths by File Type**: **Version Paths by File Type**:
@@ -315,7 +338,7 @@ git commit -m "docs(project): update architecture documentation"
Before creating the release commit, ask user to confirm: Before creating the release commit, ask user to confirm:
**Use AskUserQuestion with two questions**: **Use AskUserQuestion with three questions**:
1. **Version bump** (single select): 1. **Version bump** (single select):
- Show recommended version based on Step 3 analysis - Show recommended version based on Step 3 analysis
@@ -325,6 +348,11 @@ Before creating the release commit, ask user to confirm:
2. **Push to remote** (single select): 2. **Push to remote** (single select):
- Options: "Yes, push after commit", "No, keep local only" - Options: "Yes, push after commit", "No, keep local only"
3. **Publish GitHub Release** (single select):
- Offer this only when GitHub release support is available
- Default to "Yes, publish after tag push" when the user also chose push
- If the user keeps the release local, do not create or edit a GitHub Release
**Example Output Before Confirmation**: **Example Output Before Confirmation**:
``` ```
Commits created: Commits created:
@@ -339,10 +367,11 @@ Changelog preview (en):
### Fixes ### Fixes
- Improve panel layout for long dialogues in comic - Improve panel layout for long dialogues in comic
Ready to create release commit and tag. Release notes source: CHANGELOG.md#1.3.0
Ready to create release commit, annotated tag, and GitHub Release.
``` ```
### Step 9: Create Release Commit and Tag ### Step 9: Create Release Commit and Annotated Tag
After user confirmation: After user confirmation:
@@ -357,10 +386,11 @@ After user confirmation:
git commit -m "chore: release v{VERSION}" git commit -m "chore: release v{VERSION}"
``` ```
3. **Create tag**: 3. **Create annotated tag**:
```bash ```bash
git tag v{VERSION} git tag -a v{VERSION} -F <release-notes-file>
``` ```
If `.releaserc.yml` sets `tag.sign: true`, use `git tag -s` with the same notes file.
4. **Push if user confirmed** (Step 8): 4. **Push if user confirmed** (Step 8):
```bash ```bash
@@ -370,6 +400,28 @@ After user confirmation:
**Note**: Do NOT add Co-Authored-By line. This is a release commit, not a code contribution. **Note**: Do NOT add Co-Authored-By line. This is a release commit, not a code contribution.
### Step 10: Publish Release Artifacts and GitHub Release
Project artifact publishing and GitHub Releases are separate outputs:
1. **Project artifacts**:
- If `release.hooks.publish_artifact` exists, run it once per prepared target
- Pass the same `{release_notes_file}` used for the tag and GitHub Release
- In dry-run mode, pass `{dry_run}=true` and report what would be published
2. **GitHub Release**:
- Run only if the user confirmed remote publishing and GitHub support is available
- Ensure the tag exists on the remote before creating the release
- Create or update using the extracted notes:
```bash
if gh release view v{VERSION} >/dev/null 2>&1; then
gh release edit v{VERSION} --title "v{VERSION}" --notes-file <release-notes-file>
else
gh release create v{VERSION} --title "v{VERSION}" --notes-file <release-notes-file> --verify-tag
fi
```
- Never inline multiline release notes into shell commands
**Post-Release Output**: **Post-Release Output**:
``` ```
Release v1.3.0 created. Release v1.3.0 created.
@@ -381,9 +433,32 @@ Commits:
4. chore: release v1.3.0 4. chore: release v1.3.0
Tag: v1.3.0 Tag: v1.3.0
Tag type: annotated
GitHub Release: published # or "skipped/local only"
Status: Pushed to origin # or "Local only - run git push when ready" Status: Pushed to origin # or "Local only - run git push when ready"
``` ```
## Backfill Existing GitHub Releases
Use this mode when the user asks to backfill historical releases or passes `--backfill-releases`.
1. Do not bump versions, edit changelogs, or create release commits.
2. List existing tags in version order and detect missing releases:
```bash
git tag --sort=v:refname
gh release view <tag>
```
3. For each tag without a GitHub Release:
- Normalize the changelog lookup by stripping the configured tag prefix, e.g. `v1.2.3` -> `1.2.3`
- Extract the matching section from `CHANGELOG.md`; fall back to the first matching changelog file
- Skip or ask before publishing if no matching changelog section exists
- Create the release with:
```bash
gh release create <tag> --title "<tag>" --notes-file <release-notes-file> --verify-tag
```
4. Detect lightweight tags with `git cat-file -t <tag>` (`commit` means lightweight, `tag` means annotated).
5. Do not rewrite public lightweight tags by default. Converting an existing remote tag to an annotated tag requires explicit user confirmation because it rewrites a published reference.
## Configuration (.releaserc.yml) ## Configuration (.releaserc.yml)
Optional config file in project root to override defaults: Optional config file in project root to override defaults:
@@ -488,6 +563,7 @@ No changes made. Run without --dry-run to execute.
/release-skills --minor # Force minor bump /release-skills --minor # Force minor bump
/release-skills --patch # Force patch bump /release-skills --patch # Force patch bump
/release-skills --major # Force major bump (with confirmation) /release-skills --major # Force major bump (with confirmation)
/release-skills --backfill-releases # Create missing GitHub Releases for existing tags
``` ```
## When to Use ## When to Use
@@ -496,6 +572,7 @@ Trigger this skill when user requests:
- "release", "发布", "create release", "new version", "新版本" - "release", "发布", "create release", "new version", "新版本"
- "bump version", "update version", "更新版本" - "bump version", "update version", "更新版本"
- "prepare release" - "prepare release"
- "release notes", "GitHub Release", "回填 Release"
- "push to remote" (with uncommitted changes) - "push to remote" (with uncommitted changes)
**Important**: If user says "just push" or "直接 push" with uncommitted changes, STILL follow all steps above first. **Important**: If user says "just push" or "直接 push" with uncommitted changes, STILL follow all steps above first.
+1 -1
View File
@@ -4,4 +4,4 @@ set -eu
REPO_ROOT=$(git rev-parse --show-toplevel) REPO_ROOT=$(git rev-parse --show-toplevel)
cd "$REPO_ROOT" cd "$REPO_ROOT"
node scripts/sync-shared-skill-packages.mjs --repo-root "$REPO_ROOT" --enforce-clean node scripts/verify-shared-package-deps.mjs --repo-root "$REPO_ROOT"
@@ -0,0 +1,38 @@
name: codex-imagegen tests
on:
push:
paths:
- 'packages/baoyu-codex-imagegen/**'
- '.github/workflows/codex-imagegen-tests.yml'
pull_request:
paths:
- 'packages/baoyu-codex-imagegen/**'
- '.github/workflows/codex-imagegen-tests.yml'
workflow_dispatch:
jobs:
unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Show Bun version
run: bun --version
- name: Run unit tests
working-directory: packages/baoyu-codex-imagegen
run: bun test
- name: Bundle smoke test (catches import/syntax errors)
run: bun build --target=node packages/baoyu-codex-imagegen/src/main.ts --outfile /tmp/main-build.js
- name: Help output smoke test
run: bun packages/baoyu-codex-imagegen/src/main.ts --help
+10
View File
@@ -11,6 +11,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -18,8 +20,16 @@ jobs:
node-version: 22 node-version: 22
cache: npm cache: npm
- name: Verify skill release commits
run: npm run verify:skill-release-commits
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Install baoyu-post-to-wechat script dependencies
run: |
cd skills/baoyu-post-to-wechat/scripts
npm install --no-audit --no-fund --ignore-scripts
- name: Run tests - name: Run tests
run: npm test run: npm test
+7
View File
@@ -83,6 +83,10 @@ out
.nuxt .nuxt
dist dist
# Shared package builds are published to npm.
!packages/*/dist/
!packages/*/dist/**
# Gatsby files # Gatsby files
.cache/ .cache/
# Comment in the public line in if your project uses Gatsby and not Next.js # Comment in the public line in if your project uses Gatsby and not Next.js
@@ -169,3 +173,6 @@ diagram/
.worktrees/ .worktrees/
youtube-transcript/ youtube-transcript/
.omx/ .omx/
.codex-tmp/
outputs/
wechat/
+1 -1
View File
@@ -2,5 +2,5 @@ release:
target_globs: target_globs:
- skills/* - skills/*
hooks: hooks:
prepare_artifact: node scripts/sync-shared-skill-packages.mjs --repo-root "{project_root}" --target "{target}" prepare_artifact: node scripts/verify-shared-package-deps.mjs --repo-root "{project_root}" --target "{target}"
publish_artifact: node scripts/publish-skill.mjs --skill-dir "{target}" --version "{version}" --changelog-file "{release_notes_file}" --dry-run "{dry_run}" publish_artifact: node scripts/publish-skill.mjs --skill-dir "{target}" --version "{version}" --changelog-file "{release_notes_file}" --dry-run "{dry_run}"
+294 -1
View File
@@ -2,6 +2,299 @@
English | [中文](./CHANGELOG.zh.md) 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
- `baoyu-markdown-to-html`: render fenced ` ```mermaid ` code blocks to local PNG via shared Chrome (CDP) before the standard image-placeholder pipeline runs. New CLI flags: `--mermaid-theme <default|forest|dark|neutral|base>`, `--mermaid-scale <N>` (default `2` for @2x resolution), `--mermaid-bg <white|transparent|#hex>`, and `--no-mermaid` to disable rendering. Generated images land in `imgs/.mermaid-cache/mermaid-<hash>.png` and are deduplicated/reused across runs by a 12-char SHA-256 over `(code, theme, scale, background, mermaid version)`. The browser-side `<pre class="mermaid">` path is retained as a graceful fallback when Chrome is unavailable or a single block fails to render
- `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-post-to-x`: cascade the same Mermaid → PNG preprocessing into the WeChat / Weibo / X publishing pipelines so diagrams appear as real images in the published posts (previously they fell through as unrendered `<pre>` blocks). Existing `WECHATIMGPH_*` / `WBIMGPH_*` / `XIMGPH_*` placeholder pipelines pick up the generated PNGs unchanged
- `baoyu-md` package: new exports `preprocessMermaidInMarkdown`, `extractMermaidBlocks`, `replaceMermaidBlocks`, `hashMermaidCode`, and `MERMAID_VERSION` (skills inject the render function so the package stays Chrome-free)
- `baoyu-chrome-cdp` package: new `./mermaid` subexport providing `renderMermaidToPng(code, outputPath, options)` backed by a process-singleton CDP connection that reuses the shared Chrome profile and ships the vendored Mermaid 10.9.1 UMD bundle as an asset
## 2.0.1 - 2026-05-24
### Fixes
- `baoyu-post-to-wechat`: convert `newspic` (image-text) `content` from HTML to plain text before sending to the WeChat draft API. The previous behavior passed raw HTML, triggering error 45166 (invalid content) when multiple images pushed the body over WeChat's length limit. The new `htmlToPlainText` helper turns `<br>` and block-closing tags into line breaks, strips remaining tags, decodes named/numeric (decimal and hex) entities, and collapses whitespace (by @Go1dFinger, [#164](https://github.com/JimLiu/baoyu-skills/pull/164), closes [#163](https://github.com/JimLiu/baoyu-skills/issues/163))
### Credits
- `baoyu-post-to-wechat` newspic plain-text fix contributed by @Go1dFinger ([#164](https://github.com/JimLiu/baoyu-skills/pull/164))
## 2.0.0 - 2026-05-24
### Breaking
- Removed `baoyu-imagine` skill. All functionality (providers, scripts, references) now lives under `baoyu-image-gen`. The skill is registered in `marketplace.json` under the new name and its `homepage` URL has changed to `#baoyu-image-gen`.
- Removed `baoyu-image-cards` skill. All functionality (styles, layouts, palettes, presets) now lives under `baoyu-xhs-images`. The skill is registered in `marketplace.json` under the new name.
- Cross-skill `## Image Generation Tools` examples in `baoyu-article-illustrator`, `baoyu-comic`, `baoyu-cover-image`, `baoyu-infographic`, and `baoyu-slide-deck` now reference `baoyu-image-gen` instead of `baoyu-imagine`.
### Migration
- Existing `~/.baoyu-skills/baoyu-imagine/EXTEND.md` and `.baoyu-skills/baoyu-imagine/EXTEND.md` configs are auto-renamed to `…/baoyu-image-gen/EXTEND.md` on first run via the legacy-path resolver in `scripts/main.ts`.
- Users invoking the skill via slash command should switch from `/baoyu-imagine ...` to `/baoyu-image-gen ...` and from any `baoyu-image-cards` reference to `baoyu-xhs-images`.
## 1.119.0 - 2026-05-24
### Features
- `baoyu-electron-extract`: new skill that extracts resources and JavaScript from any installed Electron app's `app.asar`. Restores original sources from embedded `sourcesContent` in `.js.map` files when present (TypeScript/JSX included) or formats minified JS/CSS with Prettier in place when not. Source-map paths are resolved relative to each `.js.map` file first, so bundler-relative entries like `../../src/main.ts` restore to readable paths under `restored/` instead of hashed placeholders. Always skips `node_modules` and `webpack/runtime/*` entries. Auto-discovers apps on macOS (`/Applications`, `~/Applications`) and Windows (`%LOCALAPPDATA%\Programs`, `%PROGRAMFILES%`, `%PROGRAMFILES(X86)%`, `%APPDATA%`); other platforms can pass `--asar <path>` explicitly. Safety: refuses to write to `/`, the user home, or the current working directory, and refuses non-empty existing output dirs without `--force`
## 1.118.0 - 2026-05-21
### Features
- `codex-imagegen`: new image-generation backend for non-Codex runtimes (e.g., Claude Code) — spawns `codex exec --json --sandbox danger-full-access` and delegates to Codex CLI's built-in `image_gen` tool, so no `OPENAI_API_KEY` is required. Ships with idempotency cache, file-lock concurrency control, JSONL event-stream parsing, PNG magic-byte validation, and exponential-backoff retries (by @yelban, #158)
- `baoyu-cover-image`: wire `SKILL.md` to call the `codex-imagegen` wrapper when `preferred_image_backend: codex-imagegen` is set, with `--timeout` documented for slow networks
### Refactor
- `codex-imagegen`: enforce `--prompt` / `--prompt-file` mutual exclusion in code (was docs-only)
- `codex-imagegen`: replace `(opts as any).__promptFile` hack with a typed `promptFile` field on `CliOptions`
- `codex-imagegen`: replace inline `cp|mv ... generated_images` regex with the shared `findCpToTarget` helper
- `codex-imagegen`: propagate `attempts` on error responses (previously hardcoded to `0`)
- `codex-imagegen`: drop dead `parseFinalJson()` + matching test (wrapper ignores agent-reported JSON in favor of disk verification)
### Security
- `codex-imagegen`: reject `--image` / `--ref` paths containing shell metacharacters before interpolating them into the agent instruction sent to `codex exec --sandbox danger-full-access`
### Credits
- `codex-imagegen` backend contributed by @yelban (#158)
## 1.117.5 - 2026-05-21
### Credits
- `baoyu-post-to-wechat`: remote API publishing update credited to Dame5211 <1079825614@qq.com>
## 1.117.4 - 2026-05-21
### Features
- `baoyu-post-to-wechat`: add remote API publishing via an SSH SOCKS5 tunnel
### Fixes
- `baoyu-post-to-wechat`: make remote API publishing work under Bun and strictly validate remote publish config
### CI
- Install `baoyu-post-to-wechat` script dependencies before running tests
## 1.117.3 - 2026-05-20
### Features
- CI: add skill release commit validation — commits touching `skills/<name>/**` must use Conventional Commit subjects; SKILL.md version validated during publish/sync
### Fixes
- `baoyu-diagram`: add version field to SKILL.md
- `baoyu-post-to-wechat`: sync SKILL.md version
### Documentation
- `baoyu-wechat-summary`: restructure profile fields — split `aliases` into `group_nicknames` (user's own prior names) and `aliases` (nicknames from other members), add `tags` for cross-cutting attributes
## 1.117.2 - 2026-05-17
### Documentation
- `baoyu-cover-image`: ban programmatic text repair on generated bitmaps — disallow ImageMagick / Pillow / Canvas / SVG / HTML overlays to cover, rewrite, or replace title/subtitle text; regenerate from a corrected prompt or switch to a lower-text or no-title variant instead
- `baoyu-article-illustrator`, `baoyu-comic`, `baoyu-image-cards`, `baoyu-xhs-images`, `baoyu-infographic`, `baoyu-slide-deck`: sync the same text-repair ban with skill-specific text categories (labels/captions, dialogue/sound effects, titles/body/tags, headings/data values, slide titles/bullets)
## 1.117.1 - 2026-05-16
### Fixes
- `baoyu-post-to-wechat`: fix WeChat browser article publishing (by @zhangga)
- `baoyu-post-to-wechat`: fix image upload fallback and WebP clipboard copy on macOS
## 1.117.0 - 2026-05-16
### Features
- `baoyu-article-illustrator`: add batch generation policy — backend native batch first, runtime parallel calls second, sequential fallback; configurable `generation_batch_size` and `--batch-size` option
- `baoyu-comic`: add batch generation policy with dependency-aware ordering (character sheet before pages) and configurable `--batch-size`
- `baoyu-image-cards`: add batch generation policy honoring image-1 anchor chain, with configurable `--batch-size`
- `baoyu-slide-deck`: add batch generation policy for slide image rendering with configurable `--batch-size`
- `baoyu-xhs-images`: sync batch generation policy from baoyu-image-cards
## 1.116.5 - 2026-05-14
### Features
- `baoyu-post-to-wechat`: send WeChat login QR code to Telegram when `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` env vars are set, enabling headless / remote login flows (by @beforesun)
### Refactor
- `baoyu-post-to-wechat`: harden Telegram QR notification — add 10s fetch timeout, defer the 2s QR-render wait until env vars are configured, and use viewport screenshot as fallback
## 1.116.4 - 2026-05-14
### Refactor
- `baoyu-wechat-summary`: streamline roast version prompts in output-formats.md (99 → 23 lines), add roast-specific profile usage bullets to SKILL.md Round 2
## 1.116.3 - 2026-05-13
### Documentation
- Replace Claude Code references with generic Agent wording in READMEs to reflect multi-agent support (Claude Code, Codex, etc.)
## 1.116.2 - 2026-05-13
### Documentation
- `baoyu-wechat-summary`: update example group name in SKILL.md
## 1.116.1 - 2026-05-13
### Features
- `baoyu-wechat-summary`: add `data_root` option to first-time setup flow, allowing users to customize the digest output directory during initialization
## 1.116.0 - 2026-05-13
### Features
- Add `baoyu-wechat-summary` skill: summarize WeChat group chat highlights into structured digests with topic extraction, message leaderboards, and per-user profiles. Supports normal and roast (毒舌) versions, incremental mode, and profile backfill. Requires [wx-cli](https://github.com/jackwener/wx-cli).
## 1.115.4 - 2026-05-11
### Documentation
- Image generation backend selection: emphasize Codex `imagegen` as the priority runtime-native tool (invoke via the `Skill` tool with `skill: "imagegen"`) and forbid SVG/HTML/canvas substitution when no raster backend can be resolved — fall through to asking the user instead of silently emitting code-based art. Updated in `docs/image-generation-tools.md` and inlined into `baoyu-article-illustrator`, `baoyu-comic`, `baoyu-cover-image`, `baoyu-image-cards`, `baoyu-infographic`, `baoyu-slide-deck`, and `baoyu-xhs-images`.
## 1.115.3 - 2026-05-11
### Fixes
- `baoyu-post-to-wechat`: ensure tab activation before copy/paste in WeChat editor (by @fengxiaodong28)
- `baoyu-post-to-x`: use toolbar media upload instead of image clipboard paste for X Articles
## 1.115.2 - 2026-05-10
### Fixes
- `baoyu-post-to-x`: honor explicit Codex Chrome plugin requests as a distinct browser-control mode, keep Chrome Computer Use and CDP fallbacks from silently taking over, and improve X Articles draft creation detection.
## 1.115.1 - 2026-05-10
### Fixes
- `baoyu-imagine`: change the default MiniMax image API endpoint to `https://api.minimaxi.com` to match the current official image generation documentation, while keeping `https://api.minimax.io` available through `MINIMAX_BASE_URL` overrides.
- `baoyu-image-gen`: sync the deprecated image-generation entrypoint with the same MiniMax default endpoint and regression coverage.
## 1.115.0 - 2026-05-09
### Features
- `baoyu-post-to-x`: add Chrome Computer Use as the preferred execution mode in Codex. When Computer Use tools are available, all X UI actions (compose, article, quote, video) go through the user's real Chrome window instead of CDP scripts. CDP scripts become a fallback when Computer Use is unavailable or explicitly not requested.
## 1.114.1 - 2026-05-08
### Fixes
- `baoyu-danger-gemini-web`: restore generated-image extraction for current Gemini Web responses where image URLs appear as `https://lh3.googleusercontent.com/gg-dl/` without the legacy generated-image markers. Adds regression coverage for the fallback response shape. (by @evilstar2016)
## 1.114.0 - 2026-05-05
### Features
- `baoyu-infographic`: add `retro-popup-pop` style — retro pixel popup × pop-art collage. Renders content as a stack of 80/90s desktop popup windows (title bars, close buttons, ERROR / ALERT dialogs, file windows like `PROBLEMS.EXE`, progress bars, OK / CANCEL / FIX IT buttons) with thick black outlines, flat color fills, and bright cyan (#12B8DE) or vintage cream (#F5F0E6) backgrounds. Pairs especially well with the `dense-modules` layout; promoted as a recommended style for the `高密度信息大图` keyword shortcut and the `Product/Buying Guide` content type. Style Gallery count goes from 21 to 22.
Credit to AJ@WaytoAGI.
### Documentation
- `release-skills`: document GitHub Release publishing in the release workflow, including release-notes extraction from changelog sections, annotated tag creation, `gh release create/edit`, and historical release backfill for existing tags.
## 1.113.0 - 2026-04-25
### Features
- `baoyu-imagine`: add DashScope Wan 2.7 image model support (`wan2.7-image-pro` and `wan2.7-image`) directly through the official Aliyun (Bailian) API. Supports text-to-image, image editing, and multi-image fusion with up to 9 reference images, with documented `[1:8, 8:1]` aspect ratio validation and per-mode pixel-budget rules. Forces `parameters.n: 1` to match baoyu-imagine's single-image save semantics and explicitly rejects `--n > 1` to prevent silent multi-image billing (the API defaults to `n=4` in non-collage mode). Allows `--provider dashscope --ref ...` opt-in for Wan 2.7 reference workflows.
## 1.112.0 - 2026-04-24
### Features
- `baoyu-article-illustrator`: make `hand-drawn-edu` (infographic + sketch-notes + macaron) the universal fallback preset when content analysis surfaces no strong signal — warm cream paper, black hand-drawn lines, soft pastel section blocks. Elevate `sketch-notes` to primary style across infographic / flowchart / comparison / framework auto-selection; rewrite the sketch-notes style spec (macaron palette, canonical single-page layout, diagram-only rule); add matching prompt block and workflow defaults.
- `baoyu-article-illustrator`: add `hand-drawn-edu-flow` (flowchart) and `hand-drawn-edu-compare` (comparison) presets for the same warm educational style.
### Breaking Changes
- `baoyu-article-illustrator`: `hand-drawn-edu` preset now maps to `infographic` instead of `flowchart`. Users relying on the previous flowchart behavior should switch to the new `hand-drawn-edu-flow` preset.
### Fixes
- `baoyu-post-to-x`: add entry point guard to `scripts/md-to-html.ts` so that importing `parseMarkdown` from `x-article.ts` no longer triggers the CLI entry point. Mirrors the same fix applied to `baoyu-post-to-weibo`.
## 1.111.1 - 2026-04-21
### Documentation
- Add a top-level `## Confirmation Policy` section to every image-generating skill (`baoyu-infographic`, `baoyu-cover-image`, `baoyu-slide-deck`, `baoyu-image-cards`, `baoyu-xhs-images`, `baoyu-article-illustrator`) as a single source of truth: explicit skill invocation, keyword shortcuts, EXTEND.md defaults, and auto-recommendations are recommendation inputs only — they never authorize skipping the confirmation step. Opt-out requires an explicit current-request signal (`--no-confirm` / `--quick` / `--yes` / "直接生成" / equivalent).
- `baoyu-infographic`: consolidate the scattered reminders (previously repeated across Step 5, Step 6, Default combination, Keyword Shortcuts, and the preferences docs) into a single policy section referenced from Step 4's hard gate.
## 1.111.0 - 2026-04-21
### Refactor
- Unify image-backend resolution across all image-consuming skills (`baoyu-infographic`, `baoyu-comic`, `baoyu-cover-image`, `baoyu-image-cards`, `baoyu-article-illustrator`, `baoyu-slide-deck`, `baoyu-xhs-images`): add a single `preferred_image_backend` preference field (`auto | ask | <backend-id>`) and replace the stateless ask-once rule with a 4-step resolution (current-request override → saved preference → auto-select → ask). Runtime-native tools (Codex `imagegen`, Hermes `image_generate`) are preferred by default; existing `EXTEND.md` files without the field are treated as `auto` with no schema bump.
- Add a top-level `## Changing Preferences` section to each image-consuming skill as a first-class surface for pinning the backend and editing common one-line preferences.
## 1.110.0 - 2026-04-21
### Features
- `baoyu-imagine`: add `gpt-image-2` support for OpenAI image generation and edits, make it the default OpenAI model, and document the official size/quality mapping, custom-size constraints, and Azure deployment guidance
## 1.109.0 - 2026-04-21
### Features
- `baoyu-url-to-markdown`: vendor the `baoyu-fetch` runtime into `scripts/lib` and run it through a local `scripts/baoyu-fetch` CLI so published skill installs are self-contained
### Fixes
- `baoyu-fetch`: extract playable X/Twitter video MP4 variants for single posts and X Articles, choosing the highest-bitrate MP4 and rendering article videos as `[video](...)`
- `sync-clawhub`: publish from the shared release file list so extensionless CLI entrypoints, `bun.lock`, and vendored `scripts/lib` files are uploaded
### Maintenance
- Upgrade `defuddle` to 0.17.0 and `jsdom` to 29.0.2; override `@xmldom/xmldom` to 0.8.13 to keep the Defuddle dependency chain vulnerability-free
## 1.108.0 - 2026-04-19
### Refactor
- Refactor skills into focused reference files for better maintainability
- Use npm packages for shared skill code across skills
## 1.107.0 - 2026-04-15 ## 1.107.0 - 2026-04-15
### Features ### Features
@@ -630,7 +923,7 @@ English | [中文](./CHANGELOG.zh.md)
- `baoyu-format-markdown`: add reader-perspective content analysis phase — analyzes highlights, structure, and formatting issues before applying formatting - `baoyu-format-markdown`: add reader-perspective content analysis phase — analyzes highlights, structure, and formatting issues before applying formatting
- `baoyu-format-markdown`: restructure workflow from 8 steps to 7 with explicit do/don't formatting principles and completion report - `baoyu-format-markdown`: restructure workflow from 8 steps to 7 with explicit do/don't formatting principles and completion report
- `baoyu-translate`: extract Step 2 workflow mechanics to separate reference file for cleaner SKILL.md - `baoyu-translate`: extract Step 2 workflow mechanics to separate reference file for cleaner SKILL.md
- `baoyu-translate`: expand trigger keywords (改成中文, 快翻, 本地化, etc.) for better skill activation - `baoyu-translate`: expand trigger keywords (改成中文,快翻,本地化etc.) for better skill activation
- `baoyu-translate`: add proactive warning for long content in quick mode - `baoyu-translate`: add proactive warning for long content in quick mode
- `baoyu-translate`: save frontmatter to `chunks/frontmatter.md` during chunking - `baoyu-translate`: save frontmatter to `chunks/frontmatter.md` during chunking
+296 -3
View File
@@ -2,6 +2,299 @@
[English](./CHANGELOG.md) | 中文 [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
### 新功能
- `baoyu-markdown-to-html`:在标准图片占位符流水线之前,通过共享 Chrome(CDP)把 ` ```mermaid ` 围栏代码块渲染为本地 PNG。新增 CLI 参数 `--mermaid-theme <default|forest|dark|neutral|base>``--mermaid-scale <N>`(默认 `2`,即 @2x 分辨率)、`--mermaid-bg <white|transparent|#hex>`,以及用于关闭渲染的 `--no-mermaid`。生成的图片落在 `imgs/.mermaid-cache/mermaid-<hash>.png`,跨次运行通过对 `(code, theme, scale, background, mermaid 版本)` 取 SHA-256 前 12 位进行去重/复用。Chrome 不可用或单块渲染失败时,保留浏览器侧 `<pre class="mermaid">` 兜底
- `baoyu-post-to-wechat``baoyu-post-to-weibo``baoyu-post-to-x`:在微信 / 微博 / X 发布流水线中同步接入上述 Mermaid → PNG 预处理,Mermaid 图能以真图形式出现在已发布的稿件中(此前会落到未渲染的 `<pre>` 块)。现有的 `WECHATIMGPH_*` / `WBIMGPH_*` / `XIMGPH_*` 占位符流水线无需改动,会直接拾取生成的 PNG
- `baoyu-md` 包:新增导出 `preprocessMermaidInMarkdown``extractMermaidBlocks``replaceMermaidBlocks``hashMermaidCode``MERMAID_VERSION`(渲染函数由 skill 注入,本包不再反向依赖 Chrome)
- `baoyu-chrome-cdp` 包:新增 `./mermaid` 子导出,提供 `renderMermaidToPng(code, outputPath, options)`,底层为进程级单例 CDP 连接,复用共享 Chrome profile,并把 Mermaid 10.9.1 UMD 资产打入包内
## 2.0.1 - 2026-05-24
### 修复
- `baoyu-post-to-wechat`:发布 `newspic`(图文)时,将 `content` 字段从 HTML 转换为纯文本后再提交微信草稿接口。此前直接传 HTML,多图场景下内容长度超出限制会触发错误 45166(invalid content)。新增 `htmlToPlainText` 工具:把 `<br>` 和块级闭合标签转成换行、剥离其余标签、解码命名实体与十进制/十六进制数字实体、合并多余空白 (by @Go1dFinger, [#164](https://github.com/JimLiu/baoyu-skills/pull/164), 关联 [#163](https://github.com/JimLiu/baoyu-skills/issues/163))
### 致谢
- `baoyu-post-to-wechat` 的 newspic 纯文本修复由 @Go1dFinger 贡献([#164](https://github.com/JimLiu/baoyu-skills/pull/164)
## 2.0.0 - 2026-05-24
### 破坏性变更
- 移除 `baoyu-imagine` skill。所有功能(providers、脚本、references)合并入 `baoyu-image-gen``marketplace.json` 改用新名称注册,`homepage` 链接更新为 `#baoyu-image-gen`
- 移除 `baoyu-image-cards` skill。所有功能(样式、布局、配色、预设)合并入 `baoyu-xhs-images``marketplace.json` 改用新名称注册
- 其它 skill`baoyu-article-illustrator``baoyu-comic``baoyu-cover-image``baoyu-infographic``baoyu-slide-deck`)中 `## Image Generation Tools` 示例统一改用 `baoyu-image-gen`,不再引用 `baoyu-imagine`
### 迁移说明
- 旧的 `~/.baoyu-skills/baoyu-imagine/EXTEND.md``.baoyu-skills/baoyu-imagine/EXTEND.md` 配置文件会被 `scripts/main.ts` 中的 legacy-path 解析器自动重命名到 `…/baoyu-image-gen/EXTEND.md`,首次运行时迁移
- 通过斜杠命令调用时,请将 `/baoyu-imagine ...` 改为 `/baoyu-image-gen ...`;将所有 `baoyu-image-cards` 改为 `baoyu-xhs-images`
## 1.119.0 - 2026-05-24
### 新功能
- `baoyu-electron-extract`:新增 skill,可从任意已安装的 Electron 应用的 `app.asar` 中提取资源和 JavaScript。`.js.map` 文件内嵌 `sourcesContent` 时还原原始源码(含 TypeScript/JSX),否则用 Prettier 原地美化压缩后的 JS/CSS。source-map 路径先相对各 `.js.map` 文件解析,因此 `../../src/main.ts` 这类打包器相对路径会还原为 `restored/` 下的可读路径,而不是哈希占位符。始终跳过 `node_modules``webpack/runtime/*` 条目。macOS 下自动从 `/Applications``~/Applications` 发现应用,Windows 下从 `%LOCALAPPDATA%\Programs``%PROGRAMFILES%``%PROGRAMFILES(X86)%``%APPDATA%` 发现;其他平台请用 `--asar <path>` 显式指定。安全:拒绝写入 `/`、用户主目录或当前工作目录,未加 `--force` 时拒绝写入非空的已有输出目录
## 1.118.0 - 2026-05-21
### 新功能
- `codex-imagegen`:新增面向非 Codex 运行时(如 Claude Code)的图像生成后端 —— 通过 `codex exec --json --sandbox danger-full-access` 调用 Codex CLI 内置的 `image_gen` 工具,无需 `OPENAI_API_KEY`。内置幂等缓存、文件锁并发控制、JSONL 事件流解析、PNG 魔术字节校验和指数退避重试 (by @yelban, #158)
- `baoyu-cover-image`:在 `SKILL.md` 中接入 `codex-imagegen` 包装脚本(当 `preferred_image_backend: codex-imagegen` 时生效),并补充慢网络下的 `--timeout` 参数说明
### 重构
- `codex-imagegen`:在代码中强制校验 `--prompt``--prompt-file` 互斥(此前仅在文档说明)
- `codex-imagegen`:将 `(opts as any).__promptFile` 这一 hack 改为 `CliOptions` 上类型化的 `promptFile` 字段
- `codex-imagegen`:用复用的 `findCpToTarget` 辅助函数替换内联的 `cp|mv ... generated_images` 正则
- `codex-imagegen`:错误返回时正确透传 `attempts`(此前硬编码为 `0`
- `codex-imagegen`:删除无用的 `parseFinalJson()` 函数及对应测试(包装脚本以磁盘校验为准,不再依赖 agent 自报 JSON
### 安全
- `codex-imagegen`:在拼入发往 `codex exec --sandbox danger-full-access` 的 agent 指令前,拒绝包含 shell 元字符的 `--image` / `--ref` 路径
### 致谢
- `codex-imagegen` 后端由 @yelban 贡献 (#158)
## 1.117.5 - 2026-05-21
### 致谢
- `baoyu-post-to-wechat`:远程 API 发布更新感谢 Dame5211 <1079825614@qq.com>
## 1.117.4 - 2026-05-21
### 新功能
- `baoyu-post-to-wechat`:新增通过 SSH SOCKS5 隧道进行远程 API 发布
### 修复
- `baoyu-post-to-wechat`:修复远程 API 发布在 Bun 下的运行问题,并严格校验远程发布配置
### CI
- 测试前安装 `baoyu-post-to-wechat` 脚本依赖
## 1.117.3 - 2026-05-20
### 新功能
- CI:新增 skill 发布提交校验 —— 涉及 `skills/<name>/**` 的提交必须使用 Conventional Commit 格式;发布/同步时校验 SKILL.md 版本一致性
### 修复
- `baoyu-diagram`:为 SKILL.md 添加 version 字段
- `baoyu-post-to-wechat`:同步 SKILL.md 版本
### 文档
- `baoyu-wechat-summary`:重构 profile 字段 —— 将 `aliases` 拆分为 `group_nicknames`(用户历史群名)和 `aliases`(其他成员对用户的称呼),新增 `tags` 字段存储横向属性
## 1.117.2 - 2026-05-17
### 文档
- `baoyu-cover-image`:禁止用代码修补已生成的位图文字 —— 不再使用 ImageMagick / Pillow / Canvas / SVG / HTML 叠层覆盖、重写或替换标题/副标题文字,文字异常时应改 prompt 重新生成或换用少字/无标题版本
- `baoyu-article-illustrator``baoyu-comic``baoyu-image-cards``baoyu-xhs-images``baoyu-infographic``baoyu-slide-deck`:同步上述文字修补禁令,各自针对该 skill 的文字类别(标签/说明、对白/拟声词、标题/正文/标签、标题/数据、幻灯片标题/要点)
## 1.117.1 - 2026-05-16
### 修复
- `baoyu-post-to-wechat`:修复微信浏览器文章发布问题 (by @zhangga)
- `baoyu-post-to-wechat`:修复图片上传回退逻辑及 macOS WebP 剪贴板复制
## 1.117.0 - 2026-05-16
### 新功能
- `baoyu-article-illustrator`:新增批量生成策略 —— 优先使用后端原生批量接口,其次运行时并行调用,最后顺序生成;支持 `generation_batch_size` 配置和 `--batch-size` 参数
- `baoyu-comic`:新增批量生成策略,支持依赖感知排序(角色图先于页面)和 `--batch-size` 参数
- `baoyu-image-cards`:新增批量生成策略,遵循 image-1 锚定链,支持 `--batch-size` 参数
- `baoyu-slide-deck`:新增幻灯片图片批量生成策略,支持 `--batch-size` 参数
- `baoyu-xhs-images`:同步 baoyu-image-cards 的批量生成策略
## 1.116.5 - 2026-05-14
### 新功能
- `baoyu-post-to-wechat`:当设置 `TELEGRAM_BOT_TOKEN``TELEGRAM_CHAT_ID` 环境变量时,自动将微信登录二维码发送到 Telegram,支持无显示器/远程登录场景 (by @beforesun)
### 重构
- `baoyu-post-to-wechat`:加固 Telegram QR 通知逻辑 —— 增加 10 秒 fetch 超时、未配置环境变量时不再无谓等待 2 秒、回退截图改为视口范围以减小体积
## 1.116.4 - 2026-05-14
### 重构
- `baoyu-wechat-summary`:精简毒舌版提示词(99 → 23 行),在 SKILL.md Round 2 中增加 roast 专用的画像使用指引
## 1.116.3 - 2026-05-13
### 文档
- README 中将 Claude Code 替换为通用的 Agent 表述,体现多 Agent 支持(Claude Code、Codex 等)
## 1.116.2 - 2026-05-13
### 文档
- `baoyu-wechat-summary`:更新 SKILL.md 中的示例群名
## 1.116.1 - 2026-05-13
### 新功能
- `baoyu-wechat-summary`:初始化设置流程中新增 `data_root` 选项,允许用户在首次配置时自定义摘要输出目录
## 1.116.0 - 2026-05-13
### 新功能
- 新增 `baoyu-wechat-summary` 技能:将微信群聊精华提炼为结构化简报,支持话题提取、发言排行榜和群友画像。可生成正常版和毒舌版,支持增量模式和画像回溯初始化。需安装 [wx-cli](https://github.com/jackwener/wx-cli)。
## 1.115.4 - 2026-05-11
### 文档
- 图片生成后端选择规则强化:明确将 Codex `imagegen` 作为运行时原生工具的优先项(通过 `Skill` 工具调用,`skill: "imagegen"`),并禁止在无可用光栅后端时降级为 SVG/HTML/canvas 等代码渲染 —— 应退回到询问用户,而非静默输出代码绘图。规则同步更新到 `docs/image-generation-tools.md`,并按自包含规则内联到 `baoyu-article-illustrator``baoyu-comic``baoyu-cover-image``baoyu-image-cards``baoyu-infographic``baoyu-slide-deck``baoyu-xhs-images`
## 1.115.3 - 2026-05-11
### 修复
- `baoyu-post-to-wechat`:修复微信编辑器中复制粘贴前未激活标签页的问题 (by @fengxiaodong28)
- `baoyu-post-to-x`:X 文章图片插入改用工具栏媒体上传替代剪贴板粘贴方式
## 1.115.2 - 2026-05-10
### 修复
- `baoyu-post-to-x`:将显式请求 Codex Chrome 插件的场景作为独立浏览器控制模式处理,避免 Chrome Computer Use 或 CDP 回退流程静默接管;同时改进 X Articles 草稿创建按钮检测。
## 1.115.1 - 2026-05-10
### 修复
- `baoyu-imagine`:将默认 MiniMax 图片 API 端点改为 `https://api.minimaxi.com`,与当前官方图片生成文档保持一致;仍可通过 `MINIMAX_BASE_URL` 覆盖为 `https://api.minimax.io`
- `baoyu-image-gen`:同步已废弃图片生成入口的 MiniMax 默认端点和回归测试。
## 1.115.0 - 2026-05-09
### 新功能
- `baoyu-post-to-x`:新增 Chrome Computer Use 作为 Codex 环境下的首选执行模式。当 Computer Use 工具可用时,所有 X 界面操作(发帖、文章、引用、视频)均通过用户真实 Chrome 窗口完成,不再使用 CDP 脚本。CDP 脚本降级为 Computer Use 不可用或用户明确要求时的回退方案。
## 1.114.1 - 2026-05-08
### 修复
- `baoyu-danger-gemini-web`:修复当前 Gemini Web 响应中生成图 URL 以 `https://lh3.googleusercontent.com/gg-dl/` 形式出现、但不再包含旧版生成图 marker 时的图片提取失败问题。补充该响应形态的回归测试。 (by @evilstar2016)
## 1.114.0 - 2026-05-05
### 新功能
- `baoyu-infographic`:新增 `retro-popup-pop` 风格 —— 复古像素弹窗 × 波普信息图。画面由多个 80/90 年代桌面弹窗叠加而成(标题栏、关闭按钮、ERROR / ALERT 报错对话框、`PROBLEMS.EXE` 等复古文件窗、进度条、OK / CANCEL / FIX IT 按钮),统一粗黑描边、平涂色块,背景使用亮青蓝(#12B8DE)或复古奶油色(#F5F0E6)。与 `dense-modules` 布局尤其契合;同时升级为 `高密度信息大图` 关键词快捷方式与 `Product/Buying Guide` 内容类型的推荐风格。风格库从 21 个扩展至 22 个。
Credit to AJ@WaytoAGI.
### 文档
- `release-skills`:补充 GitHub Release 发布流程,包括从 changelog 段落提取 release notes、创建 annotated tag、执行 `gh release create/edit`,以及为已有 tag 回填历史 GitHub Releases。
## 1.113.0 - 2026-04-25
### 新功能
- `baoyu-imagine`:新增 DashScope Wan 2.7 图像模型支持(`wan2.7-image-pro``wan2.7-image`),通过阿里云百炼官方 API 直接调用,无需经 Replicate 转发。支持文生图、图像编辑、多图融合(最多 9 张参考图),按官方文档校验 `[1:8, 8:1]` 宽高比范围,并按模式应用不同的像素预算规则。强制 `parameters.n: 1` 以匹配 baoyu-imagine 的单图保存语义,显式拒绝 `--n > 1`,避免在用户不知情的情况下产生多图计费(API 在非拼图模式下默认 `n=4`)。允许通过 `--provider dashscope --ref ...` 显式启用 Wan 2.7 参考图工作流。
## 1.112.0 - 2026-04-24
### 新功能
- `baoyu-article-illustrator`:当内容分析未检测到明确信号时,将 `hand-drawn-edu`infographic + sketch-notes + macaron)作为通用默认预设 —— 暖奶油色纸面背景、黑色手绘线条、柔和马卡龙色块。`sketch-notes` 升级为 infographic / flowchart / comparison / framework 自动选择的首选风格;重写 sketch-notes 风格规范(马卡龙调色板、标准单页布局、仅限示意图的规则);新增对应的 prompt 模板块和默认工作流规则。
- `baoyu-article-illustrator`:新增 `hand-drawn-edu-flow`flowchart)和 `hand-drawn-edu-compare`(comparison)两个预设,保持相同的温暖教育风格。
### 破坏性变更
- `baoyu-article-illustrator``hand-drawn-edu` 预设的类型由 `flowchart` 改为 `infographic`。依赖原有流程图行为的用户请改用新增的 `hand-drawn-edu-flow` 预设。
### 修复
- `baoyu-post-to-x`:为 `scripts/md-to-html.ts` 添加入口守卫,确保 `x-article.ts` 导入 `parseMarkdown` 时不再触发 CLI 入口逻辑。与 `baoyu-post-to-weibo` 此前的修复保持一致。
## 1.111.1 - 2026-04-21
### 文档
- 为每个图片生成类技能(`baoyu-infographic``baoyu-cover-image``baoyu-slide-deck``baoyu-image-cards``baoyu-xhs-images``baoyu-article-illustrator`)新增顶级 `## Confirmation Policy` 章节作为单一事实源:显式调用技能、关键词快捷方式、EXTEND.md 偏好、自动推荐都只是"推荐输入",不授权跳过确认步骤。跳过确认必须由当前请求中的明确信号触发(`--no-confirm` / `--quick` / `--yes` / "直接生成" / 同义表达)。
- `baoyu-infographic`:将原先散落在 Step 5、Step 6、Default combination、Keyword Shortcuts 及 preferences 文档中的重复提醒合并为单一策略章节,由 Step 4 的 hard gate 引用。
## 1.111.0 - 2026-04-21
### 重构
- 统一所有图片生成类技能(`baoyu-infographic``baoyu-comic``baoyu-cover-image``baoyu-image-cards``baoyu-article-illustrator``baoyu-slide-deck``baoyu-xhs-images`)的后端选择规则:新增单一 `preferred_image_backend` 偏好字段(`auto | ask | <backend-id>`),用 4 步解析规则(当前请求覆盖 → 已保存偏好 → 自动选择 → 询问用户)替换原有的无状态询问规则。默认优先使用运行时原生工具(如 Codex `imagegen`、Hermes `image_generate`);未设置该字段的现有 `EXTEND.md` 文件视为 `auto`,无需升级 schema 版本。
- 在每个图片技能中新增顶级 `## Changing Preferences` 章节,作为固定后端和修改常用偏好的一级入口。
## 1.110.0 - 2026-04-21
### 新功能
- `baoyu-imagine`:新增 `gpt-image-2` 支持,用于 OpenAI 图像生成与编辑;将其设为默认 OpenAI 模型,并补齐官方尺寸/质量映射、自定义尺寸约束与 Azure 部署说明
## 1.109.0 - 2026-04-21
### 新功能
- `baoyu-url-to-markdown`:将 `baoyu-fetch` 运行时代码 vendored 到 `scripts/lib`,并通过本地 `scripts/baoyu-fetch` CLI 调用,使发布后的技能安装不再依赖 `baoyu-fetch` npm 包
### 修复
- `baoyu-fetch`:修复 X/Twitter 单条内容与 X Article 的视频解析,提取可播放的最高码率 MP4,并将文章视频渲染为 `[video](...)`
- `sync-clawhub`:改用共享 release 文件清单发布,确保无扩展名 CLI 入口、`bun.lock` 和 vendored `scripts/lib` 文件都会被上传
### 维护
-`defuddle` 升级到 0.17.0、`jsdom` 升级到 29.0.2,并通过 override 将 `@xmldom/xmldom` 固定到 0.8.13,清除 Defuddle 依赖链上的漏洞提示
## 1.108.0 - 2026-04-19
### 重构
- 将技能文档拆分为聚焦的参考文件,提升可维护性
- 将多技能共享代码迁移至 npm 包管理
## 1.107.0 - 2026-04-15 ## 1.107.0 - 2026-04-15
### 新功能 ### 新功能
@@ -586,7 +879,7 @@
## 1.52.0 - 2026-03-06 ## 1.52.0 - 2026-03-06
### 新功能 ### 新功能
- `baoyu-post-to-weibo`:新增 `--video` 视频上传支持(图片+视频最多 18 个文件) - `baoyu-post-to-weibo`:新增 `--video` 视频上传支持(图片 + 视频最多 18 个文件)
- `baoyu-post-to-weibo`:上传方式从剪贴板粘贴改为 `DOM.setFileInputFiles`,提升上传可靠性 - `baoyu-post-to-weibo`:上传方式从剪贴板粘贴改为 `DOM.setFileInputFiles`,提升上传可靠性
### 修复 ### 修复
@@ -1049,7 +1342,7 @@
## 1.20.0 - 2026-01-24 ## 1.20.0 - 2026-01-24
### 新功能 ### 新功能
- `baoyu-cover-image`:从类型 × 风格二维系统升级为**四维系统**——新增 `--text` 维度(none 无文字、title-only 仅标题、title-subtitle 标题+副标题、text-rich 丰富文字)控制文字密度,新增 `--mood` 维度(subtle 低调、balanced 平衡、bold 醒目)控制情感强度。新增 `--quick` 标志跳过确认,直接使用自动选择。 - `baoyu-cover-image`:从类型 × 风格二维系统升级为**四维系统**——新增 `--text` 维度(none 无文字、title-only 仅标题、title-subtitle 标题 + 副标题、text-rich 丰富文字)控制文字密度,新增 `--mood` 维度(subtle 低调、balanced 平衡、bold 醒目)控制情感强度。新增 `--quick` 标志跳过确认,直接使用自动选择。
### 文档 ### 文档
- `baoyu-cover-image`:新增维度参考文件——`references/dimensions/text.md`(文字密度级别)和 `references/dimensions/mood.md`(氛围强度级别)。 - `baoyu-cover-image`:新增维度参考文件——`references/dimensions/text.md`(文字密度级别)和 `references/dimensions/mood.md`(氛围强度级别)。
@@ -1069,7 +1362,7 @@
- `baoyu-image-gen`:代码模块化——类型定义提取至 `types.ts`provider 实现提取至 `providers/google.ts``providers/openai.ts` - `baoyu-image-gen`:代码模块化——类型定义提取至 `types.ts`provider 实现提取至 `providers/google.ts``providers/openai.ts`
### 文档 ### 文档
- `baoyu-comic`:改进 ohmsha 预设文档,明确默认哆啦A梦角色定义和视觉描述。 - `baoyu-comic`:改进 ohmsha 预设文档,明确默认哆啦 A 梦角色定义和视觉描述。
## 1.18.3 - 2026-01-23 ## 1.18.3 - 2026-01-23
+38 -9
View File
@@ -1,6 +1,6 @@
# CLAUDE.md # CLAUDE.md
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.107.0**. Claude Code marketplace plugin providing AI-powered content generation skills. Version: **2.0.0**.
## Architecture ## Architecture
@@ -31,7 +31,7 @@ Execute: `${BUN_X} skills/<skill>/scripts/main.ts [options]`
- **Bun**: TypeScript runtime (`bun` preferred, fallback `npx -y bun`) - **Bun**: TypeScript runtime (`bun` preferred, fallback `npx -y bun`)
- **Chrome**: Required for CDP-based skills (gemini-web, post-to-x/wechat/weibo, url-to-markdown). All CDP skills share a single profile, override via `BAOYU_CHROME_PROFILE_DIR` env var. Platform paths: [docs/chrome-profile.md](docs/chrome-profile.md) - **Chrome**: Required for CDP-based skills (gemini-web, post-to-x/wechat/weibo, url-to-markdown). All CDP skills share a single profile, override via `BAOYU_CHROME_PROFILE_DIR` env var. Platform paths: [docs/chrome-profile.md](docs/chrome-profile.md)
- **Image generation APIs**: `baoyu-imagine` requires API key (OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, or Replicate) configured in EXTEND.md - **Image generation APIs**: `baoyu-image-gen` requires API key (OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, or Replicate) configured in EXTEND.md
- **Gemini Web auth**: Browser cookies (first run opens Chrome for login, `--login` to refresh) - **Gemini Web auth**: Browser cookies (first run opens Chrome for login, `--login` to refresh)
## Security ## Security
@@ -46,16 +46,43 @@ Execute: `${BUN_X} skills/<skill>/scripts/main.ts [options]`
| Rule | Description | | Rule | Description |
|------|-------------| |------|-------------|
| **Load project skills first** | Project skills override system/user-level skills with same name | | **Load project skills first** | Project skills override system/user-level skills with same name |
| **Default image generation** | Use `skills/baoyu-imagine/SKILL.md` unless user specifies otherwise | | **Default image generation** | Use whatever image backend is available in the current runtime; if multiple are available, ask the user which to use. See `## Image Generation Tools` below. |
Priority: project `skills/``$HOME/.baoyu-skills/` → system-level. Priority: project `skills/``$HOME/.baoyu-skills/` → system-level.
## Deprecated Skills ## Skill Self-Containment
| Skill | Note | Each skill under `skills/` (and `.claude/skills/`) is distributed and consumed independently — the folder may be extracted, copied into another project, or loaded without the rest of this repo. Therefore:
|-------|------|
| `baoyu-image-gen` | Migrated to `baoyu-imagine`. Do NOT add to `.claude-plugin/marketplace.json`. Do NOT update README for this skill. | - **Never link from `SKILL.md` or its `references/` to files outside the skill's own directory.** This includes `docs/`, sibling skills, and the repo root. Relative paths like `../../docs/foo.md` break when the skill is used standalone.
| `baoyu-xhs-images` | Migrated to `baoyu-image-cards`. Do NOT add to `.claude-plugin/marketplace.json`. Do NOT update README for this skill. | - **Inline any shared convention** (e.g., user-input rules, image-generation backend selection) directly in the skill rather than referencing an out-of-skill doc.
- Shared docs under `docs/` exist for **repo-author guidance only** — they may be referenced from `CLAUDE.md` and `docs/creating-skills.md`, but NOT from any `SKILL.md`. This applies to `docs/user-input-tools.md`, `docs/image-generation-tools.md`, `docs/image-generation.md`, and any other `docs/` file.
## User Input Tools
Skills that prompt users for choices MUST declare the tool-selection convention **inline** in exactly one place per `SKILL.md` — a `## User Input Tools` section near the top. Do NOT link out to [docs/user-input-tools.md](docs/user-input-tools.md); that doc is the author-side canonical source — copy its body into each SKILL.md. Concrete `AskUserQuestion` mentions elsewhere in a skill are treated as examples — other runtimes substitute their local equivalent under the rule.
## 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`, `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. Lives under [packages/baoyu-codex-imagegen](packages/baoyu-codex-imagegen) so it follows the same workspace layout as other shared packages.
Invoke via (TS entrypoint with `#!/usr/bin/env bun` shebang):
```bash
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
```
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 ## Release Process
@@ -77,7 +104,9 @@ All skills MUST use `baoyu-` prefix. Details: [docs/creating-skills.md](docs/cre
| Topic | File | | Topic | File |
|-------|------| |-------|------|
| Image generation guidelines | [docs/image-generation.md](docs/image-generation.md) | | Image generation output guidelines | [docs/image-generation.md](docs/image-generation.md) |
| Image generation backend selection | [docs/image-generation-tools.md](docs/image-generation-tools.md) |
| User input tool convention | [docs/user-input-tools.md](docs/user-input-tools.md) |
| Chrome profile platform paths | [docs/chrome-profile.md](docs/chrome-profile.md) | | Chrome profile platform paths | [docs/chrome-profile.md](docs/chrome-profile.md) |
| Comic style maintenance | [docs/comic-style-maintenance.md](docs/comic-style-maintenance.md) | | Comic style maintenance | [docs/comic-style-maintenance.md](docs/comic-style-maintenance.md) |
| ClawHub/OpenClaw publishing | [docs/publishing.md](docs/publishing.md) | | ClawHub/OpenClaw publishing | [docs/publishing.md](docs/publishing.md) |
+21
View File
@@ -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.
+166 -45
View File
@@ -2,7 +2,7 @@
English | [中文](./README.zh.md) English | [中文](./README.zh.md)
Skills shared by Baoyu for improving daily work efficiency with Claude Code. Skills shared by Baoyu for improving daily work efficiency with AI Agents (Claude Code, Codex, etc.).
## Prerequisites ## Prerequisites
@@ -11,12 +11,39 @@ Skills shared by Baoyu for improving daily work efficiency with Claude Code.
## Installation ## Installation
> **Tip**: This repository contains 20+ skills. Install only the ones you actually need — bulk-installing every skill adds unnecessary context overhead for your AI agent on every run.
### Quick Install (Recommended) ### Quick Install (Recommended)
```bash ```bash
npx skills add jimliu/baoyu-skills 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 ### Publish to ClawHub / OpenClaw
This repository now supports publishing each `skills/baoyu-*` directory as an individual ClawHub skill. This repository now supports publishing each `skills/baoyu-*` directory as an individual ClawHub skill.
@@ -32,7 +59,7 @@ This repository now supports publishing each `skills/baoyu-*` directory as an in
ClawHub installs skills individually, not as one marketplace bundle. After publishing, users can install specific skills such as: ClawHub installs skills individually, not as one marketplace bundle. After publishing, users can install specific skills such as:
```bash ```bash
clawhub install baoyu-imagine clawhub install baoyu-image-gen
clawhub install baoyu-markdown-to-html clawhub install baoyu-markdown-to-html
``` ```
@@ -40,7 +67,7 @@ Publishing to ClawHub releases the published skill under `MIT-0`, per ClawHub's
### Register as Plugin Marketplace ### Register as Plugin Marketplace
Run the following command in Claude Code: Run the following command in the Agent:
```bash ```bash
/plugin marketplace add JimLiu/baoyu-skills /plugin marketplace add JimLiu/baoyu-skills
@@ -64,7 +91,7 @@ Run the following command in Claude Code:
**Option 3: Ask the Agent** **Option 3: Ask the Agent**
Simply tell Claude Code: Simply tell the Agent:
> Please install Skills from github.com/JimLiu/baoyu-skills > Please install Skills from github.com/JimLiu/baoyu-skills
@@ -80,7 +107,7 @@ The marketplace now exposes a single plugin so each skill is registered exactly
To update skills to the latest version: To update skills to the latest version:
1. Run `/plugin` in Claude Code 1. Run `/plugin` in the Agent
2. Switch to **Marketplaces** tab (use arrow keys or Tab) 2. Switch to **Marketplaces** tab (use arrow keys or Tab)
3. Select **baoyu-skills** 3. Select **baoyu-skills**
4. Choose **Update marketplace** 4. Choose **Update marketplace**
@@ -93,6 +120,18 @@ You can also **Enable auto-update** to get the latest versions automatically.
Skills are organized into three categories: 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 Skills
Content generation and publishing skills. Content generation and publishing skills.
@@ -579,7 +618,7 @@ Plain text input is treated as a regular post. Markdown files are treated as X A
```bash ```bash
# Post with text # Post with text
/baoyu-post-to-x "Hello from Claude Code!" /baoyu-post-to-x "Hello from AI Agent!"
# Post with images # Post with images
/baoyu-post-to-x "Check this out" --image photo.png /baoyu-post-to-x "Check this out" --image photo.png
@@ -612,8 +651,9 @@ Post content to WeChat Official Account (微信公众号). Two modes available:
| Method | Speed | Requirements | | Method | Speed | Requirements |
|--------|-------|--------------| |--------|-------|--------------|
| API (Recommended) | Fast | API credentials | | API (Recommended) | Fast | API credentials (local IP allowlisted in WeChat) |
| Browser | Slow | Chrome, login session | | Browser | Slow | Chrome, login session |
| Remote API | Fast | API credentials + SSH-reachable server whose IP is on WeChat's allowlist |
**API Configuration** (for faster publishing): **API Configuration** (for faster publishing):
@@ -631,6 +671,17 @@ To obtain credentials:
**Browser Method** (no API setup needed): Requires Google Chrome. First run opens browser for QR code login (session preserved). **Browser Method** (no API setup needed): Requires Google Chrome. First run opens browser for QR code login (session preserved).
**Remote API Method** (for when WeChat's IP allowlist excludes your local machine): tunnels WeChat API calls through an SSH SOCKS5 dynamic port forward to a server whose IP is on the allowlist. No files are written to the remote host and `AppSecret` never leaves the local process. Add to your EXTEND.md:
```yaml
# Optional: only set when WeChat's IP allowlist excludes your local machine
remote_publish_host: server.example.com
remote_publish_user: deploy
remote_publish_identity_file: ~/.ssh/id_ed25519
```
Then publish with `--remote` (or set `default_publish_method: remote-api`). Authentication is SSH key only; only the typed `remote_publish_*` keys are honored.
**Multi-Account Support**: Manage multiple WeChat Official Accounts via `EXTEND.md`: **Multi-Account Support**: Manage multiple WeChat Official Accounts via `EXTEND.md`:
```bash ```bash
@@ -712,67 +763,67 @@ Post content to Weibo (微博). Supports regular posts with text, images, and vi
AI-powered generation backends. AI-powered generation backends.
#### baoyu-imagine #### baoyu-image-gen
AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, DashScope (Aliyun Tongyi Wanxiang), MiniMax, Jimeng (即梦), Seedream (豆包), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, custom sizes, batch generation, and quality presets. AI SDK-based image generation using OpenAI GPT Image 2, Azure OpenAI, Google, OpenRouter, DashScope (Aliyun Tongyi Wanxiang), MiniMax, Jimeng (即梦), Seedream (豆包), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, custom sizes, batch generation, and quality presets.
```bash ```bash
# Basic generation (auto-detect provider) # Basic generation (auto-detect provider)
/baoyu-imagine --prompt "A cute cat" --image cat.png /baoyu-image-gen --prompt "A cute cat" --image cat.png
# With aspect ratio # With aspect ratio
/baoyu-imagine --prompt "A landscape" --image landscape.png --ar 16:9 /baoyu-image-gen --prompt "A landscape" --image landscape.png --ar 16:9
# High quality (2k) # High quality (2k)
/baoyu-imagine --prompt "A banner" --image banner.png --quality 2k /baoyu-image-gen --prompt "A banner" --image banner.png --quality 2k
# Specific provider # Specific provider
/baoyu-imagine --prompt "A cat" --image cat.png --provider openai /baoyu-image-gen --prompt "A cat" --image cat.png --provider openai --model gpt-image-2
# Azure OpenAI (model = deployment name) # Azure OpenAI (model = deployment name)
/baoyu-imagine --prompt "A cat" --image cat.png --provider azure --model gpt-image-1.5 /baoyu-image-gen --prompt "A cat" --image cat.png --provider azure --model gpt-image-2
# OpenRouter # OpenRouter
/baoyu-imagine --prompt "A cat" --image cat.png --provider openrouter /baoyu-image-gen --prompt "A cat" --image cat.png --provider openrouter
# OpenRouter with reference images # OpenRouter with reference images
/baoyu-imagine --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) # DashScope (Aliyun Tongyi Wanxiang)
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider dashscope /baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
# DashScope with custom size # DashScope with custom size
/baoyu-imagine --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image banner.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872 /baoyu-image-gen --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image banner.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
# Z.AI GLM-Image # Z.AI GLM-Image
/baoyu-imagine --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai /baoyu-image-gen --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai
# MiniMax # MiniMax
/baoyu-imagine --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax /baoyu-image-gen --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
# MiniMax with subject reference # MiniMax with subject reference
/baoyu-imagine --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9 /baoyu-image-gen --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
# Replicate (default: google/nano-banana-2) # Replicate (default: google/nano-banana-2)
/baoyu-imagine --prompt "A cat" --image cat.png --provider replicate /baoyu-image-gen --prompt "A cat" --image cat.png --provider replicate
# Replicate Seedream 4.5 # Replicate Seedream 4.5
/baoyu-imagine --prompt "A studio portrait" --image portrait.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2 /baoyu-image-gen --prompt "A studio portrait" --image portrait.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2
# Replicate Wan 2.7 Image Pro # Replicate Wan 2.7 Image Pro
/baoyu-imagine --prompt "A concept frame" --image frame.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152 /baoyu-image-gen --prompt "A concept frame" --image frame.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
# Jimeng (即梦) # Jimeng (即梦)
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider jimeng /baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
# Seedream (豆包) # Seedream (豆包)
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider seedream /baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
# With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, MiniMax, or Seedream 5.0/4.5/4.0) # With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, MiniMax, or Seedream 5.0/4.5/4.0)
/baoyu-imagine --prompt "Make it blue" --image out.png --ref source.png /baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png
# Batch mode # Batch mode
/baoyu-imagine --batchfile batch.json --jobs 4 --json /baoyu-image-gen --batchfile batch.json --jobs 4 --json
``` ```
**Options**: **Options**:
@@ -783,10 +834,10 @@ AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, Da
| `--image` | Output image path (required) | | `--image` | Output image path (required) |
| `--batchfile` | JSON batch file for multi-image generation | | `--batchfile` | JSON batch file for multi-image generation |
| `--jobs` | Worker count for batch mode | | `--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` | | `--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`) | | `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
| `--size` | Size (e.g., `1024x1024`) | | `--size` | Size (e.g., `1024x1024`; `gpt-image-2` accepts valid custom sizes up to 3840px max edge) |
| `--quality` | `normal` or `2k` (default: `2k`) | | `--quality` | `normal` or `2k` (default: `2k`) |
| `--imageSize` | `1K`, `2K`, or `4K` for Google/OpenRouter | | `--imageSize` | `1K`, `2K`, or `4K` for Google/OpenRouter |
| `--imageApiDialect` | `openai-native` or `ratio-metadata` for OpenAI-compatible gateways | | `--imageApiDialect` | `openai-native` or `ratio-metadata` for OpenAI-compatible gateways |
@@ -810,11 +861,11 @@ AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, Da
| `JIMENG_ACCESS_KEY_ID` | Jimeng Volcengine access key | - | | `JIMENG_ACCESS_KEY_ID` | Jimeng Volcengine access key | - |
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng Volcengine secret key | - | | `JIMENG_SECRET_ACCESS_KEY` | Jimeng Volcengine secret key | - |
| `ARK_API_KEY` | Seedream Volcengine ARK API key | - | | `ARK_API_KEY` | Seedream Volcengine ARK API key | - |
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-1.5` | | `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-2` |
| `AZURE_OPENAI_DEPLOYMENT` | Azure default deployment name | - | | `AZURE_OPENAI_DEPLOYMENT` | Azure default deployment name | - |
| `AZURE_OPENAI_IMAGE_MODEL` | Backward-compatible Azure deployment/model alias | `gpt-image-1.5` | | `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` | | `OPENROUTER_IMAGE_MODEL` | OpenRouter model | `google/gemini-3.1-flash-image` |
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` | | `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image` |
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `qwen-image-2.0-pro` | | `DASHSCOPE_IMAGE_MODEL` | DashScope model | `qwen-image-2.0-pro` |
| `ZAI_IMAGE_MODEL` | Z.AI model | `glm-image` | | `ZAI_IMAGE_MODEL` | Z.AI model | `glm-image` |
| `BIGMODEL_IMAGE_MODEL` | Backward-compatible alias for Z.AI model | `glm-image` | | `BIGMODEL_IMAGE_MODEL` | Backward-compatible alias for Z.AI model | `glm-image` |
@@ -834,7 +885,7 @@ AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, Da
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | - | | `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | - |
| `ZAI_BASE_URL` | Custom Z.AI endpoint | `https://api.z.ai/api/paas/v4` | | `ZAI_BASE_URL` | Custom Z.AI endpoint | `https://api.z.ai/api/paas/v4` |
| `BIGMODEL_BASE_URL` | Backward-compatible alias for Z.AI endpoint | - | | `BIGMODEL_BASE_URL` | Backward-compatible alias for Z.AI endpoint | - |
| `MINIMAX_BASE_URL` | Custom MiniMax endpoint | `https://api.minimax.io` | | `MINIMAX_BASE_URL` | Custom MiniMax endpoint | `https://api.minimaxi.com` |
| `REPLICATE_BASE_URL` | Custom Replicate endpoint | - | | `REPLICATE_BASE_URL` | Custom Replicate endpoint | - |
| `JIMENG_BASE_URL` | Custom Jimeng endpoint | `https://visual.volcengineapi.com` | | `JIMENG_BASE_URL` | Custom Jimeng endpoint | `https://visual.volcengineapi.com` |
| `JIMENG_REGION` | Jimeng region | `cn-north-1` | | `JIMENG_REGION` | Jimeng region | `cn-north-1` |
@@ -851,15 +902,15 @@ AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, Da
- MiniMax reference images are sent as `subject_reference`; the current API is specialized toward character / portrait consistency. - MiniMax reference images are sent as `subject_reference`; the current API is specialized toward character / portrait consistency.
- Jimeng does not support reference images. - Jimeng does not support reference images.
- Seedream reference images are supported by Seedream 5.0 / 4.5 / 4.0, not Seedream 3.0. - Seedream reference images are supported by Seedream 5.0 / 4.5 / 4.0, not Seedream 3.0.
- Replicate defaults to `google/nano-banana-2`. `baoyu-imagine` only enables Replicate advanced options for `google/nano-banana*`, `bytedance/seedream-4.5`, `bytedance/seedream-5-lite`, `wan-video/wan-2.7-image`, and `wan-video/wan-2.7-image-pro`. - Replicate defaults to `google/nano-banana-2`. `baoyu-image-gen` only enables Replicate advanced options for `google/nano-banana*`, `bytedance/seedream-4.5`, `bytedance/seedream-5-lite`, `wan-video/wan-2.7-image`, and `wan-video/wan-2.7-image-pro`.
- Replicate currently saves exactly one output image per request. `--n > 1` is blocked locally instead of silently dropping extra results. - Replicate currently saves exactly one output image per request. `--n > 1` is blocked locally instead of silently dropping extra results.
- Replicate model behavior is family-specific: nano-banana uses `--quality` / `--ar`, Seedream uses validated `--size` / `--ar`, and Wan uses validated `--size` (with `--ar` converted locally to a concrete size). - Replicate model behavior is family-specific: nano-banana uses `--quality` / `--ar`, Seedream uses validated `--size` / `--ar`, and Wan uses validated `--size` (with `--ar` converted locally to a concrete size).
**Provider Auto-Selection**: **Provider Auto-Selection**:
1. If `--provider` is specified → use it 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 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 #### baoyu-danger-gemini-web
@@ -1112,12 +1163,80 @@ Custom style descriptions are also accepted, e.g., `--style "poetic and lyrical"
- Translator's notes for cultural/domain-specific references - Translator's notes for cultural/domain-specific references
- Output directory with all intermediate files preserved - Output directory with all intermediate files preserved
#### 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, 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
/baoyu-wechat-summary 相亲相爱一家人 最近 1
# Weekly summary
/baoyu-wechat-summary AI 技术群 最近 7
# Incremental (since last digest)
/baoyu-wechat-summary 相亲相爱一家人
# Roast version
/baoyu-wechat-summary 相亲相爱一家人 最近 3 天 毒舌版
```
**Requirements**:
- [wx-cli](https://github.com/jackwener/wx-cli) installed (`npm install -g @jackwener/wx-cli`)
- WeChat 4.x running and logged in on macOS
**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
- Profile backfill from historical digests
#### baoyu-electron-extract
Extract resources and JavaScript from any installed Electron app's `app.asar`. When `.js.map` files embed `sourcesContent`, restores the original source tree (TypeScript/JSX included); otherwise formats the minified JS/CSS with Prettier in place. Always skips `node_modules`. Works on macOS and Windows; pass `--asar <path>` on other platforms.
```bash
# Extract by app name (default output: ~/Downloads/Codex-electron-extract/)
/baoyu-electron-extract Codex
# Extract by absolute path (.app bundle, install dir, or .asar file)
/baoyu-electron-extract "/Applications/Visual Studio Code.app"
/baoyu-electron-extract --asar /Applications/Codex.app/Contents/Resources/app.asar Codex
# Custom output directory
/baoyu-electron-extract Codex --output ~/work/codex-source
# Preview discovery without writing anything
/baoyu-electron-extract Codex --dry-run
# Overwrite an existing output directory
/baoyu-electron-extract Codex --force
```
**Options**:
| Option | Description | Default |
|--------|-------------|---------|
| `<app>` | App name or absolute path (required unless `--asar`) | — |
| `--output`, `-o` | Output directory | `~/Downloads/<AppName>-electron-extract` |
| `--asar` | Override the resolved `.asar` path | auto-discovered |
| `--force`, `-f` | Allow writing into a non-empty existing output dir | false |
| `--skip-format` | Skip Prettier formatting | false |
| `--skip-restore` | Skip source-map restoration | false |
| `--no-unpacked` | Don't copy `app.asar.unpacked/` alongside | false |
| `--dry-run` | Print resolved paths and exit without writing | false |
| `--json` | Emit one JSON-line summary on stdout | false |
**Output layout**: `extract-report.json` (counts, warnings, paths), `extracted/` (raw asar, formatted in place when no map), `extracted.unpacked/` (native modules if present), and `restored/` (rebuilt source tree from `.js.map` files).
## Environment Configuration ## Environment Configuration
Some skills require API keys or custom configuration. Environment variables can be set in `.env` files: Some skills require API keys or custom configuration. Environment variables can be set in `.env` files:
**Load Priority** (higher priority overrides lower): **Load Priority** (higher priority overrides lower):
1. CLI environment variables (e.g., `OPENAI_API_KEY=xxx /baoyu-imagine ...`) 1. CLI environment variables (e.g., `OPENAI_API_KEY=xxx /baoyu-image-gen ...`)
2. `process.env` (system environment) 2. `process.env` (system environment)
3. `<cwd>/.baoyu-skills/.env` (project-level) 3. `<cwd>/.baoyu-skills/.env` (project-level)
4. `~/.baoyu-skills/.env` (user-level) 4. `~/.baoyu-skills/.env` (user-level)
@@ -1132,26 +1251,26 @@ mkdir -p ~/.baoyu-skills
cat > ~/.baoyu-skills/.env << 'EOF' cat > ~/.baoyu-skills/.env << 'EOF'
# OpenAI # OpenAI
OPENAI_API_KEY=sk-xxx OPENAI_API_KEY=sk-xxx
OPENAI_IMAGE_MODEL=gpt-image-1.5 OPENAI_IMAGE_MODEL=gpt-image-2
# OPENAI_BASE_URL=https://api.openai.com/v1 # OPENAI_BASE_URL=https://api.openai.com/v1
# OPENAI_IMAGE_USE_CHAT=false # OPENAI_IMAGE_USE_CHAT=false
# Azure OpenAI # Azure OpenAI
AZURE_OPENAI_API_KEY=xxx AZURE_OPENAI_API_KEY=xxx
AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com
AZURE_OPENAI_DEPLOYMENT=gpt-image-1.5 AZURE_OPENAI_DEPLOYMENT=gpt-image-2
# AZURE_API_VERSION=2025-04-01-preview # AZURE_API_VERSION=2025-04-01-preview
# OpenRouter # OpenRouter
OPENROUTER_API_KEY=sk-or-xxx 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_BASE_URL=https://openrouter.ai/api/v1
# OPENROUTER_HTTP_REFERER=https://your-app.example.com # OPENROUTER_HTTP_REFERER=https://your-app.example.com
# OPENROUTER_TITLE=Your App Name # OPENROUTER_TITLE=Your App Name
# Google # Google
GOOGLE_API_KEY=xxx 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 # GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta
# DashScope (Aliyun Tongyi Wanxiang) # DashScope (Aliyun Tongyi Wanxiang)
@@ -1167,7 +1286,7 @@ ZAI_IMAGE_MODEL=glm-image
# MiniMax # MiniMax
MINIMAX_API_KEY=xxx MINIMAX_API_KEY=xxx
MINIMAX_IMAGE_MODEL=image-01 MINIMAX_IMAGE_MODEL=image-01
# MINIMAX_BASE_URL=https://api.minimax.io # MINIMAX_BASE_URL=https://api.minimaxi.com
# Replicate # Replicate
REPLICATE_API_TOKEN=r8_xxx REPLICATE_API_TOKEN=r8_xxx
@@ -1268,7 +1387,9 @@ This project was inspired by and builds upon the following open source projects:
## License ## 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 ## Star History
+169 -47
View File
@@ -2,7 +2,17 @@
[English](./README.md) | 中文 [English](./README.md) | 中文
宝玉分享的 Claude Code 技能集,提升日常工作效率。 宝玉分享的 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
## 前置要求 ## 前置要求
@@ -11,12 +21,39 @@
## 安装 ## 安装
> **提示**:本仓库已收录 20+ 个 skill,请按需安装你真正会用到的那几个,不要一次性全装 —— 每个加载的 skill 都会在 Agent 每次运行时占用额外上下文。
### 快速安装(推荐) ### 快速安装(推荐)
```bash ```bash
npx skills add jimliu/baoyu-skills 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 ### 发布到 ClawHub / OpenClaw
现在这个仓库支持把每个 `skills/baoyu-*` 目录作为独立 ClawHub skill 发布。 现在这个仓库支持把每个 `skills/baoyu-*` 目录作为独立 ClawHub skill 发布。
@@ -32,7 +69,7 @@ npx skills add jimliu/baoyu-skills
ClawHub 按“单个 skill”安装,不是把整个 marketplace 一次性装进去。发布后,用户可以按需安装: ClawHub 按“单个 skill”安装,不是把整个 marketplace 一次性装进去。发布后,用户可以按需安装:
```bash ```bash
clawhub install baoyu-imagine clawhub install baoyu-image-gen
clawhub install baoyu-markdown-to-html clawhub install baoyu-markdown-to-html
``` ```
@@ -40,7 +77,7 @@ clawhub install baoyu-markdown-to-html
### 注册插件市场 ### 注册插件市场
Claude Code 中运行: Agent 中运行:
```bash ```bash
/plugin marketplace add JimLiu/baoyu-skills /plugin marketplace add JimLiu/baoyu-skills
@@ -64,7 +101,7 @@ clawhub install baoyu-markdown-to-html
**方式三:告诉 Agent** **方式三:告诉 Agent**
直接告诉 Claude Code 直接告诉 Agent
> 请帮我安装 github.com/JimLiu/baoyu-skills 中的 Skills > 请帮我安装 github.com/JimLiu/baoyu-skills 中的 Skills
@@ -80,7 +117,7 @@ clawhub install baoyu-markdown-to-html
更新技能到最新版本: 更新技能到最新版本:
1.Claude Code 中运行 `/plugin` 1.Agent 中运行 `/plugin`
2. 切换到 **Marketplaces** 标签页(使用方向键或 Tab 2. 切换到 **Marketplaces** 标签页(使用方向键或 Tab
3. 选择 **baoyu-skills** 3. 选择 **baoyu-skills**
4. 选择 **Update marketplace** 4. 选择 **Update marketplace**
@@ -93,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) ### 内容技能 (Content Skills)
内容生成和发布技能。 内容生成和发布技能。
@@ -579,7 +628,7 @@ clawhub install baoyu-markdown-to-html
```bash ```bash
# 发布文字 # 发布文字
/baoyu-post-to-x "Hello from Claude Code!" /baoyu-post-to-x "Hello from AI Agent!"
# 发布带图片 # 发布带图片
/baoyu-post-to-x "看看这个" --image photo.png /baoyu-post-to-x "看看这个" --image photo.png
@@ -612,8 +661,9 @@ clawhub install baoyu-markdown-to-html
| 方式 | 速度 | 要求 | | 方式 | 速度 | 要求 |
|------|------|------| |------|------|------|
| API(推荐) | 快 | API 凭证 | | API(推荐) | 快 | API 凭证(本机 IP 在公众号白名单内) |
| 浏览器 | 慢 | Chrome,登录会话 | | 浏览器 | 慢 | Chrome,登录会话 |
| 远程 API | 快 | API 凭证 + 一台 IP 在公众号白名单内、可 SSH 登录的服务器 |
**API 配置**(更快的发布方式): **API 配置**(更快的发布方式):
@@ -631,6 +681,8 @@ WECHAT_APP_SECRET=你的AppSecret
**浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存) **浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
**远程 API 方式**(适用于本机 IP 不在公众号白名单内的情况):通过 SSH SOCKS5 动态端口转发,将对 `api.weixin.qq.com` 的 HTTPS 调用转发到 IP 在白名单内的服务器上。Markdown 渲染、图片处理、草稿组装仍在本地完成;远端不会落任何文件,`AppSecret` 不会离开本地进程。仅支持 SSH 密钥认证,且只接受类型化的 `remote_publish_*` 配置项,不透传任意 ssh 选项。在 EXTEND.md 中配置 `remote_publish_host` 等字段后,发布时加上 `--remote`(或将 `default_publish_method` 设为 `remote-api`)。
**多账号支持**:通过 `EXTEND.md` 管理多个微信公众号: **多账号支持**:通过 `EXTEND.md` 管理多个微信公众号:
```bash ```bash
@@ -653,8 +705,8 @@ accounts:
default_author: 宝玉 default_author: 宝玉
need_open_comment: 1 need_open_comment: 1
only_fans_can_comment: 0 only_fans_can_comment: 0
app_id: 你的微信AppID app_id: 你的微信 AppID
app_secret: 你的微信AppSecret app_secret: 你的微信 AppSecret
- name: AI 工具集 - name: AI 工具集
alias: ai-tools alias: ai-tools
default_publish_method: browser default_publish_method: browser
@@ -712,67 +764,67 @@ accounts:
AI 驱动的生成后端。 AI 驱动的生成后端。
#### baoyu-imagine #### baoyu-image-gen
基于 AI SDK 的图像生成,支持 OpenAI、Azure OpenAI、Google、OpenRouter、DashScope(阿里通义万相)、MiniMax、即梦(Jimeng)、豆包(Seedream)和 Replicate API。支持文生图、参考图、宽高比、自定义尺寸、批量生成和质量预设。 基于 AI SDK 的图像生成,支持 OpenAI GPT Image 2、Azure OpenAI、Google、OpenRouter、DashScope(阿里通义万相)、MiniMax、即梦(Jimeng)、豆包(Seedream)和 Replicate API。支持文生图、参考图、宽高比、自定义尺寸、批量生成和质量预设。
```bash ```bash
# 基础生成(自动检测服务商) # 基础生成(自动检测服务商)
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png /baoyu-image-gen --prompt "一只可爱的猫" --image cat.png
# 指定宽高比 # 指定宽高比
/baoyu-imagine --prompt "风景图" --image landscape.png --ar 16:9 /baoyu-image-gen --prompt "风景图" --image landscape.png --ar 16:9
# 高质量(2k 分辨率) # 高质量(2k 分辨率)
/baoyu-imagine --prompt "横幅图" --image banner.png --quality 2k /baoyu-image-gen --prompt "横幅图" --image banner.png --quality 2k
# 指定服务商 # 指定服务商
/baoyu-imagine --prompt "一只猫" --image cat.png --provider openai /baoyu-image-gen --prompt "一只猫" --image cat.png --provider openai --model gpt-image-2
# Azure OpenAImodel 为部署名称) # Azure OpenAImodel 为部署名称)
/baoyu-imagine --prompt "一只猫" --image cat.png --provider azure --model gpt-image-1.5 /baoyu-image-gen --prompt "一只猫" --image cat.png --provider azure --model gpt-image-2
# OpenRouter # OpenRouter
/baoyu-imagine --prompt "一只猫" --image cat.png --provider openrouter /baoyu-image-gen --prompt "一只猫" --image cat.png --provider openrouter
# OpenRouter + 参考图 # OpenRouter + 参考图
/baoyu-imagine --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(阿里通义万相) # DashScope(阿里通义万相)
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider dashscope /baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
# DashScope 自定义尺寸 # DashScope 自定义尺寸
/baoyu-imagine --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image banner.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872 /baoyu-image-gen --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image banner.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
# Z.AI GLM-Image # Z.AI GLM-Image
/baoyu-imagine --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai /baoyu-image-gen --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai
# MiniMax # MiniMax
/baoyu-imagine --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax /baoyu-image-gen --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
# MiniMax + 角色参考图 # MiniMax + 角色参考图
/baoyu-imagine --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9 /baoyu-image-gen --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
# Replicate(默认:google/nano-banana-2 # Replicate(默认:google/nano-banana-2
/baoyu-imagine --prompt "一只猫" --image cat.png --provider replicate /baoyu-image-gen --prompt "一只猫" --image cat.png --provider replicate
# Replicate Seedream 4.5 # Replicate Seedream 4.5
/baoyu-imagine --prompt "一张影棚人像" --image portrait.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2 /baoyu-image-gen --prompt "一张影棚人像" --image portrait.png --provider replicate --model bytedance/seedream-4.5 --ar 3:2
# Replicate Wan 2.7 Image Pro # Replicate Wan 2.7 Image Pro
/baoyu-imagine --prompt "一张概念分镜" --image frame.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152 /baoyu-image-gen --prompt "一张概念分镜" --image frame.png --provider replicate --model wan-video/wan-2.7-image-pro --size 2048x1152
# 即梦(Jimeng # 即梦(Jimeng
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider jimeng /baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
# 豆包(Seedream # 豆包(Seedream
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider seedream /baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
# 带参考图(Google、OpenAI、Azure OpenAI、OpenRouter、Replicate、MiniMax 或 Seedream 5.0/4.5/4.0 # 带参考图(Google、OpenAI、Azure OpenAI、OpenRouter、Replicate、MiniMax 或 Seedream 5.0/4.5/4.0
/baoyu-imagine --prompt "把它变成蓝色" --image out.png --ref source.png /baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png
# 批量模式 # 批量模式
/baoyu-imagine --batchfile batch.json --jobs 4 --json /baoyu-image-gen --batchfile batch.json --jobs 4 --json
``` ```
**选项** **选项**
@@ -783,10 +835,10 @@ AI 驱动的生成后端。
| `--image` | 输出图片路径(必需) | | `--image` | 输出图片路径(必需) |
| `--batchfile` | 多图批量生成的 JSON 文件 | | `--batchfile` | 多图批量生成的 JSON 文件 |
| `--jobs` | 批量模式的并发 worker 数 | | `--jobs` | 批量模式的并发 worker 数 |
| `--provider` | `google``openai``azure``openrouter``dashscope``zai``minimax``jimeng``seedream``replicate` | | `--provider` | `google``openai``azure``openrouter``dashscope``zai``minimax``jimeng``seedream``replicate``agnes` |
| `--model`, `-m` | 模型 ID 或部署名。Azure 使用部署名;OpenRouter 使用完整模型 IDZ.AI 使用 `glm-image`MiniMax 使用 `image-01` / `image-01-live` | | `--model`, `-m` | 模型 ID 或部署名。Azure 使用部署名;OpenRouter 使用完整模型 IDZ.AI 使用 `glm-image`MiniMax 使用 `image-01` / `image-01-live` |
| `--ar` | 宽高比(如 `16:9``1:1``4:3` | | `--ar` | 宽高比(如 `16:9``1:1``4:3` |
| `--size` | 尺寸(如 `1024x1024` | | `--size` | 尺寸(如 `1024x1024``gpt-image-2` 支持最长边不超过 3840px 的有效自定义尺寸 |
| `--quality` | `normal``2k`(默认:`2k` | | `--quality` | `normal``2k`(默认:`2k` |
| `--imageSize` | Google/OpenRouter 使用的 `1K``2K``4K` | | `--imageSize` | Google/OpenRouter 使用的 `1K``2K``4K` |
| `--imageApiDialect` | OpenAI 兼容网关的图像 API 方言(`openai-native``ratio-metadata` | | `--imageApiDialect` | OpenAI 兼容网关的图像 API 方言(`openai-native``ratio-metadata` |
@@ -810,11 +862,11 @@ AI 驱动的生成后端。
| `JIMENG_ACCESS_KEY_ID` | 即梦火山引擎 Access Key | - | | `JIMENG_ACCESS_KEY_ID` | 即梦火山引擎 Access Key | - |
| `JIMENG_SECRET_ACCESS_KEY` | 即梦火山引擎 Secret Key | - | | `JIMENG_SECRET_ACCESS_KEY` | 即梦火山引擎 Secret Key | - |
| `ARK_API_KEY` | 豆包火山引擎 ARK API 密钥 | - | | `ARK_API_KEY` | 豆包火山引擎 ARK API 密钥 | - |
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-1.5` | | `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-2` |
| `AZURE_OPENAI_DEPLOYMENT` | Azure 默认部署名 | - | | `AZURE_OPENAI_DEPLOYMENT` | Azure 默认部署名 | - |
| `AZURE_OPENAI_IMAGE_MODEL` | 兼容旧配置的 Azure 部署/模型别名 | `gpt-image-1.5` | | `AZURE_OPENAI_IMAGE_MODEL` | 兼容旧配置的 Azure 部署/模型别名 | `gpt-image-2` |
| `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image-preview` | | `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image` |
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` | | `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image` |
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `qwen-image-2.0-pro` | | `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `qwen-image-2.0-pro` |
| `ZAI_IMAGE_MODEL` | Z.AI 模型 | `glm-image` | | `ZAI_IMAGE_MODEL` | Z.AI 模型 | `glm-image` |
| `BIGMODEL_IMAGE_MODEL` | Z.AI 模型向后兼容别名 | `glm-image` | | `BIGMODEL_IMAGE_MODEL` | Z.AI 模型向后兼容别名 | `glm-image` |
@@ -834,7 +886,7 @@ AI 驱动的生成后端。
| `DASHSCOPE_BASE_URL` | 自定义 DashScope 端点 | - | | `DASHSCOPE_BASE_URL` | 自定义 DashScope 端点 | - |
| `ZAI_BASE_URL` | 自定义 Z.AI 端点 | `https://api.z.ai/api/paas/v4` | | `ZAI_BASE_URL` | 自定义 Z.AI 端点 | `https://api.z.ai/api/paas/v4` |
| `BIGMODEL_BASE_URL` | Z.AI 端点向后兼容别名 | - | | `BIGMODEL_BASE_URL` | Z.AI 端点向后兼容别名 | - |
| `MINIMAX_BASE_URL` | 自定义 MiniMax 端点 | `https://api.minimax.io` | | `MINIMAX_BASE_URL` | 自定义 MiniMax 端点 | `https://api.minimaxi.com` |
| `REPLICATE_BASE_URL` | 自定义 Replicate 端点 | - | | `REPLICATE_BASE_URL` | 自定义 Replicate 端点 | - |
| `JIMENG_BASE_URL` | 自定义即梦端点 | `https://visual.volcengineapi.com` | | `JIMENG_BASE_URL` | 自定义即梦端点 | `https://visual.volcengineapi.com` |
| `JIMENG_REGION` | 即梦区域 | `cn-north-1` | | `JIMENG_REGION` | 即梦区域 | `cn-north-1` |
@@ -851,15 +903,15 @@ AI 驱动的生成后端。
- MiniMax 参考图会走 `subject_reference`,当前能力更偏角色 / 人像一致性。 - MiniMax 参考图会走 `subject_reference`,当前能力更偏角色 / 人像一致性。
- 即梦不支持参考图。 - 即梦不支持参考图。
- 豆包参考图能力仅适用于 Seedream 5.0 / 4.5 / 4.0,不适用于 Seedream 3.0。 - 豆包参考图能力仅适用于 Seedream 5.0 / 4.5 / 4.0,不适用于 Seedream 3.0。
- Replicate 默认模型改为 `google/nano-banana-2``baoyu-imagine` 目前只对 `google/nano-banana*``bytedance/seedream-4.5``bytedance/seedream-5-lite``wan-video/wan-2.7-image``wan-video/wan-2.7-image-pro` 开启本地能力识别与校验。 - Replicate 默认模型改为 `google/nano-banana-2``baoyu-image-gen` 目前只对 `google/nano-banana*``bytedance/seedream-4.5``bytedance/seedream-5-lite``wan-video/wan-2.7-image``wan-video/wan-2.7-image-pro` 开启本地能力识别与校验。
- Replicate 当前只保存单张输出图,`--n > 1` 会在本地直接报错,避免多图结果被静默丢弃。 - Replicate 当前只保存单张输出图,`--n > 1` 会在本地直接报错,避免多图结果被静默丢弃。
- Replicate 的参数能力按模型家族区分:nano-banana 走 `--quality` / `--ar`Seedream 走校验后的 `--size` / `--ar`Wan 走校验后的 `--size``--ar` 会先在本地换算成具体尺寸)。 - Replicate 的参数能力按模型家族区分:nano-banana 走 `--quality` / `--ar`Seedream 走校验后的 `--size` / `--ar`Wan 走校验后的 `--size``--ar` 会先在本地换算成具体尺寸)。
**服务商自动选择** **服务商自动选择**
1. 如果指定了 `--provider` → 使用指定的 1. 如果指定了 `--provider` → 使用指定的
2. 如果传了 `--ref` 且未指定 provider → 依次尝试 Google、OpenAI、Azure、OpenRouter、Replicate、Seedream,最后是 MiniMax 2. 如果传了 `--ref` 且未指定 provider → 依次尝试 Google、OpenAI、Azure、OpenRouter、Replicate、SeedreamMiniMax,最后是 Agnes
3. 如果只有一个 API 密钥 → 使用对应服务商 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 #### baoyu-danger-gemini-web
@@ -1112,12 +1164,80 @@ AI 驱动的生成后端。
- 为文化/专业术语添加译注 - 为文化/专业术语添加译注
- 输出目录保留所有中间文件 - 输出目录保留所有中间文件
#### baoyu-wechat-summary
微信群聊精华提取。使用 [wx-cli](https://github.com/jackwener/wx-cli) 从群消息中提取话题、引言和统计数据,生成结构化简报。支持跨次运行的群聊历史、群友画像和群级事实记忆维护,可生成正常版和毒舌版,并在简报中回应群里向 `@bot` 提出的问题。
```bash
# 总结群最近消息
/baoyu-wechat-summary 相亲相爱一家人 最近 1
# 周报
/baoyu-wechat-summary AI 技术群 最近 7
# 增量模式(从上次摘要继续)
/baoyu-wechat-summary 相亲相爱一家人
# 毒舌版
/baoyu-wechat-summary 相亲相爱一家人 最近 3 天 毒舌版
```
**前置要求**
- 安装 [wx-cli](https://github.com/jackwener/wx-cli)`npm install -g @jackwener/wx-cli`
- macOS 上运行并登录 WeChat 4.x
**特性**
- 话题提取,带归属和引言
- 发言排行榜和群友画像
- 群级事实记忆:群友指正过的事实跨期生效(内置防注入规则)
- 增量模式(从上次摘要断点继续)
- 大批量消息自动按天分割
- 正常版和毒舌版两种风格
- 支持从历史摘要回溯初始化画像
#### baoyu-electron-extract
从任意已安装的 Electron 应用的 `app.asar` 中提取资源和 JavaScript。当 `.js.map` 内嵌 `sourcesContent` 时,还原原始源码树(含 TypeScript/JSX);否则用 Prettier 原地美化压缩后的 JS/CSS。始终跳过 `node_modules`。支持 macOS 和 Windows,其他平台请用 `--asar <path>` 指定 asar 文件。
```bash
# 按应用名提取(默认输出:~/Downloads/<AppName>-electron-extract/
/baoyu-electron-extract Codex
# 按绝对路径提取(.app 包、安装目录或 .asar 文件均可)
/baoyu-electron-extract "/Applications/Visual Studio Code.app"
/baoyu-electron-extract --asar /Applications/Codex.app/Contents/Resources/app.asar Codex
# 自定义输出目录
/baoyu-electron-extract Codex --output ~/work/codex-source
# 仅预览发现的路径,不写入任何文件
/baoyu-electron-extract Codex --dry-run
# 覆盖已存在的输出目录
/baoyu-electron-extract Codex --force
```
**选项**
| 选项 | 说明 | 默认值 |
|------|------|--------|
| `<app>` | 应用名或绝对路径(未给 `--asar` 时必填) | — |
| `--output`, `-o` | 输出目录 | `~/Downloads/<AppName>-electron-extract` |
| `--asar` | 覆盖解析得到的 `.asar` 路径 | 自动发现 |
| `--force`, `-f` | 允许写入非空的已有输出目录 | false |
| `--skip-format` | 跳过 Prettier 格式化 | false |
| `--skip-restore` | 跳过 source-map 还原 | false |
| `--no-unpacked` | 不复制同级的 `app.asar.unpacked/` | false |
| `--dry-run` | 打印解析路径后退出,不写文件 | false |
| `--json` | 在 stdout 输出一行 JSON 概要 | false |
**输出结构**`extract-report.json`(计数、警告、路径),`extracted/`(asar 原始内容,无 map 时原地美化),`extracted.unpacked/`(存在时复制的原生模块),以及 `restored/`(基于 `.js.map` 重建的源码树)。
## 环境配置 ## 环境配置
部分技能需要 API 密钥或自定义配置。环境变量可以在 `.env` 文件中设置: 部分技能需要 API 密钥或自定义配置。环境变量可以在 `.env` 文件中设置:
**加载优先级**(高优先级覆盖低优先级): **加载优先级**(高优先级覆盖低优先级):
1. 命令行环境变量(如 `OPENAI_API_KEY=xxx /baoyu-imagine ...` 1. 命令行环境变量(如 `OPENAI_API_KEY=xxx /baoyu-image-gen ...`
2. `process.env`(系统环境变量) 2. `process.env`(系统环境变量)
3. `<cwd>/.baoyu-skills/.env`(项目级) 3. `<cwd>/.baoyu-skills/.env`(项目级)
4. `~/.baoyu-skills/.env`(用户级) 4. `~/.baoyu-skills/.env`(用户级)
@@ -1132,26 +1252,26 @@ mkdir -p ~/.baoyu-skills
cat > ~/.baoyu-skills/.env << 'EOF' cat > ~/.baoyu-skills/.env << 'EOF'
# OpenAI # OpenAI
OPENAI_API_KEY=sk-xxx OPENAI_API_KEY=sk-xxx
OPENAI_IMAGE_MODEL=gpt-image-1.5 OPENAI_IMAGE_MODEL=gpt-image-2
# OPENAI_BASE_URL=https://api.openai.com/v1 # OPENAI_BASE_URL=https://api.openai.com/v1
# OPENAI_IMAGE_USE_CHAT=false # OPENAI_IMAGE_USE_CHAT=false
# Azure OpenAI # Azure OpenAI
AZURE_OPENAI_API_KEY=xxx AZURE_OPENAI_API_KEY=xxx
AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com
AZURE_OPENAI_DEPLOYMENT=gpt-image-1.5 AZURE_OPENAI_DEPLOYMENT=gpt-image-2
# AZURE_API_VERSION=2025-04-01-preview # AZURE_API_VERSION=2025-04-01-preview
# OpenRouter # OpenRouter
OPENROUTER_API_KEY=sk-or-xxx 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_BASE_URL=https://openrouter.ai/api/v1
# OPENROUTER_HTTP_REFERER=https://your-app.example.com # OPENROUTER_HTTP_REFERER=https://your-app.example.com
# OPENROUTER_TITLE=你的应用名 # OPENROUTER_TITLE=你的应用名
# Google # Google
GOOGLE_API_KEY=xxx 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 # GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta
# DashScope(阿里通义万相) # DashScope(阿里通义万相)
@@ -1167,7 +1287,7 @@ ZAI_IMAGE_MODEL=glm-image
# MiniMax # MiniMax
MINIMAX_API_KEY=xxx MINIMAX_API_KEY=xxx
MINIMAX_IMAGE_MODEL=image-01 MINIMAX_IMAGE_MODEL=image-01
# MINIMAX_BASE_URL=https://api.minimax.io # MINIMAX_BASE_URL=https://api.minimaxi.com
# Replicate # Replicate
REPLICATE_API_TOKEN=r8_xxx REPLICATE_API_TOKEN=r8_xxx
@@ -1268,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 ## Star History
BIN
View File
Binary file not shown.
+272
View File
@@ -0,0 +1,272 @@
# `codex-imagegen` Backend
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 backend implements the `preferred_image_backend: codex-imagegen` config key already referenced in several `SKILL.md` files across this repo.
## Features
| Feature | Status |
|---------|--------|
| **Reliability**: retry + exponential backoff | Default 2 retries |
| **Verification**: confirms `image_gen` was actually invoked (not bypassed) | Checks `$CODEX_HOME/generated_images/{thread_id}/` |
| **Verification**: PNG magic-byte sanity check | ✓ |
| **Idempotency cache**: reuses output for same prompt+aspect+refs | `--cache-dir` |
| **Concurrency control**: file lock prevents parallel `codex exec` collisions | Built-in |
| **Structured logging**: JSONL log file | `--log-file` |
| **Token usage returned** | Embedded in result JSON |
| **`--ref` reference images** | Repeatable |
| **Unit tests** | 16 tests (parser / cache / validator) |
| **Error classification**: retryable vs non-retryable | 9 `error_kind` values |
## Why this backend
| Scenario | Conventional backend | This backend |
|----------|---------------------|--------------|
| You have a Codex subscription | OpenAI Images API costs add up per image | Subscription already covers it — zero marginal API cost |
| No `OPENAI_API_KEY` available | `baoyu-image-gen` needs an API key | `codex login` is enough |
| Want to use GPT Image 2 | Only via OpenAI API | Codex's `image_gen` *is* GPT Image 2 |
## 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. On macOS:
```bash
brew install oven-sh/bun/bun
```
If `bun` is not on `PATH`, fall back to `npx -y bun packages/baoyu-codex-imagegen/src/main.ts …`.
## Usage
### Direct CLI
```bash
# Inline prompt
bun packages/baoyu-codex-imagegen/src/main.ts \
--image /tmp/cat.png \
--prompt "A friendly orange cat, watercolor"
# Prompt from file
bun packages/baoyu-codex-imagegen/src/main.ts \
--image cover.png \
--prompt-file prompts/01-cover.md \
--aspect 16:9
# Verbose mode for debugging
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
{"status":"ok","path":"/tmp/cat.png","bytes":2567101,"elapsed_seconds":53}
```
On failure, exit code is non-zero and stderr contains the error message.
### Enabling within image skills
Image-generating skills (e.g., `baoyu-cover-image`, `baoyu-article-illustrator`) already support a `preferred_image_backend` preference. To route them through this backend, set the following in the corresponding `EXTEND.md`:
```yaml
# ~/.baoyu-skills/baoyu-cover-image/EXTEND.md
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 `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.
## Parameters
| Flag | Required | Description |
|------|----------|-------------|
| `--image <path>` | ✓ | Output PNG path (absolute recommended; relative paths are resolved against cwd) |
| `--prompt <text>` | one of | Prompt string (mutually exclusive with `--prompt-file`) |
| `--prompt-file <path>` | one of | Read prompt from file (mutually exclusive with `--prompt`) |
| `--aspect <ratio>` | | Aspect ratio. Default `1:1`. Common: `16:9`, `9:16`, `4:3`, `2.35:1` |
| `--ref <file>` | | Reference image path (repeatable) |
| `--timeout <ms>` | | `codex exec` timeout in ms. Default `300000` |
| `--retries <n>` | | Retry count on retryable errors. Default `2` (total attempts = retries + 1) |
| `--retry-delay <ms>` | | Base delay between retries (exponential backoff). Default `1500` |
| `--cache-dir <path>` | | Enable idempotency cache (reuses output for same prompt+aspect+refs) |
| `--log-file <path>` | | Structured JSONL log path (appended) |
| `-v` / `--verbose` | | Mirror log entries to stderr |
| `-h` / `--help` | | Show usage |
## Structured Output
On success, stdout contains a single JSON line:
```json
{
"status": "ok",
"path": "/tmp/owl.png",
"bytes": 1693831,
"elapsed_seconds": 87,
"thread_id": "019e40e8-daef-7c60-943d-5e7bb3f6cb3d",
"attempts": 1,
"cached": false,
"usage": {
"input": 110899,
"cached_input": 83456,
"output": 457,
"reasoning": 47
},
"tool_calls": [
{"tool": "shell", "status": "completed"},
{"tool": "agent_message", "status": "completed"}
]
}
```
Cache hits return with `elapsed_seconds: 0`, `cached: true`, `attempts: 0`.
On failure, exit code is `1` and the JSON contains `error` and `error_kind`:
```json
{
"status": "error",
"error": "image_gen was not invoked: no PNG in ...",
"error_kind": "no_image_gen_tool_use"
}
```
## Error Kinds
| `error_kind` | Retryable | Meaning |
|--------------|-----------|---------|
| `codex_not_installed` | ✗ | `codex` CLI not found |
| `invalid_args` | ✗ | Argument parsing error |
| `prompt_file_missing` | ✗ | `--prompt-file` path does not exist |
| `spawn_failed` | ✓ | `codex exec` exited non-zero |
| `timeout` | ✓ | Exceeded `--timeout` |
| `no_image_gen_tool_use` | ✓ | Agent did not invoke `image_gen` (it took another path) |
| `output_missing` | ✓ | Output file not created |
| `invalid_png` | ✓ | Output is not a valid PNG |
| `agent_refused` | ✓ | No `thread_id` in event stream (Codex refused to respond) |
| `lock_busy` | ✗ | Concurrency lock acquisition timed out |
## Measured Performance
| Metric | Value |
|--------|-------|
| First-run latency | 5090 s |
| Cache-hit latency | < 0.3 s |
| Output dimensions | 1024×1024, 1672×941 (16:9), etc. — chosen by `image_gen` |
| Output format | PNG (RGB, 8-bit) |
| Token usage per call | ~110k input (~80k cached) + ~500 output |
| Quota source | Codex subscription (does not consume OpenAI API quota) |
| Default timeout | 300 s (5 min) |
## Limitations & Risks
1. **510× slower than direct API**. `codex exec` cold-starts the agent, loads the built-in `image_gen` SKILL.md, and runs reasoning before invoking the tool. Cache hits avoid this for repeated prompts.
2. **ToS gray area**. Codex's `image_gen` tool is designed for interactive use. Invoking it programmatically via `codex exec` from an external agent is not explicitly addressed by current OpenAI policies. Suggested guardrails:
- Personal, low-volume use is reasonable.
- Not recommended for production automation or high-volume batch jobs.
- Users are responsible for ensuring their usage complies with applicable terms of service.
3. **Sandbox permissions**. The wrapper passes `--sandbox danger-full-access` so the spawned agent can move the rendered PNG out of `$CODEX_HOME/generated_images/`. This is necessary because the agent must `cp`/`mv` the file to the user-specified output path.
4. **Concurrency = 1**. The file lock serializes concurrent invocations to avoid `codex exec` collisions. Parallel calls queue.
## Troubleshooting
| Symptom | `error_kind` | Resolution |
|---------|--------------|------------|
| `command not found: codex` | `codex_not_installed` | `npm install -g @openai/codex` |
| `codex exec` fails | `spawn_failed` | Check `codex login` status; inspect `raw_log` path |
| Timeout | `timeout` | Pass `--timeout 600000` (10 min) for slow networks |
| Agent skipped `image_gen` | `no_image_gen_tool_use` | Auto-retries; consider sharpening the prompt — abstract prompts let the agent wander |
| Output missing | `output_missing` | Agent did not `cp` to the target path; check `raw_log` for the actual save location under `generated_images/` |
| Lock held | `lock_busy` | Wait for the in-flight request to finish; or `rm ~/.cache/baoyu-codex-imagegen/codex-exec.lock` |
| Low image quality | — | Sharpen the prompt, try a different aspect, or supply `--ref` |
## Architecture
```
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 packages/baoyu-codex-imagegen && bun test
```
## Internal Flow
```mermaid
flowchart LR
CC[Claude Code / any caller]
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]
DEFAULT["$CODEX_HOME/<br/>generated_images/{thread_id}/"]
OUT[/specified OUTPUT path/]
CC -->|exec wrapper| WRAPPER
WRAPPER -->|stdin: instruction| CODEX
CODEX --> AGENT
AGENT -->|tool call| TOOL
TOOL -->|writes file| DEFAULT
AGENT -->|agent cp/mv| OUT
WRAPPER -->|verify + parse| CC
classDef cc fill:#1e40af,color:#fff,stroke:#93c5fd
classDef cdx fill:#7c2d12,color:#fff,stroke:#fdba74
class CC,WRAPPER cc
class CODEX,AGENT,TOOL cdx
```
## Design Decisions
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. **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 |
|------|------|
| `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 |
+39 -1
View File
@@ -127,7 +127,7 @@ test -f "$HOME/.baoyu-skills/<skill-name>/EXTEND.md" && echo "user"
| Result | Action | | Result | Action |
|--------|--------| |--------|--------|
| Found | Read, parse, display summary | | Found | Read, parse, display summary |
| Not found | Ask user with AskUserQuestion | | Not found | Ask user via the runtime's user-input tool (see [user-input-tools.md](user-input-tools.md)) |
``` ```
End of SKILL.md should include: End of SKILL.md should include:
@@ -135,3 +135,41 @@ End of SKILL.md should include:
## Extension Support ## Extension Support
Custom configurations via EXTEND.md. See **Step 1.1** for paths and supported options. Custom configurations via EXTEND.md. See **Step 1.1** for paths and supported options.
``` ```
## User Input Tools Section (Required)
Every SKILL.md that prompts the user for choices MUST include exactly one `## User Input Tools` section near the top (right after the intro, before the main workflow). The rule must be **inlined** — do NOT link to `docs/user-input-tools.md` (skills are self-contained; see [CLAUDE.md → Skill Self-Containment](../CLAUDE.md)). The author-side canonical reference lives at [user-input-tools.md](user-input-tools.md); copy its body into each new SKILL.md.
Standard snippet (copy verbatim):
```markdown
## User Input Tools
When this skill prompts the user, follow this tool-selection rule (priority order):
1. **Prefer built-in user-input tools** exposed by the current agent runtime — e.g., `AskUserQuestion`, `request_user_input`, `clarify`, `ask_user`, or any equivalent.
2. **Fallback**: if no such tool exists, emit a numbered plain-text message and ask the user to reply with the chosen number/answer for each question.
3. **Batching**: if the tool supports multiple questions per call, combine all applicable questions into a single call; if only single-question, ask them one at a time in priority order.
Concrete `AskUserQuestion` references below are examples — substitute the local equivalent in other runtimes.
```
## Image Generation Tools Section (Required for image-gen skills)
Every SKILL.md that renders images — whether by calling an image-generation API directly or by delegating to another skill — MUST include exactly one `## Image Generation Tools` section near the top (after `## User Input Tools`, before the main workflow). The rule must be **inlined** — do NOT link to `docs/image-generation-tools.md` (skills are self-contained; see [CLAUDE.md → Skill Self-Containment](../CLAUDE.md)). The author-side canonical reference lives at [image-generation-tools.md](image-generation-tools.md); copy its body into each new SKILL.md.
Standard snippet (copy verbatim):
```markdown
## Image Generation Tools
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`, 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`, `GenerateImage`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
```
+49
View File
@@ -0,0 +1,49 @@
# Image Generation Tools
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
When a skill needs to render an image, resolve the backend in this order:
1. **Current-request override** — if the user names a specific backend in the current message, use it.
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.
4. **If none are available**, tell the user and ask how to proceed.
**⛔ Never substitute SVG, HTML, canvas, or other code-based rendering for raster image generation.** Codex `imagegen`'s own description says it should be used "when the output should be a bitmap asset rather than repo-native code or vector." If you cannot resolve a raster backend via step 3, fall through to step 4 and ask the user — do **not** silently emit SVG, write inline `<svg>` markup, or produce HTML/CSS art as a substitute. This applies even if the article/section seems "diagram-like": the consumer skill calling this rule has already decided that a raster image is what it needs.
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends.
## The Preference Field
Each image-consuming skill's `EXTEND.md` carries a single `preferred_image_backend` field:
| Value | Meaning |
|---|---|
| `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`, `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.
## Prompt File Requirement (hard)
Regardless of which backend is chosen, every skill that renders images MUST 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 allows switching backends without regenerating prompts.
## How Skills Declare This
Each `SKILL.md` that renders images includes **exactly one** `## Image Generation Tools` section (near the top, after `## User Input Tools` and before the main workflow) that **inlines** this rule. Skills are self-contained and cannot link to `docs/` — each skill folder must ship the rule inside its own `SKILL.md`. See [CLAUDE.md → Skill Self-Containment](../CLAUDE.md).
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`, `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
Skills that **are themselves** image-generation backends — currently `baoyu-image-gen`, `baoyu-image-gen` (deprecated), and `baoyu-danger-gemini-web` — do NOT include a `## Image Generation Tools` section. They render directly via their own provider integrations and have no need to "select a backend." The rule applies only to consumer skills that delegate rendering to whatever backend the runtime exposes.
+16 -15
View File
@@ -1,33 +1,34 @@
# Image Generation Guidelines # Image Generation Guidelines
Skills that require image generation MUST delegate to available image generation skills. Skills that require image generation MUST delegate to available image generation tools (runtime-native tools or installed skills).
**Backend selection convention**: see [image-generation-tools.md](image-generation-tools.md) for the runtime-neutral rule. Short version: use whatever backend is available; if multiple, ask the user once; if none, ask how to proceed. This document covers output conventions (naming, paths) that apply regardless of which backend is selected.
## Skill Selection ## Skill Selection
**Default**: `skills/baoyu-imagine/SKILL.md` (unless user specifies otherwise). 1. Follow the rule in [image-generation-tools.md](image-generation-tools.md): use whatever backend is available; ask only on ambiguity.
2. Read the chosen backend's documentation for parameters and capabilities.
1. Read skill's SKILL.md for parameters and capabilities 3. If user requests a specific backend, honor it.
2. If user requests different skill, check `skills/` for alternatives
3. Only ask user when multiple viable options exist
## Generation Flow Template ## Generation Flow Template
```markdown ```markdown
### Step N: Generate Images ### Step N: Generate Images
**Skill Selection**: **Backend Selection**:
1. Check available skills (`baoyu-imagine` default, or `baoyu-danger-gemini-web`) 1. Detect available image-generation tools/skills (runtime-native + installed)
2. Read selected skill's SKILL.md for parameters 2. If one available → use it. If multiple → ask user once. If none → ask how to proceed.
3. If multiple skills available, ask user to choose 3. Read the chosen backend's docs for parameters
**Generation Flow**: **Generation Flow**:
1. Call skill with prompt, output path, and skill-specific parameters 1. Write the full prompt to `prompts/NN-{type}-[slug].md` BEFORE invoking the backend
2. Generate sequentially by default (batch parallel only when user has multiple prompts) 2. Call backend with the prompt (or prompt file), output path, and parameters
3. Output progress: "Generated X/N" 3. Generate sequentially by default (batch parallel only when backend supports it and user has multiple prompts)
4. On failure, auto-retry once before reporting error 4. Output progress: "Generated X/N"
5. On failure, auto-retry once before reporting error
``` ```
**Batch Parallel** (`baoyu-imagine` only): concurrent workers with per-provider throttling via `batch.max_workers` in EXTEND.md. **Batch Parallel** (`baoyu-image-gen` only): concurrent workers with per-provider throttling via `batch.max_workers` in EXTEND.md.
## Output Path Convention ## Output Path Convention
+14 -7
View File
@@ -21,21 +21,28 @@ bash scripts/sync-clawhub.sh # sync all skills
bash scripts/sync-clawhub.sh <skill> # sync one skill bash scripts/sync-clawhub.sh <skill> # sync one skill
``` ```
Release hooks are configured via `.releaserc.yml`. This repo does not stage a separate release directory: release prep only syncs `packages/` into each skill's committed `scripts/vendor/`, and publish reads the skill directory directly. Release hooks are configured via `.releaserc.yml`. This repo does not stage a separate release directory: publish reads the skill directory directly and validates that local package references and CLI bin targets are self-contained.
Every skill release must keep the `version:` in that skill's `SKILL.md` aligned with the version being published. `publish-skill.mjs` and `sync-clawhub.mjs` both reject mismatches so a registry payload cannot ship with stale skill metadata.
Commits that touch `skills/<name>/**` must use Conventional Commit subjects, for example `fix(baoyu-post-to-wechat): handle WeChat editor focus`. CI runs `npm run verify:skill-release-commits` against the pushed or PR commit range so bare subjects like `Fix WeChat browser article publishing` cannot bypass per-skill release versioning silently.
## Shared Workspace Packages ## Shared Workspace Packages
`packages/` is the **only** source of truth. Never edit `skills/*/scripts/vendor/` directly. `packages/` is the source of truth for shared runtime code. Most skills consume shared packages from npm with semver ranges. `baoyu-url-to-markdown` is the exception: it vendors the `baoyu-fetch` runtime into `skills/baoyu-url-to-markdown/scripts/lib/` so the published skill is self-contained and does not depend on the `baoyu-fetch` npm package.
Current packages: Current packages:
- `baoyu-chrome-cdp` (Chrome CDP utilities), consumed by 6 skills (`baoyu-danger-gemini-web`, `baoyu-danger-x-to-markdown`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-post-to-x`, `baoyu-url-to-markdown`) - `baoyu-chrome-cdp` (Chrome CDP utilities), consumed by 5 skills (`baoyu-danger-gemini-web`, `baoyu-danger-x-to-markdown`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-post-to-x`)
- `baoyu-md` (shared Markdown rendering and placeholder pipeline), consumed by 3 skills (`baoyu-markdown-to-html`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`) - `baoyu-md` (shared Markdown rendering and placeholder pipeline), consumed by 3 skills (`baoyu-markdown-to-html`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`)
- `baoyu-fetch` (URL-to-Markdown CLI), vendored into 1 skill (`baoyu-url-to-markdown`)
**How it works**: Sync script copies packages into each consuming skill's `vendor/` directory and rewrites dependency specs to `file:./vendor/<name>`. Vendor copies are committed to git, making skills self-contained. **How it works**: npm packages are built from `packages/` and published to the public npm registry. Skills normally depend on those packages with `^<version>` specs. Release prep runs `node scripts/verify-shared-package-deps.mjs` so accidental `file:` dependencies cannot slip back in. For vendored skill runtimes, keep the copied code under the skill directory and run `node scripts/publish-skill.mjs --skill-dir <skill> --version <version> --dry-run` before publishing.
**Update workflow**: **Update workflow**:
1. Edit package under `packages/` 1. Edit package under `packages/`
2. Run `node scripts/sync-shared-skill-packages.mjs` 2. Run the package build, e.g. `bun run --cwd packages/baoyu-md build`
3. Commit synced `vendor/`, `package.json`, and `bun.lock` together 3. Publish the changed npm package with `npm publish --access public`
4. Update consuming skill `package.json` semver ranges if the package version changed
5. Run `node scripts/verify-shared-package-deps.mjs`
**Git hook**: Run `node scripts/install-git-hooks.mjs` once to enable the `pre-push` hook. It re-syncs and blocks push if vendor copies are stale (`--enforce-clean`). **Git hook**: Run `node scripts/install-git-hooks.mjs` once to enable the `pre-push` hook. It blocks pushes when a skill uses a local `file:` dependency or a vendored workspace package.
+4 -4
View File
@@ -9,7 +9,7 @@ This repository has many scripts, but they do not share a single runtime or depe
- Coverage command: `npm run test:coverage` - Coverage command: `npm run test:coverage`
- CI trigger: GitHub Actions on `push`, `pull_request`, and manual dispatch - CI trigger: GitHub Actions on `push`, `pull_request`, and manual dispatch
This avoids introducing Jest/Vitest across a repo that already mixes plain Node scripts, Bun-based skill packages, vendored code, and browser automation. This avoids introducing Jest/Vitest across a repo that already mixes plain Node scripts, Bun-based skill packages, npm-published shared packages, and browser automation.
## Rollout Plan ## Rollout Plan
@@ -18,19 +18,19 @@ This avoids introducing Jest/Vitest across a repo that already mixes plain Node
Focus on pure functions under `scripts/lib/` first. Focus on pure functions under `scripts/lib/` first.
- `scripts/lib/release-files.mjs` - `scripts/lib/release-files.mjs`
- `scripts/lib/shared-skill-packages.mjs` - `scripts/verify-shared-package-deps.mjs`
Goals: Goals:
- Validate file filtering and release packaging rules - Validate file filtering and release packaging rules
- Catch regressions in package vendoring and dependency rewriting - Catch regressions that reintroduce local `file:` dependencies or vendored workspace packages
- Keep tests deterministic and free of network, Bun, or browser requirements - Keep tests deterministic and free of network, Bun, or browser requirements
### Phase 2: Root CLI integration tests ### Phase 2: Root CLI integration tests
Add temp-directory integration tests for root CLIs that already support dry-run or local-only flows. Add temp-directory integration tests for root CLIs that already support dry-run or local-only flows.
- `scripts/sync-shared-skill-packages.mjs` - `scripts/verify-shared-package-deps.mjs`
- `scripts/publish-skill.mjs --dry-run` - `scripts/publish-skill.mjs --dry-run`
- `scripts/sync-clawhub.mjs` argument handling and local skill discovery - `scripts/sync-clawhub.mjs` argument handling and local skill discovery
+17
View File
@@ -0,0 +1,17 @@
# User Input Tools
Skills in this repo are loaded by multiple agent runtimes (Claude Code, other agents, bare CLI). Each runtime exposes a different API for asking the user questions. This document defines the canonical **tool-selection rule** every skill follows so skills stay portable.
## Tool Selection (priority order)
1. **Prefer built-in user-input tools** if the current agent runtime exposes one — e.g., `AskUserQuestion`, `request_user_input`, `clarify`, `ask_user`, or any equivalent.
2. **Fallback to plain text**: if no such tool exists, emit a numbered plain-text message and ask the user to reply with the chosen number/answer for each question.
3. **Batching rule**:
- If the tool supports **multiple questions per call** (e.g., `AskUserQuestion`): **Combine all applicable questions into a single call. Do NOT split into separate calls.**
- If the tool supports **only one question per call** (e.g., single-prompt `clarify`): ask **one question per call, in priority order**.
## How Skills Declare This
Each `SKILL.md` that uses interactive user input includes **exactly one** `## User Input Tools` section (typically near the top, right after the intro) that **inlines** this rule. Do NOT link here from a SKILL.md — skills are self-contained (see [CLAUDE.md → Skill Self-Containment](../CLAUDE.md)). This document is the author-side canonical source; copy its body into each SKILL.md. The rule then governs every user-input interaction in that skill and its `references/` files.
Specific mentions of a concrete tool (e.g., `AskUserQuestion`) elsewhere in a skill are **concrete examples** — agents in other runtimes apply the rule above and substitute the local equivalent. Tool-specific parameters (e.g., `header:`, `multiSelect:`) are illustrative; runtimes without those knobs can omit them.
+900 -164
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,13 +1,15 @@
{ {
"name": "baoyu-skills", "name": "baoyu-skills",
"private": true, "private": true,
"license": "MIT",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
"scripts": { "scripts": {
"test": "node ./scripts/run-node-tests.mjs", "test": "node ./scripts/run-node-tests.mjs",
"test:coverage": "node ./scripts/run-node-tests.mjs --experimental-test-coverage" "test:coverage": "node ./scripts/run-node-tests.mjs --experimental-test-coverage",
"verify:skill-release-commits": "node ./scripts/verify-skill-release-commits.mjs"
}, },
"devDependencies": { "devDependencies": {
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
@@ -20,5 +22,8 @@
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pptxgenjs": "^4.0.1", "pptxgenjs": "^4.0.1",
"sharp": "^0.34.5" "sharp": "^0.34.5"
},
"overrides": {
"@xmldom/xmldom": "0.8.13"
} }
} }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+479
View File
@@ -0,0 +1,479 @@
var __create = Object.create;
var __getProtoOf = Object.getPrototypeOf;
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __hasOwnProp = Object.prototype.hasOwnProperty;
function __accessProp(key) {
return this[key];
}
var __toESMCache_node;
var __toESMCache_esm;
var __toESM = (mod, isNodeMode, target) => {
var canCache = mod != null && typeof mod === "object";
if (canCache) {
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
var cached = cache.get(mod);
if (cached)
return cached;
}
target = mod != null ? __create(__getProtoOf(mod)) : {};
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
for (let key of __getOwnPropNames(mod))
if (!__hasOwnProp.call(to, key))
__defProp(to, key, {
get: __accessProp.bind(mod, key),
enumerable: true
});
if (canCache)
cache.set(mod, to);
return to;
};
var __toCommonJS = (from) => {
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
if (entry)
return entry;
entry = __defProp({}, "__esModule", { value: true });
if (from && typeof from === "object" || typeof from === "function") {
for (var key of __getOwnPropNames(from))
if (!__hasOwnProp.call(entry, key))
__defProp(entry, key, {
get: __accessProp.bind(from, key),
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
});
}
__moduleCache.set(from, entry);
return entry;
};
var __moduleCache;
var __returnValue = (v) => v;
function __exportSetter(name, newValue) {
this[name] = __returnValue.bind(null, newValue);
}
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: __exportSetter.bind(all, name)
});
};
// src/index.ts
var exports_src = {};
__export(exports_src, {
waitForChromeDebugPort: () => waitForChromeDebugPort,
sleep: () => sleep,
resolveSharedChromeProfileDir: () => resolveSharedChromeProfileDir,
openPageSession: () => openPageSession,
launchChrome: () => launchChrome,
killChrome: () => killChrome,
gracefulKillChrome: () => gracefulKillChrome,
getFreePort: () => getFreePort,
getDefaultChromeUserDataDirs: () => getDefaultChromeUserDataDirs,
findExistingChromeDebugPort: () => findExistingChromeDebugPort,
findChromeExecutable: () => findChromeExecutable,
discoverRunningChromeDebugPort: () => discoverRunningChromeDebugPort,
CdpConnection: () => CdpConnection
});
module.exports = __toCommonJS(exports_src);
var import_node_child_process = require("node:child_process");
var import_node_fs = __toESM(require("node:fs"));
var import_node_net = __toESM(require("node:net"));
var import_node_os = __toESM(require("node:os"));
var import_node_path = __toESM(require("node:path"));
var import_node_process = __toESM(require("node:process"));
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFreePort(fixedEnvName) {
const fixed = fixedEnvName ? Number.parseInt(import_node_process.default.env[fixedEnvName] ?? "", 10) : NaN;
if (Number.isInteger(fixed) && fixed > 0)
return fixed;
return await new Promise((resolve, reject) => {
const server = import_node_net.default.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
return;
}
const port = address.port;
server.close((err) => {
if (err)
reject(err);
else
resolve(port);
});
});
});
}
function findChromeExecutable(options) {
for (const envName of options.envNames ?? []) {
const override = import_node_process.default.env[envName]?.trim();
if (override && import_node_fs.default.existsSync(override))
return override;
}
const candidates = import_node_process.default.platform === "darwin" ? options.candidates.darwin ?? options.candidates.default : import_node_process.default.platform === "win32" ? options.candidates.win32 ?? options.candidates.default : options.candidates.default;
for (const candidate of candidates) {
if (import_node_fs.default.existsSync(candidate))
return candidate;
}
return;
}
function resolveSharedChromeProfileDir(options = {}) {
for (const envName of options.envNames ?? []) {
const override = import_node_process.default.env[envName]?.trim();
if (override)
return import_node_path.default.resolve(override);
}
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
const profileDirName = options.profileDirName ?? "chrome-profile";
if (options.wslWindowsHome) {
return import_node_path.default.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
}
const base = import_node_process.default.platform === "darwin" ? import_node_path.default.join(import_node_os.default.homedir(), "Library", "Application Support") : import_node_process.default.platform === "win32" ? import_node_process.default.env.APPDATA ?? import_node_path.default.join(import_node_os.default.homedir(), "AppData", "Roaming") : import_node_process.default.env.XDG_DATA_HOME ?? import_node_path.default.join(import_node_os.default.homedir(), ".local", "share");
return import_node_path.default.join(base, appDataDirName, profileDirName);
}
async function fetchWithTimeout(url, timeoutMs) {
if (!timeoutMs || timeoutMs <= 0)
return await fetch(url, { redirect: "follow" });
const ctl = new AbortController;
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
return await fetch(url, { redirect: "follow", signal: ctl.signal });
} finally {
clearTimeout(timer);
}
}
async function fetchJson(url, options = {}) {
const response = await fetchWithTimeout(url, options.timeoutMs);
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
return await response.json();
}
async function isDebugPortReady(port, timeoutMs = 3000) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
return !!version.webSocketDebuggerUrl;
} catch {
return false;
}
}
function isPortListening(port, timeoutMs = 3000) {
return new Promise((resolve) => {
const socket = new import_node_net.default.Socket;
const timer = setTimeout(() => {
socket.destroy();
resolve(false);
}, timeoutMs);
socket.once("connect", () => {
clearTimeout(timer);
socket.destroy();
resolve(true);
});
socket.once("error", () => {
clearTimeout(timer);
resolve(false);
});
socket.connect(port, "127.0.0.1");
});
}
function parseDevToolsActivePort(filePath) {
try {
const content = import_node_fs.default.readFileSync(filePath, "utf-8");
const lines = content.split(/\r?\n/);
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
const wsPath = lines[1]?.trim();
if (port > 0 && wsPath)
return { port, wsPath };
} catch {}
return null;
}
async function findExistingChromeDebugPort(options) {
const timeoutMs = options.timeoutMs ?? 3000;
const parsed = parseDevToolsActivePort(import_node_path.default.join(options.profileDir, "DevToolsActivePort"));
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs))
return parsed.port;
if (import_node_process.default.platform === "win32")
return null;
try {
const result = import_node_child_process.spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5000 });
if (result.status !== 0 || !result.stdout)
return null;
const lines = result.stdout.split(`
`).filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
for (const line of lines) {
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs))
return port;
}
} catch {}
return null;
}
function getDefaultChromeUserDataDirs(channels = ["stable"]) {
const home = import_node_os.default.homedir();
const dirs = [];
const channelDirs = {
stable: {
darwin: import_node_path.default.join(home, "Library", "Application Support", "Google", "Chrome"),
linux: import_node_path.default.join(home, ".config", "google-chrome"),
win32: import_node_path.default.join(import_node_process.default.env.LOCALAPPDATA ?? import_node_path.default.join(home, "AppData", "Local"), "Google", "Chrome", "User Data")
},
beta: {
darwin: import_node_path.default.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
linux: import_node_path.default.join(home, ".config", "google-chrome-beta"),
win32: import_node_path.default.join(import_node_process.default.env.LOCALAPPDATA ?? import_node_path.default.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data")
},
canary: {
darwin: import_node_path.default.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
linux: import_node_path.default.join(home, ".config", "google-chrome-canary"),
win32: import_node_path.default.join(import_node_process.default.env.LOCALAPPDATA ?? import_node_path.default.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data")
},
dev: {
darwin: import_node_path.default.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
linux: import_node_path.default.join(home, ".config", "google-chrome-dev"),
win32: import_node_path.default.join(import_node_process.default.env.LOCALAPPDATA ?? import_node_path.default.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data")
}
};
const platform = import_node_process.default.platform === "darwin" ? "darwin" : import_node_process.default.platform === "win32" ? "win32" : "linux";
for (const ch of channels) {
const entry = channelDirs[ch];
if (entry)
dirs.push(entry[platform]);
}
return dirs;
}
async function discoverRunningChromeDebugPort(options = {}) {
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
const timeoutMs = options.timeoutMs ?? 3000;
const userDataDirs = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels)).map((dir) => import_node_path.default.resolve(dir));
for (const dir of userDataDirs) {
const parsed = parseDevToolsActivePort(import_node_path.default.join(dir, "DevToolsActivePort"));
if (!parsed)
continue;
if (await isPortListening(parsed.port, timeoutMs)) {
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
}
}
if (import_node_process.default.platform !== "win32") {
try {
const result = import_node_child_process.spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5000 });
if (result.status === 0 && result.stdout) {
const lines = result.stdout.split(`
`).filter((line) => line.includes("--remote-debugging-port=") && userDataDirs.some((dir) => line.includes(dir)));
for (const line of lines) {
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
if (version.webSocketDebuggerUrl)
return { port, wsUrl: version.webSocketDebuggerUrl };
} catch {}
}
}
}
} catch {}
}
return null;
}
async function waitForChromeDebugPort(port, timeoutMs, options) {
const start = Date.now();
let lastError = null;
while (Date.now() - start < timeoutMs) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5000 });
if (version.webSocketDebuggerUrl)
return version.webSocketDebuggerUrl;
lastError = new Error("Missing webSocketDebuggerUrl");
} catch (error) {
lastError = error;
}
await sleep(200);
}
if (options?.includeLastError && lastError) {
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
throw new Error("Chrome debug port not ready");
}
class CdpConnection {
ws;
nextId = 0;
pending = new Map;
eventHandlers = new Map;
defaultTimeoutMs;
constructor(ws, defaultTimeoutMs = 15000) {
this.ws = ws;
this.defaultTimeoutMs = defaultTimeoutMs;
this.ws.addEventListener("message", (event) => {
try {
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
const msg = JSON.parse(data);
if (msg.method) {
const handlers = this.eventHandlers.get(msg.method);
if (handlers) {
handlers.forEach((handler) => handler(msg.params));
}
}
if (msg.id) {
const pending = this.pending.get(msg.id);
if (pending) {
this.pending.delete(msg.id);
if (pending.timer)
clearTimeout(pending.timer);
if (msg.error?.message)
pending.reject(new Error(msg.error.message));
else
pending.resolve(msg.result);
}
}
} catch {}
});
this.ws.addEventListener("close", () => {
for (const [id, pending] of this.pending.entries()) {
this.pending.delete(id);
if (pending.timer)
clearTimeout(pending.timer);
pending.reject(new Error("CDP connection closed."));
}
});
}
static async connect(url, timeoutMs, options) {
const ws = new WebSocket(url);
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
ws.addEventListener("open", () => {
clearTimeout(timer);
resolve();
});
ws.addEventListener("error", () => {
clearTimeout(timer);
reject(new Error("CDP connection failed."));
});
});
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15000);
}
on(method, handler) {
if (!this.eventHandlers.has(method)) {
this.eventHandlers.set(method, new Set);
}
this.eventHandlers.get(method)?.add(handler);
}
off(method, handler) {
this.eventHandlers.get(method)?.delete(handler);
}
async send(method, params, options) {
const id = ++this.nextId;
const message = { id, method };
if (params)
message.params = params;
if (options?.sessionId)
message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
const result = await new Promise((resolve, reject) => {
const timer = timeoutMs > 0 ? setTimeout(() => {
this.pending.delete(id);
reject(new Error(`CDP timeout: ${method}`));
}, timeoutMs) : null;
this.pending.set(id, { resolve, reject, timer });
this.ws.send(JSON.stringify(message));
});
return result;
}
close() {
try {
this.ws.close();
} catch {}
}
}
async function launchChrome(options) {
await import_node_fs.default.promises.mkdir(options.profileDir, { recursive: true });
const args = [
`--remote-debugging-port=${options.port}`,
`--user-data-dir=${options.profileDir}`,
"--no-first-run",
"--no-default-browser-check",
...options.extraArgs ?? []
];
if (options.headless)
args.push("--headless=new");
if (options.url)
args.push(options.url);
return import_node_child_process.spawn(options.chromePath, args, { stdio: "ignore" });
}
function killChrome(chrome) {
try {
chrome.kill("SIGTERM");
} catch {}
setTimeout(() => {
if (chrome.exitCode === null && chrome.signalCode === null) {
try {
chrome.kill("SIGKILL");
} catch {}
}
}, 2000).unref?.();
}
async function gracefulKillChrome(chrome, port, timeoutMs = 6000) {
if (chrome.exitCode !== null || chrome.signalCode !== null)
return;
const exitPromise = new Promise((resolve) => {
chrome.once("exit", () => resolve());
});
killChrome(chrome);
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (chrome.exitCode !== null || chrome.signalCode !== null)
return;
if (port !== undefined && !await isPortListening(port, 250))
return;
const exited = await Promise.race([
exitPromise.then(() => true),
sleep(100).then(() => false)
]);
if (exited)
return;
}
await Promise.race([
exitPromise,
sleep(250)
]);
}
async function openPageSession(options) {
let targetId;
let createdTarget = false;
if (options.reusing) {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
} else {
const targets = await options.cdp.send("Target.getTargets");
const existing = targets.targetInfos.find(options.matchTarget);
if (existing) {
targetId = existing.targetId;
} else {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
}
}
const { sessionId } = await options.cdp.send("Target.attachToTarget", { targetId, flatten: true });
if (options.activateTarget ?? true) {
await options.cdp.send("Target.activateTarget", { targetId });
}
if (options.enablePage)
await options.cdp.send("Page.enable", {}, { sessionId });
if (options.enableRuntime)
await options.cdp.send("Runtime.enable", {}, { sessionId });
if (options.enableDom)
await options.cdp.send("DOM.enable", {}, { sessionId });
if (options.enableNetwork)
await options.cdp.send("Network.enable", {}, { sessionId });
return { sessionId, targetId, createdTarget };
}
+415
View File
@@ -0,0 +1,415 @@
// src/index.ts
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFreePort(fixedEnvName) {
const fixed = fixedEnvName ? Number.parseInt(process.env[fixedEnvName] ?? "", 10) : NaN;
if (Number.isInteger(fixed) && fixed > 0)
return fixed;
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
return;
}
const port = address.port;
server.close((err) => {
if (err)
reject(err);
else
resolve(port);
});
});
});
}
function findChromeExecutable(options) {
for (const envName of options.envNames ?? []) {
const override = process.env[envName]?.trim();
if (override && fs.existsSync(override))
return override;
}
const candidates = process.platform === "darwin" ? options.candidates.darwin ?? options.candidates.default : process.platform === "win32" ? options.candidates.win32 ?? options.candidates.default : options.candidates.default;
for (const candidate of candidates) {
if (fs.existsSync(candidate))
return candidate;
}
return;
}
function resolveSharedChromeProfileDir(options = {}) {
for (const envName of options.envNames ?? []) {
const override = process.env[envName]?.trim();
if (override)
return path.resolve(override);
}
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
const profileDirName = options.profileDirName ?? "chrome-profile";
if (options.wslWindowsHome) {
return path.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
}
const base = process.platform === "darwin" ? path.join(os.homedir(), "Library", "Application Support") : process.platform === "win32" ? process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming") : process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
return path.join(base, appDataDirName, profileDirName);
}
async function fetchWithTimeout(url, timeoutMs) {
if (!timeoutMs || timeoutMs <= 0)
return await fetch(url, { redirect: "follow" });
const ctl = new AbortController;
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
return await fetch(url, { redirect: "follow", signal: ctl.signal });
} finally {
clearTimeout(timer);
}
}
async function fetchJson(url, options = {}) {
const response = await fetchWithTimeout(url, options.timeoutMs);
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
return await response.json();
}
async function isDebugPortReady(port, timeoutMs = 3000) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
return !!version.webSocketDebuggerUrl;
} catch {
return false;
}
}
function isPortListening(port, timeoutMs = 3000) {
return new Promise((resolve) => {
const socket = new net.Socket;
const timer = setTimeout(() => {
socket.destroy();
resolve(false);
}, timeoutMs);
socket.once("connect", () => {
clearTimeout(timer);
socket.destroy();
resolve(true);
});
socket.once("error", () => {
clearTimeout(timer);
resolve(false);
});
socket.connect(port, "127.0.0.1");
});
}
function parseDevToolsActivePort(filePath) {
try {
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split(/\r?\n/);
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
const wsPath = lines[1]?.trim();
if (port > 0 && wsPath)
return { port, wsPath };
} catch {}
return null;
}
async function findExistingChromeDebugPort(options) {
const timeoutMs = options.timeoutMs ?? 3000;
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs))
return parsed.port;
if (process.platform === "win32")
return null;
try {
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5000 });
if (result.status !== 0 || !result.stdout)
return null;
const lines = result.stdout.split(`
`).filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
for (const line of lines) {
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs))
return port;
}
} catch {}
return null;
}
function getDefaultChromeUserDataDirs(channels = ["stable"]) {
const home = os.homedir();
const dirs = [];
const channelDirs = {
stable: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
linux: path.join(home, ".config", "google-chrome"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data")
},
beta: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
linux: path.join(home, ".config", "google-chrome-beta"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data")
},
canary: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
linux: path.join(home, ".config", "google-chrome-canary"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data")
},
dev: {
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
linux: path.join(home, ".config", "google-chrome-dev"),
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data")
}
};
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
for (const ch of channels) {
const entry = channelDirs[ch];
if (entry)
dirs.push(entry[platform]);
}
return dirs;
}
async function discoverRunningChromeDebugPort(options = {}) {
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
const timeoutMs = options.timeoutMs ?? 3000;
const userDataDirs = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels)).map((dir) => path.resolve(dir));
for (const dir of userDataDirs) {
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
if (!parsed)
continue;
if (await isPortListening(parsed.port, timeoutMs)) {
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
}
}
if (process.platform !== "win32") {
try {
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5000 });
if (result.status === 0 && result.stdout) {
const lines = result.stdout.split(`
`).filter((line) => line.includes("--remote-debugging-port=") && userDataDirs.some((dir) => line.includes(dir)));
for (const line of lines) {
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
if (version.webSocketDebuggerUrl)
return { port, wsUrl: version.webSocketDebuggerUrl };
} catch {}
}
}
}
} catch {}
}
return null;
}
async function waitForChromeDebugPort(port, timeoutMs, options) {
const start = Date.now();
let lastError = null;
while (Date.now() - start < timeoutMs) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5000 });
if (version.webSocketDebuggerUrl)
return version.webSocketDebuggerUrl;
lastError = new Error("Missing webSocketDebuggerUrl");
} catch (error) {
lastError = error;
}
await sleep(200);
}
if (options?.includeLastError && lastError) {
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
throw new Error("Chrome debug port not ready");
}
class CdpConnection {
ws;
nextId = 0;
pending = new Map;
eventHandlers = new Map;
defaultTimeoutMs;
constructor(ws, defaultTimeoutMs = 15000) {
this.ws = ws;
this.defaultTimeoutMs = defaultTimeoutMs;
this.ws.addEventListener("message", (event) => {
try {
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
const msg = JSON.parse(data);
if (msg.method) {
const handlers = this.eventHandlers.get(msg.method);
if (handlers) {
handlers.forEach((handler) => handler(msg.params));
}
}
if (msg.id) {
const pending = this.pending.get(msg.id);
if (pending) {
this.pending.delete(msg.id);
if (pending.timer)
clearTimeout(pending.timer);
if (msg.error?.message)
pending.reject(new Error(msg.error.message));
else
pending.resolve(msg.result);
}
}
} catch {}
});
this.ws.addEventListener("close", () => {
for (const [id, pending] of this.pending.entries()) {
this.pending.delete(id);
if (pending.timer)
clearTimeout(pending.timer);
pending.reject(new Error("CDP connection closed."));
}
});
}
static async connect(url, timeoutMs, options) {
const ws = new WebSocket(url);
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
ws.addEventListener("open", () => {
clearTimeout(timer);
resolve();
});
ws.addEventListener("error", () => {
clearTimeout(timer);
reject(new Error("CDP connection failed."));
});
});
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15000);
}
on(method, handler) {
if (!this.eventHandlers.has(method)) {
this.eventHandlers.set(method, new Set);
}
this.eventHandlers.get(method)?.add(handler);
}
off(method, handler) {
this.eventHandlers.get(method)?.delete(handler);
}
async send(method, params, options) {
const id = ++this.nextId;
const message = { id, method };
if (params)
message.params = params;
if (options?.sessionId)
message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
const result = await new Promise((resolve, reject) => {
const timer = timeoutMs > 0 ? setTimeout(() => {
this.pending.delete(id);
reject(new Error(`CDP timeout: ${method}`));
}, timeoutMs) : null;
this.pending.set(id, { resolve, reject, timer });
this.ws.send(JSON.stringify(message));
});
return result;
}
close() {
try {
this.ws.close();
} catch {}
}
}
async function launchChrome(options) {
await fs.promises.mkdir(options.profileDir, { recursive: true });
const args = [
`--remote-debugging-port=${options.port}`,
`--user-data-dir=${options.profileDir}`,
"--no-first-run",
"--no-default-browser-check",
...options.extraArgs ?? []
];
if (options.headless)
args.push("--headless=new");
if (options.url)
args.push(options.url);
return spawn(options.chromePath, args, { stdio: "ignore" });
}
function killChrome(chrome) {
try {
chrome.kill("SIGTERM");
} catch {}
setTimeout(() => {
if (chrome.exitCode === null && chrome.signalCode === null) {
try {
chrome.kill("SIGKILL");
} catch {}
}
}, 2000).unref?.();
}
async function gracefulKillChrome(chrome, port, timeoutMs = 6000) {
if (chrome.exitCode !== null || chrome.signalCode !== null)
return;
const exitPromise = new Promise((resolve) => {
chrome.once("exit", () => resolve());
});
killChrome(chrome);
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (chrome.exitCode !== null || chrome.signalCode !== null)
return;
if (port !== undefined && !await isPortListening(port, 250))
return;
const exited = await Promise.race([
exitPromise.then(() => true),
sleep(100).then(() => false)
]);
if (exited)
return;
}
await Promise.race([
exitPromise,
sleep(250)
]);
}
async function openPageSession(options) {
let targetId;
let createdTarget = false;
if (options.reusing) {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
} else {
const targets = await options.cdp.send("Target.getTargets");
const existing = targets.targetInfos.find(options.matchTarget);
if (existing) {
targetId = existing.targetId;
} else {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
}
}
const { sessionId } = await options.cdp.send("Target.attachToTarget", { targetId, flatten: true });
if (options.activateTarget ?? true) {
await options.cdp.send("Target.activateTarget", { targetId });
}
if (options.enablePage)
await options.cdp.send("Page.enable", {}, { sessionId });
if (options.enableRuntime)
await options.cdp.send("Runtime.enable", {}, { sessionId });
if (options.enableDom)
await options.cdp.send("DOM.enable", {}, { sessionId });
if (options.enableNetwork)
await options.cdp.send("Network.enable", {}, { sessionId });
return { sessionId, targetId, createdTarget };
}
export {
waitForChromeDebugPort,
sleep,
resolveSharedChromeProfileDir,
openPageSession,
launchChrome,
killChrome,
gracefulKillChrome,
getFreePort,
getDefaultChromeUserDataDirs,
findExistingChromeDebugPort,
findChromeExecutable,
discoverRunningChromeDebugPort,
CdpConnection
};
+729
View File
@@ -0,0 +1,729 @@
var __create = Object.create;
var __getProtoOf = Object.getPrototypeOf;
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __hasOwnProp = Object.prototype.hasOwnProperty;
function __accessProp(key) {
return this[key];
}
var __toESMCache_node;
var __toESMCache_esm;
var __toESM = (mod, isNodeMode, target) => {
var canCache = mod != null && typeof mod === "object";
if (canCache) {
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
var cached = cache.get(mod);
if (cached)
return cached;
}
target = mod != null ? __create(__getProtoOf(mod)) : {};
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
for (let key of __getOwnPropNames(mod))
if (!__hasOwnProp.call(to, key))
__defProp(to, key, {
get: __accessProp.bind(mod, key),
enumerable: true
});
if (canCache)
cache.set(mod, to);
return to;
};
var __toCommonJS = (from) => {
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
if (entry)
return entry;
entry = __defProp({}, "__esModule", { value: true });
if (from && typeof from === "object" || typeof from === "function") {
for (var key of __getOwnPropNames(from))
if (!__hasOwnProp.call(entry, key))
__defProp(entry, key, {
get: __accessProp.bind(from, key),
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
});
}
__moduleCache.set(from, entry);
return entry;
};
var __moduleCache;
var __returnValue = (v) => v;
function __exportSetter(name, newValue) {
this[name] = __returnValue.bind(null, newValue);
}
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: __exportSetter.bind(all, name)
});
};
// src/mermaid.ts
var exports_mermaid = {};
__export(exports_mermaid, {
renderMermaidToPng: () => renderMermaidToPng,
closeRenderer: () => closeRenderer,
MermaidRenderError: () => MermaidRenderError
});
module.exports = __toCommonJS(exports_mermaid);
var import_node_child_process2 = require("node:child_process");
var import_node_fs2 = __toESM(require("node:fs"));
var import_node_path2 = __toESM(require("node:path"));
var import_node_process2 = __toESM(require("node:process"));
var import_node_url = require("node:url");
// src/index.ts
var import_node_child_process = require("node:child_process");
var import_node_fs = __toESM(require("node:fs"));
var import_node_net = __toESM(require("node:net"));
var import_node_os = __toESM(require("node:os"));
var import_node_path = __toESM(require("node:path"));
var import_node_process = __toESM(require("node:process"));
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFreePort(fixedEnvName) {
const fixed = fixedEnvName ? Number.parseInt(import_node_process.default.env[fixedEnvName] ?? "", 10) : NaN;
if (Number.isInteger(fixed) && fixed > 0)
return fixed;
return await new Promise((resolve, reject) => {
const server = import_node_net.default.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
return;
}
const port = address.port;
server.close((err) => {
if (err)
reject(err);
else
resolve(port);
});
});
});
}
function findChromeExecutable(options) {
for (const envName of options.envNames ?? []) {
const override = import_node_process.default.env[envName]?.trim();
if (override && import_node_fs.default.existsSync(override))
return override;
}
const candidates = import_node_process.default.platform === "darwin" ? options.candidates.darwin ?? options.candidates.default : import_node_process.default.platform === "win32" ? options.candidates.win32 ?? options.candidates.default : options.candidates.default;
for (const candidate of candidates) {
if (import_node_fs.default.existsSync(candidate))
return candidate;
}
return;
}
function resolveSharedChromeProfileDir(options = {}) {
for (const envName of options.envNames ?? []) {
const override = import_node_process.default.env[envName]?.trim();
if (override)
return import_node_path.default.resolve(override);
}
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
const profileDirName = options.profileDirName ?? "chrome-profile";
if (options.wslWindowsHome) {
return import_node_path.default.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
}
const base = import_node_process.default.platform === "darwin" ? import_node_path.default.join(import_node_os.default.homedir(), "Library", "Application Support") : import_node_process.default.platform === "win32" ? import_node_process.default.env.APPDATA ?? import_node_path.default.join(import_node_os.default.homedir(), "AppData", "Roaming") : import_node_process.default.env.XDG_DATA_HOME ?? import_node_path.default.join(import_node_os.default.homedir(), ".local", "share");
return import_node_path.default.join(base, appDataDirName, profileDirName);
}
async function fetchWithTimeout(url, timeoutMs) {
if (!timeoutMs || timeoutMs <= 0)
return await fetch(url, { redirect: "follow" });
const ctl = new AbortController;
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
return await fetch(url, { redirect: "follow", signal: ctl.signal });
} finally {
clearTimeout(timer);
}
}
async function fetchJson(url, options = {}) {
const response = await fetchWithTimeout(url, options.timeoutMs);
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
return await response.json();
}
async function isDebugPortReady(port, timeoutMs = 3000) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
return !!version.webSocketDebuggerUrl;
} catch {
return false;
}
}
function parseDevToolsActivePort(filePath) {
try {
const content = import_node_fs.default.readFileSync(filePath, "utf-8");
const lines = content.split(/\r?\n/);
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
const wsPath = lines[1]?.trim();
if (port > 0 && wsPath)
return { port, wsPath };
} catch {}
return null;
}
async function findExistingChromeDebugPort(options) {
const timeoutMs = options.timeoutMs ?? 3000;
const parsed = parseDevToolsActivePort(import_node_path.default.join(options.profileDir, "DevToolsActivePort"));
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs))
return parsed.port;
if (import_node_process.default.platform === "win32")
return null;
try {
const result = import_node_child_process.spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5000 });
if (result.status !== 0 || !result.stdout)
return null;
const lines = result.stdout.split(`
`).filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
for (const line of lines) {
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs))
return port;
}
} catch {}
return null;
}
async function waitForChromeDebugPort(port, timeoutMs, options) {
const start = Date.now();
let lastError = null;
while (Date.now() - start < timeoutMs) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5000 });
if (version.webSocketDebuggerUrl)
return version.webSocketDebuggerUrl;
lastError = new Error("Missing webSocketDebuggerUrl");
} catch (error) {
lastError = error;
}
await sleep(200);
}
if (options?.includeLastError && lastError) {
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
throw new Error("Chrome debug port not ready");
}
class CdpConnection {
ws;
nextId = 0;
pending = new Map;
eventHandlers = new Map;
defaultTimeoutMs;
constructor(ws, defaultTimeoutMs = 15000) {
this.ws = ws;
this.defaultTimeoutMs = defaultTimeoutMs;
this.ws.addEventListener("message", (event) => {
try {
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
const msg = JSON.parse(data);
if (msg.method) {
const handlers = this.eventHandlers.get(msg.method);
if (handlers) {
handlers.forEach((handler) => handler(msg.params));
}
}
if (msg.id) {
const pending = this.pending.get(msg.id);
if (pending) {
this.pending.delete(msg.id);
if (pending.timer)
clearTimeout(pending.timer);
if (msg.error?.message)
pending.reject(new Error(msg.error.message));
else
pending.resolve(msg.result);
}
}
} catch {}
});
this.ws.addEventListener("close", () => {
for (const [id, pending] of this.pending.entries()) {
this.pending.delete(id);
if (pending.timer)
clearTimeout(pending.timer);
pending.reject(new Error("CDP connection closed."));
}
});
}
static async connect(url, timeoutMs, options) {
const ws = new WebSocket(url);
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
ws.addEventListener("open", () => {
clearTimeout(timer);
resolve();
});
ws.addEventListener("error", () => {
clearTimeout(timer);
reject(new Error("CDP connection failed."));
});
});
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15000);
}
on(method, handler) {
if (!this.eventHandlers.has(method)) {
this.eventHandlers.set(method, new Set);
}
this.eventHandlers.get(method)?.add(handler);
}
off(method, handler) {
this.eventHandlers.get(method)?.delete(handler);
}
async send(method, params, options) {
const id = ++this.nextId;
const message = { id, method };
if (params)
message.params = params;
if (options?.sessionId)
message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
const result = await new Promise((resolve, reject) => {
const timer = timeoutMs > 0 ? setTimeout(() => {
this.pending.delete(id);
reject(new Error(`CDP timeout: ${method}`));
}, timeoutMs) : null;
this.pending.set(id, { resolve, reject, timer });
this.ws.send(JSON.stringify(message));
});
return result;
}
close() {
try {
this.ws.close();
} catch {}
}
}
async function launchChrome(options) {
await import_node_fs.default.promises.mkdir(options.profileDir, { recursive: true });
const args = [
`--remote-debugging-port=${options.port}`,
`--user-data-dir=${options.profileDir}`,
"--no-first-run",
"--no-default-browser-check",
...options.extraArgs ?? []
];
if (options.headless)
args.push("--headless=new");
if (options.url)
args.push(options.url);
return import_node_child_process.spawn(options.chromePath, args, { stdio: "ignore" });
}
async function openPageSession(options) {
let targetId;
let createdTarget = false;
if (options.reusing) {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
} else {
const targets = await options.cdp.send("Target.getTargets");
const existing = targets.targetInfos.find(options.matchTarget);
if (existing) {
targetId = existing.targetId;
} else {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
}
}
const { sessionId } = await options.cdp.send("Target.attachToTarget", { targetId, flatten: true });
if (options.activateTarget ?? true) {
await options.cdp.send("Target.activateTarget", { targetId });
}
if (options.enablePage)
await options.cdp.send("Page.enable", {}, { sessionId });
if (options.enableRuntime)
await options.cdp.send("Runtime.enable", {}, { sessionId });
if (options.enableDom)
await options.cdp.send("DOM.enable", {}, { sessionId });
if (options.enableNetwork)
await options.cdp.send("Network.enable", {}, { sessionId });
return { sessionId, targetId, createdTarget };
}
// src/mermaid.ts
class MermaidRenderError extends Error {
constructor(message, options) {
super(message);
this.name = "MermaidRenderError";
if (options?.cause !== undefined) {
this.cause = options.cause;
}
}
}
function resolveRenderScale(scale) {
const resolved = scale ?? 2;
if (!Number.isFinite(resolved) || resolved <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render scale: ${scale}`);
}
return resolved;
}
function resolveMinWidth(minWidth) {
if (minWidth === undefined)
return;
if (!Number.isFinite(minWidth) || minWidth <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render minWidth: ${minWidth}`);
}
return minWidth;
}
var CHROME_CANDIDATES = {
darwin: [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
],
win32: [
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
],
default: [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
"/usr/bin/microsoft-edge"
]
};
var wslHome;
function getWslWindowsHome() {
if (wslHome !== undefined)
return wslHome;
if (!import_node_process2.default.env.WSL_DISTRO_NAME) {
wslHome = null;
return null;
}
try {
const raw = import_node_child_process2.execSync('cmd.exe /C "echo %USERPROFILE%"', {
encoding: "utf-8",
timeout: 5000
}).trim().replace(/\r/g, "");
wslHome = import_node_child_process2.execSync(`wslpath -u "${raw}"`, {
encoding: "utf-8",
timeout: 5000
}).trim() || null;
} catch {
wslHome = null;
}
return wslHome;
}
function getProfileDir() {
return resolveSharedChromeProfileDir({
envNames: ["BAOYU_CHROME_PROFILE_DIR", "MERMAID_RENDER_PROFILE_DIR"],
wslWindowsHome: getWslWindowsHome()
});
}
function resolveAssetsDir() {
const here = import_node_url.fileURLToPath(undefined);
const dir = import_node_path2.default.dirname(here);
const candidates = [
import_node_path2.default.resolve(dir, "..", "assets"),
import_node_path2.default.resolve(dir, "assets")
];
for (const candidate of candidates) {
if (import_node_fs2.default.existsSync(import_node_path2.default.join(candidate, "mermaid.min.js")))
return candidate;
}
throw new MermaidRenderError(`Cannot locate mermaid.min.js. Looked in: ${candidates.join(", ")}`);
}
var cachedMermaidScript = null;
function loadMermaidScript() {
if (cachedMermaidScript)
return cachedMermaidScript;
const assetsDir = resolveAssetsDir();
cachedMermaidScript = import_node_fs2.default.readFileSync(import_node_path2.default.join(assetsDir, "mermaid.min.js"), "utf-8");
return cachedMermaidScript;
}
var rendererState = null;
var connectingPromise = null;
var exitHookInstalled = false;
function installExitHook() {
if (exitHookInstalled)
return;
exitHookInstalled = true;
const cleanup = () => {
const state = rendererState;
rendererState = null;
if (!state)
return;
try {
state.cdp.close();
} catch {}
if (state.ownsChrome && state.chrome) {
try {
state.chrome.kill("SIGTERM");
} catch {}
}
};
import_node_process2.default.on("exit", cleanup);
import_node_process2.default.on("beforeExit", cleanup);
}
async function tryConnectExisting(port) {
try {
const wsUrl = await waitForChromeDebugPort(port, 5000, { includeLastError: true });
return await CdpConnection.connect(wsUrl, 5000);
} catch {
return null;
}
}
async function ensureRenderer() {
if (rendererState)
return rendererState;
if (connectingPromise)
return await connectingPromise;
connectingPromise = (async () => {
const profileDir = getProfileDir();
const existingPort = await findExistingChromeDebugPort({ profileDir });
if (existingPort) {
const cdp2 = await tryConnectExisting(existingPort);
if (cdp2) {
const state2 = {
cdp: cdp2,
chrome: null,
port: existingPort,
ownsChrome: false
};
rendererState = state2;
installExitHook();
return state2;
}
}
const chromePath = findChromeExecutable({
candidates: CHROME_CANDIDATES,
envNames: ["BAOYU_CHROME_PATH", "MERMAID_RENDER_CHROME_PATH"]
});
if (!chromePath) {
throw new MermaidRenderError("Chrome not found. Install Google Chrome / Chromium / Edge, or set BAOYU_CHROME_PATH.");
}
const port = await getFreePort("MERMAID_RENDER_DEBUG_PORT");
const chrome = await launchChrome({
chromePath,
profileDir,
port,
headless: true,
extraArgs: [
"--disable-blink-features=AutomationControlled",
"--disable-gpu",
"--hide-scrollbars"
]
});
const wsUrl = await waitForChromeDebugPort(port, 30000, { includeLastError: true });
const cdp = await CdpConnection.connect(wsUrl, 30000);
const state = { cdp, chrome, port, ownsChrome: true };
rendererState = state;
installExitHook();
return state;
})();
try {
return await connectingPromise;
} finally {
connectingPromise = null;
}
}
function buildHostHtml(code, theme, background) {
const script = loadMermaidScript();
const safeCode = code.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const safeTheme = JSON.stringify(theme);
const cssBackground = background === "transparent" ? "transparent" : background;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body {
margin: 0;
padding: 0;
background: ${cssBackground};
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
}
#host {
display: inline-block;
padding: 16px;
}
#host svg {
max-width: none !important;
}
</style>
</head>
<body>
<div id="host"><div class="mermaid">${safeCode}</div></div>
<script>${script}</script>
<script>
window.__mermaidReady = false;
window.__mermaidError = null;
try {
mermaid.initialize({ startOnLoad: false, theme: ${safeTheme}, securityLevel: "loose" });
mermaid.run({ querySelector: ".mermaid" })
.then(function () { window.__mermaidReady = true; })
.catch(function (err) { window.__mermaidError = String(err && err.message || err); });
} catch (err) {
window.__mermaidError = String(err && err.message || err);
}
</script>
</body>
</html>`;
}
async function evaluate(cdp, sessionId, expression) {
const result = await cdp.send("Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: false
}, { sessionId });
const exception = result.exceptionDetails;
if (exception) {
return {
value: undefined,
exceptionText: exception.exception?.description ?? exception.text ?? "evaluation failed"
};
}
return { value: result.result.value };
}
async function waitForMermaidSvg(cdp, sessionId, timeoutMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const status = await evaluate(cdp, sessionId, `(function () {
if (window.__mermaidError) {
return { ready: false, error: window.__mermaidError, rect: null };
}
var svg = document.querySelector(".mermaid svg");
if (!svg) return { ready: false, error: null, rect: null };
var bbox = svg.getBoundingClientRect();
return {
ready: window.__mermaidReady === true,
error: null,
rect: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
},
};
})()`);
if (status.exceptionText) {
throw new MermaidRenderError(`Mermaid evaluation failed: ${status.exceptionText}`);
}
const value = status.value;
if (value?.error) {
throw new MermaidRenderError(`Mermaid render failed: ${value.error}`);
}
if (value?.ready && value.rect && value.rect.width > 0 && value.rect.height > 0) {
const host = await evaluate(cdp, sessionId, `(function () {
var host = document.getElementById("host");
if (!host) return null;
var rect = host.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
})()`);
if (host.value)
return host.value;
return value.rect;
}
await sleep(80);
}
throw new MermaidRenderError(`Mermaid render timed out after ${timeoutMs}ms`);
}
async function withPageSession(state, fn) {
let session = null;
try {
session = await openPageSession({
cdp: state.cdp,
reusing: !state.ownsChrome,
url: "about:blank",
matchTarget: () => false,
enablePage: true,
enableRuntime: true
});
return await fn(session.sessionId, session.targetId);
} finally {
if (session?.createdTarget) {
try {
await state.cdp.send("Target.closeTarget", { targetId: session.targetId });
} catch {}
}
}
}
async function renderMermaidToPng(code, outputPath, options = {}) {
const theme = options.theme ?? "default";
const scale = resolveRenderScale(options.scale);
const minWidth = resolveMinWidth(options.minWidth);
const background = options.background ?? "white";
const timeoutMs = options.timeoutMs ?? 15000;
if (!code.trim()) {
throw new MermaidRenderError("Mermaid code is empty");
}
await import_node_fs2.default.promises.mkdir(import_node_path2.default.dirname(outputPath), { recursive: true });
const state = await ensureRenderer();
const html = buildHostHtml(code, theme, background);
return await withPageSession(state, async (sessionId) => {
await state.cdp.send("Emulation.setDeviceMetricsOverride", {
width: 1280,
height: 800,
deviceScaleFactor: 1,
mobile: false
}, { sessionId });
await state.cdp.send("Page.setDocumentContent", { frameId: await getFrameId(state.cdp, sessionId), html }, { sessionId });
const rect = await waitForMermaidSvg(state.cdp, sessionId, timeoutMs);
const cssWidth = Math.max(1, Math.ceil(rect.width));
const cssHeight = Math.max(1, Math.ceil(rect.height));
const targetCssWidth = Math.max(cssWidth, Math.ceil(minWidth ?? cssWidth));
const captureScale = scale * (targetCssWidth / cssWidth);
const bitmapWidth = Math.max(1, Math.ceil(cssWidth * captureScale));
const bitmapHeight = Math.max(1, Math.ceil(cssHeight * captureScale));
await state.cdp.send("Emulation.setDeviceMetricsOverride", {
width: cssWidth,
height: cssHeight,
deviceScaleFactor: 1,
mobile: false
}, { sessionId });
const shot = await state.cdp.send("Page.captureScreenshot", {
format: "png",
clip: {
x: rect.x,
y: rect.y,
width: cssWidth,
height: cssHeight,
scale: captureScale
},
captureBeyondViewport: true
}, { sessionId });
const buffer = Buffer.from(shot.data, "base64");
await import_node_fs2.default.promises.writeFile(outputPath, buffer);
return {
width: bitmapWidth,
height: bitmapHeight,
bytes: buffer.length
};
});
}
async function getFrameId(cdp, sessionId) {
const result = await cdp.send("Page.getFrameTree", {}, { sessionId });
return result.frameTree.frame.id;
}
async function closeRenderer() {
const state = rendererState;
rendererState = null;
if (!state)
return;
try {
state.cdp.close();
} catch {}
if (state.ownsChrome && state.chrome) {
try {
state.chrome.kill("SIGTERM");
} catch {}
}
}
+665
View File
@@ -0,0 +1,665 @@
// src/mermaid.ts
import { execSync } from "node:child_process";
import fs2 from "node:fs";
import path2 from "node:path";
import process2 from "node:process";
import { fileURLToPath } from "node:url";
// src/index.ts
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFreePort(fixedEnvName) {
const fixed = fixedEnvName ? Number.parseInt(process.env[fixedEnvName] ?? "", 10) : NaN;
if (Number.isInteger(fixed) && fixed > 0)
return fixed;
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
return;
}
const port = address.port;
server.close((err) => {
if (err)
reject(err);
else
resolve(port);
});
});
});
}
function findChromeExecutable(options) {
for (const envName of options.envNames ?? []) {
const override = process.env[envName]?.trim();
if (override && fs.existsSync(override))
return override;
}
const candidates = process.platform === "darwin" ? options.candidates.darwin ?? options.candidates.default : process.platform === "win32" ? options.candidates.win32 ?? options.candidates.default : options.candidates.default;
for (const candidate of candidates) {
if (fs.existsSync(candidate))
return candidate;
}
return;
}
function resolveSharedChromeProfileDir(options = {}) {
for (const envName of options.envNames ?? []) {
const override = process.env[envName]?.trim();
if (override)
return path.resolve(override);
}
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
const profileDirName = options.profileDirName ?? "chrome-profile";
if (options.wslWindowsHome) {
return path.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
}
const base = process.platform === "darwin" ? path.join(os.homedir(), "Library", "Application Support") : process.platform === "win32" ? process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming") : process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
return path.join(base, appDataDirName, profileDirName);
}
async function fetchWithTimeout(url, timeoutMs) {
if (!timeoutMs || timeoutMs <= 0)
return await fetch(url, { redirect: "follow" });
const ctl = new AbortController;
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
return await fetch(url, { redirect: "follow", signal: ctl.signal });
} finally {
clearTimeout(timer);
}
}
async function fetchJson(url, options = {}) {
const response = await fetchWithTimeout(url, options.timeoutMs);
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
return await response.json();
}
async function isDebugPortReady(port, timeoutMs = 3000) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
return !!version.webSocketDebuggerUrl;
} catch {
return false;
}
}
function parseDevToolsActivePort(filePath) {
try {
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split(/\r?\n/);
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
const wsPath = lines[1]?.trim();
if (port > 0 && wsPath)
return { port, wsPath };
} catch {}
return null;
}
async function findExistingChromeDebugPort(options) {
const timeoutMs = options.timeoutMs ?? 3000;
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs))
return parsed.port;
if (process.platform === "win32")
return null;
try {
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5000 });
if (result.status !== 0 || !result.stdout)
return null;
const lines = result.stdout.split(`
`).filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
for (const line of lines) {
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
if (port > 0 && await isDebugPortReady(port, timeoutMs))
return port;
}
} catch {}
return null;
}
async function waitForChromeDebugPort(port, timeoutMs, options) {
const start = Date.now();
let lastError = null;
while (Date.now() - start < timeoutMs) {
try {
const version = await fetchJson(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5000 });
if (version.webSocketDebuggerUrl)
return version.webSocketDebuggerUrl;
lastError = new Error("Missing webSocketDebuggerUrl");
} catch (error) {
lastError = error;
}
await sleep(200);
}
if (options?.includeLastError && lastError) {
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
throw new Error("Chrome debug port not ready");
}
class CdpConnection {
ws;
nextId = 0;
pending = new Map;
eventHandlers = new Map;
defaultTimeoutMs;
constructor(ws, defaultTimeoutMs = 15000) {
this.ws = ws;
this.defaultTimeoutMs = defaultTimeoutMs;
this.ws.addEventListener("message", (event) => {
try {
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
const msg = JSON.parse(data);
if (msg.method) {
const handlers = this.eventHandlers.get(msg.method);
if (handlers) {
handlers.forEach((handler) => handler(msg.params));
}
}
if (msg.id) {
const pending = this.pending.get(msg.id);
if (pending) {
this.pending.delete(msg.id);
if (pending.timer)
clearTimeout(pending.timer);
if (msg.error?.message)
pending.reject(new Error(msg.error.message));
else
pending.resolve(msg.result);
}
}
} catch {}
});
this.ws.addEventListener("close", () => {
for (const [id, pending] of this.pending.entries()) {
this.pending.delete(id);
if (pending.timer)
clearTimeout(pending.timer);
pending.reject(new Error("CDP connection closed."));
}
});
}
static async connect(url, timeoutMs, options) {
const ws = new WebSocket(url);
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
ws.addEventListener("open", () => {
clearTimeout(timer);
resolve();
});
ws.addEventListener("error", () => {
clearTimeout(timer);
reject(new Error("CDP connection failed."));
});
});
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15000);
}
on(method, handler) {
if (!this.eventHandlers.has(method)) {
this.eventHandlers.set(method, new Set);
}
this.eventHandlers.get(method)?.add(handler);
}
off(method, handler) {
this.eventHandlers.get(method)?.delete(handler);
}
async send(method, params, options) {
const id = ++this.nextId;
const message = { id, method };
if (params)
message.params = params;
if (options?.sessionId)
message.sessionId = options.sessionId;
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
const result = await new Promise((resolve, reject) => {
const timer = timeoutMs > 0 ? setTimeout(() => {
this.pending.delete(id);
reject(new Error(`CDP timeout: ${method}`));
}, timeoutMs) : null;
this.pending.set(id, { resolve, reject, timer });
this.ws.send(JSON.stringify(message));
});
return result;
}
close() {
try {
this.ws.close();
} catch {}
}
}
async function launchChrome(options) {
await fs.promises.mkdir(options.profileDir, { recursive: true });
const args = [
`--remote-debugging-port=${options.port}`,
`--user-data-dir=${options.profileDir}`,
"--no-first-run",
"--no-default-browser-check",
...options.extraArgs ?? []
];
if (options.headless)
args.push("--headless=new");
if (options.url)
args.push(options.url);
return spawn(options.chromePath, args, { stdio: "ignore" });
}
async function openPageSession(options) {
let targetId;
let createdTarget = false;
if (options.reusing) {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
} else {
const targets = await options.cdp.send("Target.getTargets");
const existing = targets.targetInfos.find(options.matchTarget);
if (existing) {
targetId = existing.targetId;
} else {
const created = await options.cdp.send("Target.createTarget", { url: options.url });
targetId = created.targetId;
createdTarget = true;
}
}
const { sessionId } = await options.cdp.send("Target.attachToTarget", { targetId, flatten: true });
if (options.activateTarget ?? true) {
await options.cdp.send("Target.activateTarget", { targetId });
}
if (options.enablePage)
await options.cdp.send("Page.enable", {}, { sessionId });
if (options.enableRuntime)
await options.cdp.send("Runtime.enable", {}, { sessionId });
if (options.enableDom)
await options.cdp.send("DOM.enable", {}, { sessionId });
if (options.enableNetwork)
await options.cdp.send("Network.enable", {}, { sessionId });
return { sessionId, targetId, createdTarget };
}
// src/mermaid.ts
class MermaidRenderError extends Error {
constructor(message, options) {
super(message);
this.name = "MermaidRenderError";
if (options?.cause !== undefined) {
this.cause = options.cause;
}
}
}
function resolveRenderScale(scale) {
const resolved = scale ?? 2;
if (!Number.isFinite(resolved) || resolved <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render scale: ${scale}`);
}
return resolved;
}
function resolveMinWidth(minWidth) {
if (minWidth === undefined)
return;
if (!Number.isFinite(minWidth) || minWidth <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render minWidth: ${minWidth}`);
}
return minWidth;
}
var CHROME_CANDIDATES = {
darwin: [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
],
win32: [
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
],
default: [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
"/usr/bin/microsoft-edge"
]
};
var wslHome;
function getWslWindowsHome() {
if (wslHome !== undefined)
return wslHome;
if (!process2.env.WSL_DISTRO_NAME) {
wslHome = null;
return null;
}
try {
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', {
encoding: "utf-8",
timeout: 5000
}).trim().replace(/\r/g, "");
wslHome = execSync(`wslpath -u "${raw}"`, {
encoding: "utf-8",
timeout: 5000
}).trim() || null;
} catch {
wslHome = null;
}
return wslHome;
}
function getProfileDir() {
return resolveSharedChromeProfileDir({
envNames: ["BAOYU_CHROME_PROFILE_DIR", "MERMAID_RENDER_PROFILE_DIR"],
wslWindowsHome: getWslWindowsHome()
});
}
function resolveAssetsDir() {
const here = fileURLToPath(import.meta.url);
const dir = path2.dirname(here);
const candidates = [
path2.resolve(dir, "..", "assets"),
path2.resolve(dir, "assets")
];
for (const candidate of candidates) {
if (fs2.existsSync(path2.join(candidate, "mermaid.min.js")))
return candidate;
}
throw new MermaidRenderError(`Cannot locate mermaid.min.js. Looked in: ${candidates.join(", ")}`);
}
var cachedMermaidScript = null;
function loadMermaidScript() {
if (cachedMermaidScript)
return cachedMermaidScript;
const assetsDir = resolveAssetsDir();
cachedMermaidScript = fs2.readFileSync(path2.join(assetsDir, "mermaid.min.js"), "utf-8");
return cachedMermaidScript;
}
var rendererState = null;
var connectingPromise = null;
var exitHookInstalled = false;
function installExitHook() {
if (exitHookInstalled)
return;
exitHookInstalled = true;
const cleanup = () => {
const state = rendererState;
rendererState = null;
if (!state)
return;
try {
state.cdp.close();
} catch {}
if (state.ownsChrome && state.chrome) {
try {
state.chrome.kill("SIGTERM");
} catch {}
}
};
process2.on("exit", cleanup);
process2.on("beforeExit", cleanup);
}
async function tryConnectExisting(port) {
try {
const wsUrl = await waitForChromeDebugPort(port, 5000, { includeLastError: true });
return await CdpConnection.connect(wsUrl, 5000);
} catch {
return null;
}
}
async function ensureRenderer() {
if (rendererState)
return rendererState;
if (connectingPromise)
return await connectingPromise;
connectingPromise = (async () => {
const profileDir = getProfileDir();
const existingPort = await findExistingChromeDebugPort({ profileDir });
if (existingPort) {
const cdp2 = await tryConnectExisting(existingPort);
if (cdp2) {
const state2 = {
cdp: cdp2,
chrome: null,
port: existingPort,
ownsChrome: false
};
rendererState = state2;
installExitHook();
return state2;
}
}
const chromePath = findChromeExecutable({
candidates: CHROME_CANDIDATES,
envNames: ["BAOYU_CHROME_PATH", "MERMAID_RENDER_CHROME_PATH"]
});
if (!chromePath) {
throw new MermaidRenderError("Chrome not found. Install Google Chrome / Chromium / Edge, or set BAOYU_CHROME_PATH.");
}
const port = await getFreePort("MERMAID_RENDER_DEBUG_PORT");
const chrome = await launchChrome({
chromePath,
profileDir,
port,
headless: true,
extraArgs: [
"--disable-blink-features=AutomationControlled",
"--disable-gpu",
"--hide-scrollbars"
]
});
const wsUrl = await waitForChromeDebugPort(port, 30000, { includeLastError: true });
const cdp = await CdpConnection.connect(wsUrl, 30000);
const state = { cdp, chrome, port, ownsChrome: true };
rendererState = state;
installExitHook();
return state;
})();
try {
return await connectingPromise;
} finally {
connectingPromise = null;
}
}
function buildHostHtml(code, theme, background) {
const script = loadMermaidScript();
const safeCode = code.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const safeTheme = JSON.stringify(theme);
const cssBackground = background === "transparent" ? "transparent" : background;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body {
margin: 0;
padding: 0;
background: ${cssBackground};
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
}
#host {
display: inline-block;
padding: 16px;
}
#host svg {
max-width: none !important;
}
</style>
</head>
<body>
<div id="host"><div class="mermaid">${safeCode}</div></div>
<script>${script}</script>
<script>
window.__mermaidReady = false;
window.__mermaidError = null;
try {
mermaid.initialize({ startOnLoad: false, theme: ${safeTheme}, securityLevel: "loose" });
mermaid.run({ querySelector: ".mermaid" })
.then(function () { window.__mermaidReady = true; })
.catch(function (err) { window.__mermaidError = String(err && err.message || err); });
} catch (err) {
window.__mermaidError = String(err && err.message || err);
}
</script>
</body>
</html>`;
}
async function evaluate(cdp, sessionId, expression) {
const result = await cdp.send("Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: false
}, { sessionId });
const exception = result.exceptionDetails;
if (exception) {
return {
value: undefined,
exceptionText: exception.exception?.description ?? exception.text ?? "evaluation failed"
};
}
return { value: result.result.value };
}
async function waitForMermaidSvg(cdp, sessionId, timeoutMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const status = await evaluate(cdp, sessionId, `(function () {
if (window.__mermaidError) {
return { ready: false, error: window.__mermaidError, rect: null };
}
var svg = document.querySelector(".mermaid svg");
if (!svg) return { ready: false, error: null, rect: null };
var bbox = svg.getBoundingClientRect();
return {
ready: window.__mermaidReady === true,
error: null,
rect: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
},
};
})()`);
if (status.exceptionText) {
throw new MermaidRenderError(`Mermaid evaluation failed: ${status.exceptionText}`);
}
const value = status.value;
if (value?.error) {
throw new MermaidRenderError(`Mermaid render failed: ${value.error}`);
}
if (value?.ready && value.rect && value.rect.width > 0 && value.rect.height > 0) {
const host = await evaluate(cdp, sessionId, `(function () {
var host = document.getElementById("host");
if (!host) return null;
var rect = host.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
})()`);
if (host.value)
return host.value;
return value.rect;
}
await sleep(80);
}
throw new MermaidRenderError(`Mermaid render timed out after ${timeoutMs}ms`);
}
async function withPageSession(state, fn) {
let session = null;
try {
session = await openPageSession({
cdp: state.cdp,
reusing: !state.ownsChrome,
url: "about:blank",
matchTarget: () => false,
enablePage: true,
enableRuntime: true
});
return await fn(session.sessionId, session.targetId);
} finally {
if (session?.createdTarget) {
try {
await state.cdp.send("Target.closeTarget", { targetId: session.targetId });
} catch {}
}
}
}
async function renderMermaidToPng(code, outputPath, options = {}) {
const theme = options.theme ?? "default";
const scale = resolveRenderScale(options.scale);
const minWidth = resolveMinWidth(options.minWidth);
const background = options.background ?? "white";
const timeoutMs = options.timeoutMs ?? 15000;
if (!code.trim()) {
throw new MermaidRenderError("Mermaid code is empty");
}
await fs2.promises.mkdir(path2.dirname(outputPath), { recursive: true });
const state = await ensureRenderer();
const html = buildHostHtml(code, theme, background);
return await withPageSession(state, async (sessionId) => {
await state.cdp.send("Emulation.setDeviceMetricsOverride", {
width: 1280,
height: 800,
deviceScaleFactor: 1,
mobile: false
}, { sessionId });
await state.cdp.send("Page.setDocumentContent", { frameId: await getFrameId(state.cdp, sessionId), html }, { sessionId });
const rect = await waitForMermaidSvg(state.cdp, sessionId, timeoutMs);
const cssWidth = Math.max(1, Math.ceil(rect.width));
const cssHeight = Math.max(1, Math.ceil(rect.height));
const targetCssWidth = Math.max(cssWidth, Math.ceil(minWidth ?? cssWidth));
const captureScale = scale * (targetCssWidth / cssWidth);
const bitmapWidth = Math.max(1, Math.ceil(cssWidth * captureScale));
const bitmapHeight = Math.max(1, Math.ceil(cssHeight * captureScale));
await state.cdp.send("Emulation.setDeviceMetricsOverride", {
width: cssWidth,
height: cssHeight,
deviceScaleFactor: 1,
mobile: false
}, { sessionId });
const shot = await state.cdp.send("Page.captureScreenshot", {
format: "png",
clip: {
x: rect.x,
y: rect.y,
width: cssWidth,
height: cssHeight,
scale: captureScale
},
captureBeyondViewport: true
}, { sessionId });
const buffer = Buffer.from(shot.data, "base64");
await fs2.promises.writeFile(outputPath, buffer);
return {
width: bitmapWidth,
height: bitmapHeight,
bytes: buffer.length
};
});
}
async function getFrameId(cdp, sessionId) {
const result = await cdp.send("Page.getFrameTree", {}, { sessionId });
return result.frameTree.frame.id;
}
async function closeRenderer() {
const state = rendererState;
rendererState = null;
if (!state)
return;
try {
state.cdp.close();
} catch {}
if (state.ownsChrome && state.chrome) {
try {
state.chrome.kill("SIGTERM");
} catch {}
}
}
export {
renderMermaidToPng,
closeRenderer,
MermaidRenderError
};
+43 -4
View File
@@ -1,12 +1,51 @@
{ {
"name": "baoyu-chrome-cdp", "name": "baoyu-chrome-cdp",
"private": true, "version": "0.1.1",
"version": "0.1.0", "license": "MIT",
"type": "module", "type": "module",
"files": [ "files": [
"src" "dist",
"src/**/*.ts",
"assets",
"!src/**/*.test.ts"
], ],
"exports": { "exports": {
".": "./src/index.ts" ".": {
"types": "./src/index.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./mermaid": {
"types": "./src/mermaid.ts",
"import": "./dist/mermaid.js",
"require": "./dist/mermaid.cjs",
"default": "./dist/mermaid.js"
},
"./src/*": "./src/*",
"./assets/*": "./assets/*"
},
"description": "Shared Chrome DevTools Protocol utilities for baoyu skills.",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./src/index.ts",
"repository": {
"type": "git",
"url": "git+https://github.com/JimLiu/baoyu-skills.git",
"directory": "packages/baoyu-chrome-cdp"
},
"bugs": {
"url": "https://github.com/JimLiu/baoyu-skills/issues"
},
"homepage": "https://github.com/JimLiu/baoyu-skills/tree/main/packages/baoyu-chrome-cdp#readme",
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "node ../../scripts/build-shared-package.mjs --entry-out src/index.ts:index --entry-out src/mermaid.ts:mermaid --asset assets:assets",
"prepack": "bun run build"
},
"engines": {
"bun": ">=1.2.0"
} }
} }
+499
View File
@@ -0,0 +1,499 @@
import { execSync, type ChildProcess } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import {
CdpConnection,
findChromeExecutable,
findExistingChromeDebugPort,
getFreePort,
launchChrome,
openPageSession,
resolveSharedChromeProfileDir,
sleep,
waitForChromeDebugPort,
type PageSession,
type PlatformCandidates,
} from "./index.js";
export class MermaidRenderError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message);
this.name = "MermaidRenderError";
if (options?.cause !== undefined) {
(this as { cause?: unknown }).cause = options.cause;
}
}
}
export interface MermaidRenderOptions {
theme?: string;
scale?: number;
background?: string;
timeoutMs?: number;
minWidth?: number;
}
export interface MermaidRenderResult {
width: number;
height: number;
bytes: number;
}
function resolveRenderScale(scale: number | undefined): number {
const resolved = scale ?? 2;
if (!Number.isFinite(resolved) || resolved <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render scale: ${scale}`);
}
return resolved;
}
function resolveMinWidth(minWidth: number | undefined): number | undefined {
if (minWidth === undefined) return undefined;
if (!Number.isFinite(minWidth) || minWidth <= 0) {
throw new MermaidRenderError(`Invalid Mermaid render minWidth: ${minWidth}`);
}
return minWidth;
}
const CHROME_CANDIDATES: PlatformCandidates = {
darwin: [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
],
win32: [
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
],
default: [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
"/usr/bin/microsoft-edge",
],
};
let wslHome: string | null | undefined;
function getWslWindowsHome(): string | null {
if (wslHome !== undefined) return wslHome;
if (!process.env.WSL_DISTRO_NAME) {
wslHome = null;
return null;
}
try {
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', {
encoding: "utf-8",
timeout: 5_000,
}).trim().replace(/\r/g, "");
wslHome = execSync(`wslpath -u "${raw}"`, {
encoding: "utf-8",
timeout: 5_000,
}).trim() || null;
} catch {
wslHome = null;
}
return wslHome;
}
function getProfileDir(): string {
return resolveSharedChromeProfileDir({
envNames: ["BAOYU_CHROME_PROFILE_DIR", "MERMAID_RENDER_PROFILE_DIR"],
wslWindowsHome: getWslWindowsHome(),
});
}
function resolveAssetsDir(): string {
const here = fileURLToPath(import.meta.url);
const dir = path.dirname(here);
const candidates = [
path.resolve(dir, "..", "assets"),
path.resolve(dir, "assets"),
];
for (const candidate of candidates) {
if (fs.existsSync(path.join(candidate, "mermaid.min.js"))) return candidate;
}
throw new MermaidRenderError(
`Cannot locate mermaid.min.js. Looked in: ${candidates.join(", ")}`,
);
}
let cachedMermaidScript: string | null = null;
function loadMermaidScript(): string {
if (cachedMermaidScript) return cachedMermaidScript;
const assetsDir = resolveAssetsDir();
cachedMermaidScript = fs.readFileSync(path.join(assetsDir, "mermaid.min.js"), "utf-8");
return cachedMermaidScript;
}
interface RendererState {
cdp: CdpConnection;
chrome: ChildProcess | null;
port: number | null;
ownsChrome: boolean;
}
let rendererState: RendererState | null = null;
let connectingPromise: Promise<RendererState> | null = null;
let exitHookInstalled = false;
function installExitHook(): void {
if (exitHookInstalled) return;
exitHookInstalled = true;
const cleanup = () => {
const state = rendererState;
rendererState = null;
if (!state) return;
try { state.cdp.close(); } catch {}
if (state.ownsChrome && state.chrome) {
try { state.chrome.kill("SIGTERM"); } catch {}
}
};
process.on("exit", cleanup);
process.on("beforeExit", cleanup);
}
async function tryConnectExisting(port: number): Promise<CdpConnection | null> {
try {
const wsUrl = await waitForChromeDebugPort(port, 5_000, { includeLastError: true });
return await CdpConnection.connect(wsUrl, 5_000);
} catch {
return null;
}
}
async function ensureRenderer(): Promise<RendererState> {
if (rendererState) return rendererState;
if (connectingPromise) return await connectingPromise;
connectingPromise = (async (): Promise<RendererState> => {
const profileDir = getProfileDir();
const existingPort = await findExistingChromeDebugPort({ profileDir });
if (existingPort) {
const cdp = await tryConnectExisting(existingPort);
if (cdp) {
const state: RendererState = {
cdp,
chrome: null,
port: existingPort,
ownsChrome: false,
};
rendererState = state;
installExitHook();
return state;
}
}
const chromePath = findChromeExecutable({
candidates: CHROME_CANDIDATES,
envNames: ["BAOYU_CHROME_PATH", "MERMAID_RENDER_CHROME_PATH"],
});
if (!chromePath) {
throw new MermaidRenderError(
"Chrome not found. Install Google Chrome / Chromium / Edge, or set BAOYU_CHROME_PATH.",
);
}
const port = await getFreePort("MERMAID_RENDER_DEBUG_PORT");
const chrome = await launchChrome({
chromePath,
profileDir,
port,
headless: true,
extraArgs: [
"--disable-blink-features=AutomationControlled",
"--disable-gpu",
"--hide-scrollbars",
],
});
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
const cdp = await CdpConnection.connect(wsUrl, 30_000);
const state: RendererState = { cdp, chrome, port, ownsChrome: true };
rendererState = state;
installExitHook();
return state;
})();
try {
return await connectingPromise;
} finally {
connectingPromise = null;
}
}
function buildHostHtml(code: string, theme: string, background: string): string {
const script = loadMermaidScript();
const safeCode = code
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const safeTheme = JSON.stringify(theme);
const cssBackground = background === "transparent" ? "transparent" : background;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body {
margin: 0;
padding: 0;
background: ${cssBackground};
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
}
#host {
display: inline-block;
padding: 16px;
}
#host svg {
max-width: none !important;
}
</style>
</head>
<body>
<div id="host"><div class="mermaid">${safeCode}</div></div>
<script>${script}</script>
<script>
window.__mermaidReady = false;
window.__mermaidError = null;
try {
mermaid.initialize({ startOnLoad: false, theme: ${safeTheme}, securityLevel: "loose" });
mermaid.run({ querySelector: ".mermaid" })
.then(function () { window.__mermaidReady = true; })
.catch(function (err) { window.__mermaidError = String(err && err.message || err); });
} catch (err) {
window.__mermaidError = String(err && err.message || err);
}
</script>
</body>
</html>`;
}
async function evaluate<T = unknown>(
cdp: CdpConnection,
sessionId: string,
expression: string,
): Promise<{ value: T | undefined; exceptionText?: string }> {
const result = await cdp.send<{
result: { value?: T };
exceptionDetails?: { text?: string; exception?: { description?: string } };
}>(
"Runtime.evaluate",
{
expression,
returnByValue: true,
awaitPromise: false,
},
{ sessionId },
);
const exception = result.exceptionDetails;
if (exception) {
return {
value: undefined,
exceptionText: exception.exception?.description ?? exception.text ?? "evaluation failed",
};
}
return { value: result.result.value };
}
interface BoundingRect {
x: number;
y: number;
width: number;
height: number;
}
async function waitForMermaidSvg(
cdp: CdpConnection,
sessionId: string,
timeoutMs: number,
): Promise<BoundingRect> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const status = await evaluate<{
ready: boolean;
error: string | null;
rect: BoundingRect | null;
}>(
cdp,
sessionId,
`(function () {
if (window.__mermaidError) {
return { ready: false, error: window.__mermaidError, rect: null };
}
var svg = document.querySelector(".mermaid svg");
if (!svg) return { ready: false, error: null, rect: null };
var bbox = svg.getBoundingClientRect();
return {
ready: window.__mermaidReady === true,
error: null,
rect: {
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
},
};
})()`,
);
if (status.exceptionText) {
throw new MermaidRenderError(`Mermaid evaluation failed: ${status.exceptionText}`);
}
const value = status.value;
if (value?.error) {
throw new MermaidRenderError(`Mermaid render failed: ${value.error}`);
}
if (value?.ready && value.rect && value.rect.width > 0 && value.rect.height > 0) {
const host = await evaluate<BoundingRect | null>(
cdp,
sessionId,
`(function () {
var host = document.getElementById("host");
if (!host) return null;
var rect = host.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
})()`,
);
if (host.value) return host.value;
return value.rect;
}
await sleep(80);
}
throw new MermaidRenderError(`Mermaid render timed out after ${timeoutMs}ms`);
}
async function withPageSession<T>(
state: RendererState,
fn: (sessionId: string, targetId: string) => Promise<T>,
): Promise<T> {
let session: PageSession | null = null;
try {
session = await openPageSession({
cdp: state.cdp,
reusing: !state.ownsChrome,
url: "about:blank",
matchTarget: () => false,
enablePage: true,
enableRuntime: true,
});
return await fn(session.sessionId, session.targetId);
} finally {
if (session?.createdTarget) {
try {
await state.cdp.send("Target.closeTarget", { targetId: session.targetId });
} catch {}
}
}
}
export async function renderMermaidToPng(
code: string,
outputPath: string,
options: MermaidRenderOptions = {},
): Promise<MermaidRenderResult> {
const theme = options.theme ?? "default";
const scale = resolveRenderScale(options.scale);
const minWidth = resolveMinWidth(options.minWidth);
const background = options.background ?? "white";
const timeoutMs = options.timeoutMs ?? 15_000;
if (!code.trim()) {
throw new MermaidRenderError("Mermaid code is empty");
}
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
const state = await ensureRenderer();
const html = buildHostHtml(code, theme, background);
return await withPageSession(state, async (sessionId) => {
await state.cdp.send(
"Emulation.setDeviceMetricsOverride",
{
width: 1280,
height: 800,
deviceScaleFactor: 1,
mobile: false,
},
{ sessionId },
);
await state.cdp.send(
"Page.setDocumentContent",
{ frameId: await getFrameId(state.cdp, sessionId), html },
{ sessionId },
);
const rect = await waitForMermaidSvg(state.cdp, sessionId, timeoutMs);
const cssWidth = Math.max(1, Math.ceil(rect.width));
const cssHeight = Math.max(1, Math.ceil(rect.height));
const targetCssWidth = Math.max(cssWidth, Math.ceil(minWidth ?? cssWidth));
const captureScale = scale * (targetCssWidth / cssWidth);
const bitmapWidth = Math.max(1, Math.ceil(cssWidth * captureScale));
const bitmapHeight = Math.max(1, Math.ceil(cssHeight * captureScale));
await state.cdp.send(
"Emulation.setDeviceMetricsOverride",
{
width: cssWidth,
height: cssHeight,
deviceScaleFactor: 1,
mobile: false,
},
{ sessionId },
);
const shot = await state.cdp.send<{ data: string }>(
"Page.captureScreenshot",
{
format: "png",
clip: {
x: rect.x,
y: rect.y,
width: cssWidth,
height: cssHeight,
scale: captureScale,
},
captureBeyondViewport: true,
},
{ sessionId },
);
const buffer = Buffer.from(shot.data, "base64");
await fs.promises.writeFile(outputPath, buffer);
return {
width: bitmapWidth,
height: bitmapHeight,
bytes: buffer.length,
};
});
}
async function getFrameId(cdp: CdpConnection, sessionId: string): Promise<string> {
const result = await cdp.send<{ frameTree: { frame: { id: string } } }>(
"Page.getFrameTree",
{},
{ sessionId },
);
return result.frameTree.frame.id;
}
export async function closeRenderer(): Promise<void> {
const state = rendererState;
rendererState = null;
if (!state) return;
try { state.cdp.close(); } catch {}
if (state.ownsChrome && state.chrome) {
try { state.chrome.kill("SIGTERM"); } catch {}
}
}
+102
View File
@@ -0,0 +1,102 @@
# baoyu-codex-imagegen
Generate images via Codex CLI's built-in `image_gen` tool from non-Codex runtimes (e.g., Claude Code). The wrapper spawns `codex exec --json` and lets the user's existing Codex subscription drive image generation — **no `OPENAI_API_KEY` required**.
This package implements the `preferred_image_backend: codex-imagegen` config key referenced across the `baoyu-skills` plugin and is the engine behind `baoyu-image-gen --provider codex-cli`.
## Layout
```
packages/baoyu-codex-imagegen/
├── src/
│ ├── main.ts # CLI orchestrator (executable via `#!/usr/bin/env bun`)
│ ├── spawn.ts # codex exec child-process wrapper
│ ├── parser.ts # JSONL event-stream parser
│ ├── validator.ts # Output PNG / image_gen-invocation verification
│ ├── cache.ts # SHA256 idempotency cache + file lock
│ ├── logger.ts # Structured JSONL logging
│ ├── types.ts # Shared types and `GenError`
│ └── *.test.ts # Bun unit tests
└── package.json # `bin` points to `src/main.ts`
```
## Prerequisites
```bash
npm install -g @openai/codex
codex login # signs in with your OpenAI account (subscription)
codex --version # confirm >= 0.130
```
`bun` is required for running the wrapper:
```bash
brew install oven-sh/bun/bun
```
If `bun` is not on `PATH`, `npx -y bun src/main.ts …` works as a fallback.
## Usage
```bash
# Inline prompt (executes via shebang once bun is on PATH)
./src/main.ts \
--image /tmp/cat.png \
--prompt "A friendly orange cat, watercolor"
# Or invoke bun explicitly
bun src/main.ts \
--image cover.png \
--prompt-file prompts/01-cover.md \
--aspect 16:9 \
--cache-dir ~/.cache/baoyu-codex-imagegen
# Without bun installed
npx -y bun src/main.ts --image cover.png --prompt "..."
```
Stdout emits a single JSON line:
```json
{"status":"ok","path":"…","bytes":1234567,"elapsed_seconds":62,"thread_id":"…","attempts":1,"cached":false,"usage":{}}
```
On failure:
```json
{"status":"error","path":"…","bytes":0,"error":"…","error_kind":"timeout"}
```
`error_kind` values: `codex_not_installed`, `invalid_args`, `prompt_file_missing`, `spawn_failed`, `timeout`, `no_image_gen_tool_use`, `output_missing`, `invalid_png`, `agent_refused`, `lock_busy`.
## Options
| Flag | Description |
|---|---|
| `--image <path>` | Output PNG path (required) |
| `--prompt <text>` | Prompt text |
| `--prompt-file <path>` | Read prompt from file (mutually exclusive with `--prompt`) |
| `--aspect <ratio>` | Aspect ratio (`1:1`, `16:9`, `9:16`, `4:3`, `2.35:1`). Default: `1:1` |
| `--ref <file>` | Reference image (repeatable) |
| `--timeout <ms>` | Codex exec timeout in ms. Default: `300000` |
| `--retries <n>` | Retry attempts on retryable errors. Default: `2` |
| `--retry-delay <ms>` | Base retry delay (exponential). Default: `1500` |
| `--cache-dir <path>` | Enable idempotency cache. Disabled by default. |
| `--log-file <path>` | Append structured JSONL log |
| `-v, --verbose` | Verbose stderr logging |
| `-h, --help` | Show help |
## Test
```bash
cd packages/baoyu-codex-imagegen
bun test
```
## Trade-offs
- 510× slower than direct OpenAI API calls (except on cache hits)
- Uses your Codex subscription — programmatic use of `codex exec` falls into the same terms as interactive use
- Requires `codex` CLI and active login session
See [`docs/codex-imagegen-backend.md`](../../docs/codex-imagegen-backend.md) for the full background.
@@ -0,0 +1,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"
}
}
@@ -0,0 +1,63 @@
import { test, expect } from "bun:test";
import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts";
test("cacheKey is deterministic and order-independent for refs", () => {
const k1 = cacheKey("hello", "16:9", ["a.png", "b.png"]);
const k2 = cacheKey("hello", "16:9", ["b.png", "a.png"]);
expect(k1).toBe(k2);
const k3 = cacheKey("hello", "16:9", []);
expect(k3).not.toBe(k1);
const k4 = cacheKey("hello", "1:1", []);
expect(k4).not.toBe(k3);
});
test("lookupCache returns null on miss, path on hit", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "cig-test-"));
try {
expect(await lookupCache(dir, "abc")).toBeNull();
const fake = path.join(dir, "abc.png");
await writeFile(fake, Buffer.alloc(2000));
expect(await lookupCache(dir, "abc")).toBe(fake);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("storeCache copies source into cache", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "cig-test-"));
const src = path.join(dir, "src.png");
try {
await writeFile(src, Buffer.from("xxxx".repeat(1000)));
await storeCache(dir, "key1", src);
const cached = await lookupCache(dir, "key1");
expect(cached).not.toBeNull();
const a = await readFile(src);
const b = await readFile(cached!);
expect(a.equals(b)).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("FileLock prevents concurrent acquisition", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "cig-lock-"));
try {
const lockPath = path.join(dir, "x.lock");
const lock1 = new FileLock(lockPath);
const lock2 = new FileLock(lockPath);
await lock1.acquire(1000);
let lock2Acquired = false;
const p = lock2.acquire(500).then(() => (lock2Acquired = true)).catch(() => {});
await new Promise((r) => setTimeout(r, 300));
expect(lock2Acquired).toBe(false);
await lock1.release();
await p;
expect(lock2Acquired).toBe(true);
await lock2.release();
} finally {
await rm(dir, { recursive: true, force: true });
}
});
@@ -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
View File
@@ -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,58 @@
import { test, expect } from "bun:test";
import { parseEventStream, hasImageGenInvocation } from "./parser.ts";
const REAL_PoC_STREAM = `{"type":"thread.started","thread_id":"019e40d3-30e3-7030-874d-773bc0d6d1eb"}
{"type":"turn.started"}
{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"sed -n '1,5p' /tmp/x.md","status":"in_progress"}}
{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"sed -n '1,5p' /tmp/x.md","exit_code":0,"status":"completed"}}
{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"cp /Users/x/.codex/generated_images/019e40d3/ig_abc.png /tmp/out.png","status":"in_progress"}}
{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"cp /Users/x/.codex/generated_images/019e40d3/ig_abc.png /tmp/out.png","exit_code":0,"status":"completed"}}
{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\\"status\\":\\"ok\\",\\"path\\":\\"/tmp/out.png\\",\\"bytes\\":1234567}"}}
{"type":"turn.completed","usage":{"input_tokens":100000,"cached_input_tokens":80000,"output_tokens":500,"reasoning_output_tokens":50}}`;
test("parseEventStream extracts threadId, toolCalls, agentMessage, usage", () => {
const r = parseEventStream(REAL_PoC_STREAM);
expect(r.threadId).toBe("019e40d3-30e3-7030-874d-773bc0d6d1eb");
expect(r.toolCalls.length).toBe(3);
expect(r.usage).toEqual({
input: 100000,
cached_input: 80000,
output: 500,
reasoning: 50,
});
expect(r.agentMessage).toContain('"status":"ok"');
});
test("parseEventStream tolerates malformed lines", () => {
const stream = `not json at all
{"type":"thread.started","thread_id":"abc"}
{partial json
{"type":"turn.completed","usage":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0}}`;
const r = parseEventStream(stream);
expect(r.threadId).toBe("abc");
expect(r.usage?.input).toBe(1);
});
test("hasImageGenInvocation does not infer image_gen from shell copies", () => {
const r = parseEventStream(REAL_PoC_STREAM);
const hasCp = r.toolCalls.some((tc) => tc.command?.includes("generated_images"));
expect(hasCp).toBe(true);
expect(hasImageGenInvocation(r.toolCalls)).toBe(false);
});
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,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,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 });
}
});
@@ -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 };
}
+15
View File
@@ -5,6 +5,21 @@ English | [简体中文](./CHANGELOG.zh-CN.md)
The format is inspired by Keep a Changelog, and the project follows Semantic The format is inspired by Keep a Changelog, and the project follows Semantic
Versioning. Versioning.
## [0.1.2] - 2026-04-21
#### Changed
- Upgraded Defuddle to 0.17.0 and jsdom to 29.0.2 for generic extraction.
- Added an `@xmldom/xmldom` override to keep the optional Defuddle MathML
dependency chain on a non-vulnerable release.
#### Fixed
- Fixed X/Twitter video extraction for single posts and X Articles by selecting
the highest-bitrate MP4 variant instead of the preview image URL.
- Fixed X Article media rendering so video entities are emitted as
`[video](...)` links instead of image embeds.
## [0.1.1] - 2026-03-27 ## [0.1.1] - 2026-03-27
#### Added #### Added
+12
View File
@@ -4,6 +4,18 @@
格式参考 Keep a Changelog,版本号遵循 Semantic Versioning。 格式参考 Keep a Changelog,版本号遵循 Semantic Versioning。
## [0.1.2] - 2026-04-21
### 变更
- 将 Defuddle 升级到 0.17.0、jsdom 升级到 29.0.2,用于通用页面提取。
- 新增 `@xmldom/xmldom` override,使 Defuddle 的可选 MathML 依赖链保持在无漏洞版本。
### 修复
- 修复 X/Twitter 单条内容和 X Article 的视频提取逻辑,改为选择最高码率 MP4 变体,而不是预览图 URL。
- 修复 X Article 媒体渲染,视频实体现在输出为 `[video](...)` 链接,而不是图片嵌入。
## [0.1.1] - 2026-03-27 ## [0.1.1] - 2026-03-27
### 新增 ### 新增
+52 -36
View File
@@ -1,13 +1,14 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "baoyu-fetch", "name": "baoyu-fetch",
"dependencies": { "dependencies": {
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"chrome-launcher": "^1.2.1", "chrome-launcher": "^1.2.1",
"defuddle": "^0.14.0", "defuddle": "^0.17.0",
"jsdom": "^26.0.0", "jsdom": "^29.0.2",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"turndown": "^7.2.0", "turndown": "^7.2.0",
@@ -24,11 +25,22 @@
}, },
}, },
}, },
"overrides": {
"@xmldom/xmldom": "0.8.13",
},
"packages": { "packages": {
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="],
"@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="],
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
"@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.0", "", { "dependencies": { "@changesets/config": "^3.1.3", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ=="], "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.0", "", { "dependencies": { "@changesets/config": "^3.1.3", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ=="],
"@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.9", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ=="], "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.9", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ=="],
@@ -63,15 +75,19 @@
"@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="],
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
"@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], "@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="],
"@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.0", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ=="],
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.3", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
"@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="],
@@ -107,9 +123,7 @@
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
@@ -123,6 +137,8 @@
"better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
@@ -143,13 +159,13 @@
"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-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-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="], "cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="],
"cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -157,7 +173,7 @@
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"defuddle": ["defuddle@0.14.0", "", { "dependencies": { "commander": "^12.1.0" }, "optionalDependencies": { "linkedom": "^0.18.12", "mathml-to-latex": "^1.5.0", "temml": "^0.13.1", "turndown": "^7.2.0" }, "bin": { "defuddle": "dist/cli.js" } }, "sha512-btavZGd1WgiVqrVM62WGRXMUi/aU7ckTZiq0xXWLZMHvzIqNZjwIFQEDRx8MarD7fIgsB90NXZ9xHJkKtapt2Q=="], "defuddle": ["defuddle@0.17.0", "", { "dependencies": { "commander": "^12.1.0" }, "optionalDependencies": { "linkedom": "^0.18.12", "mathml-to-latex": "^1.5.0", "temml": "^0.13.1", "turndown": "^7.2.0" }, "bin": { "defuddle": "dist/cli.js" } }, "sha512-rjRb9kxgrJLFhFJjXTSAitbmmSyvjEqGCi5JiyM6ImOf4leC/O+f7TPVPAymk/gnENFTrn9j7T/LTfW9IVwiLw=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
@@ -203,16 +219,12 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="], "human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
@@ -241,7 +253,7 @@
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], "jsdom": ["jsdom@29.0.2", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.5", "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w=="],
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
@@ -255,7 +267,7 @@
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
@@ -285,6 +297,8 @@
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"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": ["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=="],
@@ -351,8 +365,6 @@
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="],
"outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="],
"p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="], "p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="],
@@ -397,12 +409,12 @@
"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=="], "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=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
@@ -419,6 +431,8 @@
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
@@ -433,15 +447,15 @@
"term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="], "term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="],
"tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], "tldts": ["tldts@7.0.28", "", { "dependencies": { "tldts-core": "^7.0.28" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw=="],
"tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], "tldts-core": ["tldts-core@7.0.28", "", {}, "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
@@ -453,6 +467,8 @@
"uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="], "uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="],
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"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=="], "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=="],
@@ -473,13 +489,11 @@
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -503,11 +517,13 @@
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"jsdom/parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"read-yaml-file/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=="], "read-yaml-file/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=="],
"whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "jsdom/parse5/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
"read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
} }
+11 -5
View File
@@ -1,12 +1,14 @@
{ {
"name": "baoyu-fetch", "name": "baoyu-fetch",
"version": "0.1.1", "version": "0.1.2",
"license": "MIT",
"description": "Read URLs into high-quality Markdown or JSON with Chrome CDP and site adapters.", "description": "Read URLs into high-quality Markdown or JSON with Chrome CDP and site adapters.",
"type": "module", "type": "module",
"bin": { "bin": {
"baoyu-fetch": "./src/cli.ts" "baoyu-fetch": "dist/cli.js"
}, },
"files": [ "files": [
"dist",
"README.zh-CN.md", "README.zh-CN.md",
"src/adapters", "src/adapters",
"src/browser", "src/browser",
@@ -36,7 +38,8 @@
"dev": "bun run ./src/cli.ts", "dev": "bun run ./src/cli.ts",
"release": "changeset publish", "release": "changeset publish",
"test": "bun test", "test": "bun test",
"version-packages": "changeset version" "version-packages": "changeset version",
"prepack": "bun run build"
}, },
"engines": { "engines": {
"bun": ">=1.2.0" "bun": ">=1.2.0"
@@ -44,8 +47,8 @@
"dependencies": { "dependencies": {
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"chrome-launcher": "^1.2.1", "chrome-launcher": "^1.2.1",
"defuddle": "^0.14.0", "defuddle": "^0.17.0",
"jsdom": "^26.0.0", "jsdom": "^29.0.2",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"turndown": "^7.2.0", "turndown": "^7.2.0",
@@ -59,5 +62,8 @@
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"typescript": "^5.9.2" "typescript": "^5.9.2"
},
"overrides": {
"@xmldom/xmldom": "0.8.13"
} }
} }
@@ -339,4 +339,105 @@ describe("x article extraction", () => {
expect(content.markdown).toContain("https://example.com/report"); expect(content.markdown).toContain("https://example.com/report");
expect(content.markdown).not.toContain("https://t.co/example"); expect(content.markdown).not.toContain("https://t.co/example");
}); });
test("renders article video media as the highest bitrate mp4 link", () => {
const payload = {
data: {
tweetResult: {
result: {
rest_id: "2046628728210350366",
legacy: {
full_text: "Fallback text",
favorite_count: 12,
retweet_count: 3,
reply_count: 1,
created_at: "Tue Apr 21 16:34:47 +0000 2026",
},
core: {
user_results: {
result: {
legacy: {
name: "Google AI Studio",
screen_name: "GoogleAIStudio",
},
},
},
},
article: {
article_results: {
result: {
title: "Article with video",
media_entities: [
{
media_id: "2046627051822530560",
media_info: {
__typename: "ApiVideo",
variants: [
{
bit_rate: 2176000,
content_type: "video/mp4",
url: "https://video.twimg.com/amplify_video/2046627051822530560/vid/avc1/1280x720/medium.mp4",
},
{
content_type: "application/x-mpegURL",
url: "https://video.twimg.com/amplify_video/2046627051822530560/pl/playlist.m3u8",
},
{
bit_rate: 10368000,
content_type: "video/mp4",
url: "https://video.twimg.com/amplify_video/2046627051822530560/vid/avc1/1920x1080/high.mp4",
},
],
},
},
],
content_state: {
blocks: [
{
type: "atomic",
text: " ",
data: {},
entityRanges: [{ key: 0, length: 1, offset: 0 }],
inlineStyleRanges: [],
},
],
entityMap: [
{
key: "0",
value: {
type: "MEDIA",
mutability: "Immutable",
data: {
mediaItems: [{ mediaId: "2046627051822530560" }],
},
},
},
],
},
},
},
},
},
},
},
};
const document = extractArticleDocumentFromPayload(
payload,
"2046628728210350366",
"https://x.com/GoogleAIStudio/status/2046628728210350366",
);
expect(document).not.toBeNull();
const content = document?.content[0];
expect(content?.type).toBe("markdown");
if (!content || content.type !== "markdown") {
throw new Error("Expected markdown content");
}
expect(content.markdown).toBe(
"[video](https://video.twimg.com/amplify_video/2046627051822530560/vid/avc1/1920x1080/high.mp4)",
);
});
}); });
@@ -184,4 +184,77 @@ describe("x single tweet extraction", () => {
"Quoted Author (@quoted_author)\n\nQuoted tweet text\n\nphoto: https://pbs.twimg.com/media/quoted?format=jpg&name=4096x4096", "Quoted Author (@quoted_author)\n\nQuoted tweet text\n\nphoto: https://pbs.twimg.com/media/quoted?format=jpg&name=4096x4096",
}); });
}); });
test("uses the highest bitrate mp4 variant for tweet video media", () => {
const payload = {
data: {
tweetResult: {
result: {
rest_id: "2046628728210350366",
legacy: {
full_text: "Video post https://t.co/video",
favorite_count: 12,
retweet_count: 3,
reply_count: 1,
created_at: "Tue Apr 21 16:34:47 +0000 2026",
extended_entities: {
media: [
{
type: "video",
media_url_https: "https://pbs.twimg.com/amplify_video_thumb/2046627051822530560/img/poster.jpg",
url: "https://t.co/video",
video_info: {
variants: [
{
content_type: "application/x-mpegURL",
url: "https://video.twimg.com/amplify_video/2046627051822530560/pl/playlist.m3u8",
},
{
bitrate: 256000,
content_type: "video/mp4",
url: "https://video.twimg.com/amplify_video/2046627051822530560/vid/avc1/480x270/low.mp4",
},
{
bitrate: 10368000,
content_type: "video/mp4",
url: "https://video.twimg.com/amplify_video/2046627051822530560/vid/avc1/1920x1080/high.mp4",
},
],
},
},
],
},
},
core: {
user_results: {
result: {
legacy: {
name: "Google AI Studio",
screen_name: "GoogleAIStudio",
},
},
},
},
},
},
},
};
const document = extractSingleTweetDocumentFromPayload(
payload,
"2046628728210350366",
"https://x.com/GoogleAIStudio/status/2046628728210350366",
);
expect(document).not.toBeNull();
const listBlock = document?.content.find((block) => block.type === "list");
expect(listBlock).toEqual({
type: "list",
ordered: false,
items: [
"video: https://video.twimg.com/amplify_video/2046627051822530560/vid/avc1/1920x1080/high.mp4",
],
});
});
}); });
@@ -19,6 +19,10 @@ describe("parseYouTubeVideoId", () => {
test("parses shorts URLs", () => { test("parses shorts URLs", () => {
expect(parseYouTubeVideoId(new URL("https://www.youtube.com/shorts/abc123"))).toBe("abc123"); 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", () => { describe("parseYouTubeDescriptionChapters", () => {
+48 -20
View File
@@ -9,18 +9,43 @@ import {
getUser, getUser,
isRecord, isRecord,
normalizeTitle, normalizeTitle,
resolveBestXVideoVariantUrl,
toHighResXImageUrl, toHighResXImageUrl,
toXTweet, toXTweet,
} from "./shared"; } from "./shared";
import type { JsonObject } from "./types"; import type { JsonObject } from "./types";
function resolveArticleMediaUrl(mediaInfo: JsonObject): string { interface ArticleMedia {
kind: "image" | "video";
url: string;
}
function resolveArticleMedia(mediaInfo: JsonObject): ArticleMedia | null {
const videoUrl = resolveBestXVideoVariantUrl(mediaInfo);
if (videoUrl) {
return {
kind: "video",
url: videoUrl,
};
}
const rawUrl = const rawUrl =
(typeof mediaInfo.original_img_url === "string" && mediaInfo.original_img_url) || (typeof mediaInfo.original_img_url === "string" && mediaInfo.original_img_url) ||
(typeof mediaInfo.url === "string" && mediaInfo.url) || (typeof mediaInfo.url === "string" && mediaInfo.url) ||
""; "";
return rawUrl ? toHighResXImageUrl(rawUrl) : ""; if (!rawUrl) {
return null;
}
return {
kind: "image",
url: toHighResXImageUrl(rawUrl),
};
}
function resolveArticleMediaUrl(mediaInfo: JsonObject): string {
return resolveArticleMedia(mediaInfo)?.url ?? "";
} }
function normalizeEntityMap(entityMap: unknown): Map<string, JsonObject> { function normalizeEntityMap(entityMap: unknown): Map<string, JsonObject> {
@@ -139,8 +164,8 @@ function getTweetId(entityMap: Map<string, JsonObject>, entityKey: unknown): str
return data.tweetId; return data.tweetId;
} }
function buildMediaUrlMap(articleResult: JsonObject): Map<string, string> { function buildMediaMap(articleResult: JsonObject): Map<string, ArticleMedia> {
const mediaMap = new Map<string, string>(); const mediaMap = new Map<string, ArticleMedia>();
const mediaEntities = Array.isArray(articleResult.media_entities) ? articleResult.media_entities : []; const mediaEntities = Array.isArray(articleResult.media_entities) ? articleResult.media_entities : [];
for (const entity of mediaEntities) { for (const entity of mediaEntities) {
@@ -148,25 +173,28 @@ function buildMediaUrlMap(articleResult: JsonObject): Map<string, string> {
continue; continue;
} }
const mediaInfo = entity.media_info; const media = resolveArticleMedia(entity.media_info);
const url = resolveArticleMediaUrl(mediaInfo); if (media) {
if (url) { mediaMap.set(entity.media_id, media);
mediaMap.set(entity.media_id, url);
} }
} }
const coverMedia = isRecord(articleResult.cover_media) ? articleResult.cover_media : null; const coverMedia = isRecord(articleResult.cover_media) ? articleResult.cover_media : null;
if (coverMedia && typeof coverMedia.media_id === "string" && isRecord(coverMedia.media_info)) { if (coverMedia && typeof coverMedia.media_id === "string" && isRecord(coverMedia.media_info)) {
const url = resolveArticleMediaUrl(coverMedia.media_info); const media = resolveArticleMedia(coverMedia.media_info);
if (url) { if (media) {
mediaMap.set(coverMedia.media_id, url); mediaMap.set(coverMedia.media_id, media);
} }
} }
return mediaMap; return mediaMap;
} }
function getMediaMarkdown(entityMap: Map<string, JsonObject>, entityKey: unknown, mediaMap: Map<string, string>): string[] { function getMediaMarkdown(
entityMap: Map<string, JsonObject>,
entityKey: unknown,
mediaMap: Map<string, ArticleMedia>,
): string[] {
const key = const key =
typeof entityKey === "string" || typeof entityKey === "number" typeof entityKey === "string" || typeof entityKey === "number"
? String(entityKey) ? String(entityKey)
@@ -182,19 +210,19 @@ function getMediaMarkdown(entityMap: Map<string, JsonObject>, entityKey: unknown
const data = isRecord(entity.data) ? entity.data : {}; const data = isRecord(entity.data) ? entity.data : {};
const mediaItems = Array.isArray(data.mediaItems) ? data.mediaItems : []; const mediaItems = Array.isArray(data.mediaItems) ? data.mediaItems : [];
const urls: string[] = []; const media: ArticleMedia[] = [];
for (const item of mediaItems) { for (const item of mediaItems) {
if (!isRecord(item) || typeof item.mediaId !== "string") { if (!isRecord(item) || typeof item.mediaId !== "string") {
continue; continue;
} }
const url = mediaMap.get(item.mediaId); const mediaItem = mediaMap.get(item.mediaId);
if (url && !urls.includes(url)) { if (mediaItem && !media.some((value) => value.url === mediaItem.url)) {
urls.push(url); media.push(mediaItem);
} }
} }
return urls.map((url) => `![](${url})`); return media.map((item) => item.kind === "image" ? `![](${item.url})` : `[video](${item.url})`);
} }
function resolveTweetMarkdown(payloads: unknown[], tweetId: string, pageUrl: string): string | null { function resolveTweetMarkdown(payloads: unknown[], tweetId: string, pageUrl: string): string | null {
@@ -250,7 +278,7 @@ function replaceLinkEntities(text: string, block: JsonObject, entityMap: Map<str
function renderAtomicBlock( function renderAtomicBlock(
block: JsonObject, block: JsonObject,
entityMap: Map<string, JsonObject>, entityMap: Map<string, JsonObject>,
mediaMap: Map<string, string>, mediaMap: Map<string, ArticleMedia>,
payloads: unknown[], payloads: unknown[],
pageUrl: string, pageUrl: string,
): string | null { ): string | null {
@@ -293,7 +321,7 @@ function renderAtomicBlock(
function renderArticleBlocks( function renderArticleBlocks(
blocks: unknown[], blocks: unknown[],
entityMap: Map<string, JsonObject>, entityMap: Map<string, JsonObject>,
mediaMap: Map<string, string>, mediaMap: Map<string, ArticleMedia>,
payloads: unknown[], payloads: unknown[],
pageUrl: string, pageUrl: string,
): string { ): string {
@@ -396,7 +424,7 @@ export function extractArticleDocumentFromPayload(
const contentState = isRecord(articleResult.content_state) ? articleResult.content_state : {}; const contentState = isRecord(articleResult.content_state) ? articleResult.content_state : {};
const blocks = Array.isArray(contentState.blocks) ? contentState.blocks : []; const blocks = Array.isArray(contentState.blocks) ? contentState.blocks : [];
const entityMap = normalizeEntityMap(contentState.entityMap); const entityMap = normalizeEntityMap(contentState.entityMap);
const mediaMap = buildMediaUrlMap(articleResult); const mediaMap = buildMediaMap(articleResult);
const richMarkdown = renderArticleBlocks(blocks, entityMap, mediaMap, payloads, pageUrl); const richMarkdown = renderArticleBlocks(blocks, entityMap, mediaMap, payloads, pageUrl);
const plainText = typeof articleResult.plain_text === "string" ? articleResult.plain_text.trim() : ""; const plainText = typeof articleResult.plain_text === "string" ? articleResult.plain_text.trim() : "";
const markdown = richMarkdown || plainText || getTweetText(tweet); const markdown = richMarkdown || plainText || getTweetText(tweet);
+39 -2
View File
@@ -251,6 +251,39 @@ export function toHighResXImageUrl(rawUrl: string): string {
} }
} }
function getVideoVariantBitrate(variant: JsonObject): number {
const value = variant.bitrate ?? variant.bit_rate;
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function getVideoVariantContentType(variant: JsonObject): string {
const value = variant.content_type ?? variant.contentType;
return typeof value === "string" ? value.toLowerCase() : "";
}
export function resolveBestXVideoVariantUrl(mediaInfo: unknown): string | undefined {
if (!isRecord(mediaInfo)) {
return undefined;
}
const variantsSource =
Array.isArray(mediaInfo.variants)
? mediaInfo.variants
: isRecord(mediaInfo.video_info) && Array.isArray(mediaInfo.video_info.variants)
? mediaInfo.video_info.variants
: [];
const variants = variantsSource
.filter(
(variant): variant is JsonObject =>
isRecord(variant) && typeof variant.url === "string" && variant.url.length > 0,
)
.filter((variant) => getVideoVariantContentType(variant) === "video/mp4")
.sort((left, right) => getVideoVariantBitrate(right) - getVideoVariantBitrate(left));
return typeof variants[0]?.url === "string" ? variants[0].url : undefined;
}
export function getTweetMedia(tweet: JsonObject): XMedia[] { export function getTweetMedia(tweet: JsonObject): XMedia[] {
const legacy = getLegacy(tweet); const legacy = getLegacy(tweet);
const extendedEntities = isRecord(legacy.extended_entities) ? legacy.extended_entities : emptyObject(); const extendedEntities = isRecord(legacy.extended_entities) ? legacy.extended_entities : emptyObject();
@@ -268,10 +301,14 @@ export function getTweetMedia(tweet: JsonObject): XMedia[] {
alt: typeof value.ext_alt_text === "string" ? value.ext_alt_text : undefined, alt: typeof value.ext_alt_text === "string" ? value.ext_alt_text : undefined,
}; };
} }
if ((value.type === "video" || value.type === "animated_gif") && typeof value.media_url_https === "string") { if (value.type === "video" || value.type === "animated_gif") {
const videoUrl = resolveBestXVideoVariantUrl(value);
if (!videoUrl) {
return null;
}
return { return {
type: value.type, type: value.type,
url: value.media_url_https, url: videoUrl,
}; };
} }
return null; return null;
@@ -52,6 +52,11 @@ export function parseYouTubeVideoId(url: URL): string | null {
return liveMatch[1]; return liveMatch[1];
} }
const embedMatch = url.pathname.match(/^\/embed\/([^/?#]+)/);
if (embedMatch) {
return embedMatch[1];
}
return null; return null;
} }

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