mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 13:59:47 +08:00
Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b7a2e4175 | |||
| a4e78af813 | |||
| 1378544ea2 | |||
| c9a50cc908 | |||
| 348dc82612 | |||
| dba3d732c3 | |||
| 661bdd08ff | |||
| f490f7e5e3 | |||
| 22031aa2d2 | |||
| 51b31aa050 | |||
| 441ca307a6 | |||
| b0ac5233cc | |||
| c1f96f8421 | |||
| 4d2322fd02 | |||
| 154d0d1f52 | |||
| 7a956f6e7d | |||
| 55223daf5c | |||
| a1c4b732c5 | |||
| 894008c7f6 | |||
| 9daa6f5db3 | |||
| 53aa30bbca | |||
| 3f1120e903 | |||
| 7ea2692acd | |||
| 591614cfa5 | |||
| ad7a7a646d | |||
| ce84174bf7 | |||
| 06e84b92c3 | |||
| 67fa5cd329 | |||
| 3907281f48 | |||
| 011406036c | |||
| e6f4cd8a46 | |||
| a80eec7d75 | |||
| ec704c8afd | |||
| a85c81e8db | |||
| f06a9021a0 | |||
| d79ebe4838 | |||
| d3d1a9f7cd | |||
| 77dd193b58 | |||
| 84d817ed52 | |||
| 639e0b4193 | |||
| 84cefc2784 | |||
| 876f01ac19 | |||
| e1c0ff7c02 | |||
| 860dd36bb6 | |||
| aab0e28823 | |||
| 95cdad2a2a | |||
| 8838161729 | |||
| 49e9c46ca4 | |||
| 23fac03691 | |||
| 66160abb9f | |||
| b34e19daad | |||
| cda76e20fd | |||
| 8d2fd7aae0 | |||
| f60702cc8e | |||
| 613bee6bb8 | |||
| e63e21786d | |||
| b3a3b632c8 | |||
| 3aafe60463 | |||
| 246e90bf28 | |||
| c3cbce9ce3 | |||
| bc303345f4 | |||
| 17d7d6105c | |||
| 50c6c72944 | |||
| ad1755fa23 | |||
| 3cc1225b18 | |||
| 460bd087c6 | |||
| daf0fb7bec | |||
| adbfa3036b | |||
| 6026b619f0 | |||
| 1406a85331 | |||
| dac5867f8a | |||
| 49829e7f98 | |||
| df16bd5d1a | |||
| 9596d39e7b | |||
| 156f8627c2 | |||
| e0b861c148 | |||
| 3b29f3c57c | |||
| f8fb457f36 | |||
| 174e472a39 | |||
| 8b99fa7af0 | |||
| 6699b802c0 | |||
| edcdc19ac5 | |||
| e98fa33bc2 | |||
| 5d2a39c636 | |||
| db58bdee8c | |||
| 38cc497748 | |||
| 0f95d12a09 | |||
| 7fd9e51fc2 | |||
| a5d227b4e8 | |||
| 81377416b4 | |||
| a44bb08360 | |||
| a07669136c | |||
| b7298a60c6 | |||
| 309f078efb | |||
| 8958ba5409 | |||
| e6612628dc | |||
| bd5745f837 | |||
| cb26732559 | |||
| 9baf570caa | |||
| f99815cec9 | |||
| 8f0663d515 | |||
| 20ebf6126c | |||
| 64db328e61 | |||
| a3819b8e30 | |||
| 234c2a832b | |||
| 7943eb7b05 | |||
| 4b4a4d3863 | |||
| 919849c863 | |||
| 1faee80da4 | |||
| dea9322d0d | |||
| 86b1be83b8 | |||
| a1b935b2d8 | |||
| 9968d79a26 | |||
| 6f75fb17e4 | |||
| adba24281b | |||
| 61342ecfea | |||
| 5f753dd584 | |||
| 076192d58e | |||
| d6d434e714 | |||
| 6f3600d8e5 | |||
| 045fe5e57e | |||
| 0b3b7d13b5 | |||
| aa1a967a9f | |||
| d643bad53c | |||
| 0d977787b5 | |||
| 516803feb4 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.114.0"
|
||||
"version": "2.5.2"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
@@ -22,8 +22,9 @@
|
||||
"./skills/baoyu-danger-gemini-web",
|
||||
"./skills/baoyu-danger-x-to-markdown",
|
||||
"./skills/baoyu-diagram",
|
||||
"./skills/baoyu-electron-extract",
|
||||
"./skills/baoyu-format-markdown",
|
||||
"./skills/baoyu-imagine",
|
||||
"./skills/baoyu-image-gen",
|
||||
"./skills/baoyu-infographic",
|
||||
"./skills/baoyu-markdown-to-html",
|
||||
"./skills/baoyu-post-to-weibo",
|
||||
@@ -32,7 +33,8 @@
|
||||
"./skills/baoyu-slide-deck",
|
||||
"./skills/baoyu-translate",
|
||||
"./skills/baoyu-url-to-markdown",
|
||||
"./skills/baoyu-image-cards",
|
||||
"./skills/baoyu-wechat-summary",
|
||||
"./skills/baoyu-xhs-images",
|
||||
"./skills/baoyu-youtube-transcript"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -11,6 +11,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -18,8 +20,16 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Verify skill release commits
|
||||
run: npm run verify:skill-release-commits
|
||||
|
||||
- name: Install dependencies
|
||||
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
|
||||
run: npm test
|
||||
|
||||
@@ -173,3 +173,6 @@ diagram/
|
||||
.worktrees/
|
||||
youtube-transcript/
|
||||
.omx/
|
||||
.codex-tmp/
|
||||
outputs/
|
||||
wechat/
|
||||
|
||||
+232
@@ -2,6 +2,238 @@
|
||||
|
||||
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
|
||||
|
||||
+232
@@ -2,6 +2,238 @@
|
||||
|
||||
[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
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -31,7 +31,7 @@ Execute: `${BUN_X} skills/<skill>/scripts/main.ts [options]`
|
||||
|
||||
- **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)
|
||||
- **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)
|
||||
|
||||
## Security
|
||||
@@ -64,14 +64,25 @@ Skills that prompt users for choices MUST declare the tool-selection convention
|
||||
|
||||
## Image Generation Tools
|
||||
|
||||
Skills that render images MUST declare the backend-selection convention **inline** in exactly one place per `SKILL.md` — a `## Image Generation Tools` section near the top (after `## User Input Tools`). Do NOT link out to [docs/image-generation-tools.md](docs/image-generation-tools.md); that doc is the author-side canonical source — copy its body into each SKILL.md. Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) 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-imagine`, `baoyu-image-gen`, `baoyu-danger-gemini-web`) are exempt — they render directly rather than selecting a backend.
|
||||
Skills that render images MUST declare the backend-selection convention **inline** in exactly one place per `SKILL.md` — a `## Image Generation Tools` section near the top (after `## User Input Tools`). Do NOT link out to [docs/image-generation-tools.md](docs/image-generation-tools.md); that doc is the author-side canonical source — copy its body into each SKILL.md. Concrete tool names (`imagegen`, `GenerateImage`, `image_generate`, `baoyu-image-gen`) elsewhere in a skill are treated as examples — other runtimes substitute their local equivalent under the rule. The rule is stateless: use whatever backend is available; if multiple, ask the user once; if none, ask how to proceed. Every rendered image's full prompt must be written to a standalone `prompts/NN-*.md` file before any backend is invoked. Backend skills (`baoyu-image-gen`, `baoyu-danger-gemini-web`) are exempt — they render directly rather than selecting a backend.
|
||||
|
||||
## Deprecated Skills
|
||||
### `codex-imagegen` Backend
|
||||
|
||||
| Skill | Note |
|
||||
|-------|------|
|
||||
| `baoyu-image-gen` | Superseded by `baoyu-imagine`. Not in `.claude-plugin/marketplace.json`. Kept functional — sync any cross-cutting changes with `baoyu-imagine`. |
|
||||
| `baoyu-xhs-images` | Superseded by `baoyu-image-cards`. Not in `.claude-plugin/marketplace.json`. Kept functional — sync any cross-cutting changes with `baoyu-image-cards`. Do NOT update README for this skill. |
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -11,12 +11,39 @@ Skills shared by Baoyu for improving daily work efficiency with Claude Code.
|
||||
|
||||
## 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)
|
||||
|
||||
```bash
|
||||
npx skills add jimliu/baoyu-skills
|
||||
```
|
||||
|
||||
### Codex Project-Level Install
|
||||
|
||||
If you only need a subset of skills in one project, you do not need to install the full plugin. Codex scans `.agents/skills` inside a project, so copy or symlink each needed skill as a full directory:
|
||||
|
||||
```text
|
||||
<project>/.agents/skills/baoyu-cover-image/SKILL.md
|
||||
<project>/.agents/skills/baoyu-article-illustrator/SKILL.md
|
||||
<project>/.agents/skills/baoyu-post-to-wechat/SKILL.md
|
||||
```
|
||||
|
||||
For a WeChat Official Account article workflow, the usual minimal set is:
|
||||
|
||||
- `baoyu-cover-image`
|
||||
- `baoyu-article-illustrator`
|
||||
- `baoyu-post-to-wechat`
|
||||
|
||||
You do not need to install `baoyu-markdown-to-html` separately. `baoyu-post-to-wechat` already includes the Markdown to WeChat-ready HTML conversion flow. Install `baoyu-format-markdown` only if you need to first turn raw text or drafts into structured Markdown articles with titles, summaries, headings, bold text, lists, and similar formatting.
|
||||
|
||||
Place WeChat API credentials according to the scope you want:
|
||||
|
||||
- User-level: `~/.baoyu-skills/.env`
|
||||
- Project-level: `<project>/.baoyu-skills/.env`
|
||||
|
||||
Project-level `.env` files are useful when credentials should apply only to the current project. Do not commit them to Git.
|
||||
|
||||
### Publish to ClawHub / OpenClaw
|
||||
|
||||
This repository now supports publishing each `skills/baoyu-*` directory as an individual ClawHub skill.
|
||||
@@ -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:
|
||||
|
||||
```bash
|
||||
clawhub install baoyu-imagine
|
||||
clawhub install baoyu-image-gen
|
||||
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
|
||||
|
||||
Run the following command in Claude Code:
|
||||
Run the following command in the Agent:
|
||||
|
||||
```bash
|
||||
/plugin marketplace add JimLiu/baoyu-skills
|
||||
@@ -64,7 +91,7 @@ Run the following command in Claude Code:
|
||||
|
||||
**Option 3: Ask the Agent**
|
||||
|
||||
Simply tell Claude Code:
|
||||
Simply tell the Agent:
|
||||
|
||||
> 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:
|
||||
|
||||
1. Run `/plugin` in Claude Code
|
||||
1. Run `/plugin` in the Agent
|
||||
2. Switch to **Marketplaces** tab (use arrow keys or Tab)
|
||||
3. Select **baoyu-skills**
|
||||
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:
|
||||
|
||||
### Featured Design Skill: baoyu-design
|
||||
|
||||
If you want a design-focused Agent Skill, check out [JimLiu/baoyu-design](https://github.com/JimLiu/baoyu-design). It is a separate project that runs Claude Design locally in Cursor, Claude Code, Codex, Claude Desktop, or any file-capable coding agent, producing polished UI mockups, interactive prototypes, wireframes, landing pages, dashboards, mobile apps, and slide decks as self-contained HTML.
|
||||
|
||||
<a href="https://github.com/JimLiu/baoyu-design">
|
||||
<img src="https://raw.githubusercontent.com/JimLiu/baoyu-design/main/assets/screenshots/cursor-reader-mac-app.webp" alt="Cursor running baoyu-design" width="720">
|
||||
</a>
|
||||
|
||||
```bash
|
||||
npx skills add JimLiu/baoyu-design
|
||||
```
|
||||
|
||||
### Content Skills
|
||||
|
||||
Content generation and publishing skills.
|
||||
@@ -579,7 +618,7 @@ Plain text input is treated as a regular post. Markdown files are treated as X A
|
||||
|
||||
```bash
|
||||
# Post with text
|
||||
/baoyu-post-to-x "Hello from Claude Code!"
|
||||
/baoyu-post-to-x "Hello from AI Agent!"
|
||||
|
||||
# Post with images
|
||||
/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 |
|
||||
|--------|-------|--------------|
|
||||
| API (Recommended) | Fast | API credentials |
|
||||
| API (Recommended) | Fast | API credentials (local IP allowlisted in WeChat) |
|
||||
| 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):
|
||||
|
||||
@@ -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).
|
||||
|
||||
**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`:
|
||||
|
||||
```bash
|
||||
@@ -712,67 +763,67 @@ Post content to Weibo (微博). Supports regular posts with text, images, and vi
|
||||
|
||||
AI-powered generation backends.
|
||||
|
||||
#### baoyu-imagine
|
||||
#### baoyu-image-gen
|
||||
|
||||
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
|
||||
# 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
|
||||
/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)
|
||||
/baoyu-imagine --prompt "A banner" --image banner.png --quality 2k
|
||||
/baoyu-image-gen --prompt "A banner" --image banner.png --quality 2k
|
||||
|
||||
# Specific provider
|
||||
/baoyu-imagine --prompt "A cat" --image cat.png --provider openai --model gpt-image-2
|
||||
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openai --model gpt-image-2
|
||||
|
||||
# Azure OpenAI (model = deployment name)
|
||||
/baoyu-imagine --prompt "A cat" --image cat.png --provider azure --model gpt-image-2
|
||||
/baoyu-image-gen --prompt "A cat" --image cat.png --provider azure --model gpt-image-2
|
||||
|
||||
# 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
|
||||
/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)
|
||||
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
||||
|
||||
# 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
|
||||
/baoyu-imagine --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai
|
||||
/baoyu-image-gen --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai
|
||||
|
||||
# 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
|
||||
/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)
|
||||
/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
|
||||
/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
|
||||
/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 (即梦)
|
||||
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
|
||||
# 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)
|
||||
/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
|
||||
/baoyu-imagine --batchfile batch.json --jobs 4 --json
|
||||
/baoyu-image-gen --batchfile batch.json --jobs 4 --json
|
||||
```
|
||||
|
||||
**Options**:
|
||||
@@ -783,7 +834,7 @@ AI SDK-based image generation using OpenAI GPT Image 2, Azure OpenAI, Google, Op
|
||||
| `--image` | Output image path (required) |
|
||||
| `--batchfile` | JSON batch file for multi-image generation |
|
||||
| `--jobs` | Worker count for batch mode |
|
||||
| `--provider` | `google`, `openai`, `azure`, `openrouter`, `dashscope`, `zai`, `minimax`, `jimeng`, `seedream`, or `replicate` |
|
||||
| `--provider` | `google`, `openai`, `azure`, `openrouter`, `dashscope`, `zai`, `minimax`, `jimeng`, `seedream`, `replicate`, or `agnes` |
|
||||
| `--model`, `-m` | Model ID or deployment name. Azure uses deployment name; OpenRouter uses full model IDs; Z.AI uses `glm-image`; MiniMax uses `image-01` / `image-01-live` |
|
||||
| `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||
| `--size` | Size (e.g., `1024x1024`; `gpt-image-2` accepts valid custom sizes up to 3840px max edge) |
|
||||
@@ -813,8 +864,8 @@ AI SDK-based image generation using OpenAI GPT Image 2, Azure OpenAI, Google, Op
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-2` |
|
||||
| `AZURE_OPENAI_DEPLOYMENT` | Azure default deployment name | - |
|
||||
| `AZURE_OPENAI_IMAGE_MODEL` | Backward-compatible Azure deployment/model alias | `gpt-image-2` |
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model | `google/gemini-3.1-flash-image-preview` |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model | `google/gemini-3.1-flash-image` |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `qwen-image-2.0-pro` |
|
||||
| `ZAI_IMAGE_MODEL` | Z.AI model | `glm-image` |
|
||||
| `BIGMODEL_IMAGE_MODEL` | Backward-compatible alias for Z.AI model | `glm-image` |
|
||||
@@ -834,7 +885,7 @@ AI SDK-based image generation using OpenAI GPT Image 2, Azure OpenAI, Google, Op
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | - |
|
||||
| `ZAI_BASE_URL` | Custom Z.AI endpoint | `https://api.z.ai/api/paas/v4` |
|
||||
| `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 | - |
|
||||
| `JIMENG_BASE_URL` | Custom Jimeng endpoint | `https://visual.volcengineapi.com` |
|
||||
| `JIMENG_REGION` | Jimeng region | `cn-north-1` |
|
||||
@@ -851,15 +902,15 @@ AI SDK-based image generation using OpenAI GPT Image 2, Azure OpenAI, Google, Op
|
||||
- MiniMax reference images are sent as `subject_reference`; the current API is specialized toward character / portrait consistency.
|
||||
- Jimeng does not support reference images.
|
||||
- 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 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**:
|
||||
1. If `--provider` is specified → use it
|
||||
2. If `--ref` is provided and no provider is specified → try Google, then OpenAI, Azure, OpenRouter, Replicate, Seedream, and finally MiniMax
|
||||
2. If `--ref` is provided and no provider is specified → try Google, then OpenAI, Azure, OpenRouter, Replicate, Seedream, MiniMax, and finally Agnes
|
||||
3. If only one API key is available → use that provider
|
||||
4. If multiple providers are available → default to Google, then OpenAI, Azure, OpenRouter, DashScope, Z.AI, MiniMax, Replicate, Jimeng, Seedream
|
||||
4. If multiple providers are available → default to Google, then OpenAI, Azure, OpenRouter, DashScope, Z.AI, MiniMax, Replicate, Jimeng, Seedream, Agnes
|
||||
|
||||
#### baoyu-danger-gemini-web
|
||||
|
||||
@@ -1112,12 +1163,80 @@ Custom style descriptions are also accepted, e.g., `--style "poetic and lyrical"
|
||||
- Translator's notes for cultural/domain-specific references
|
||||
- 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
|
||||
|
||||
Some skills require API keys or custom configuration. Environment variables can be set in `.env` files:
|
||||
|
||||
**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)
|
||||
3. `<cwd>/.baoyu-skills/.env` (project-level)
|
||||
4. `~/.baoyu-skills/.env` (user-level)
|
||||
@@ -1144,14 +1263,14 @@ AZURE_OPENAI_DEPLOYMENT=gpt-image-2
|
||||
|
||||
# OpenRouter
|
||||
OPENROUTER_API_KEY=sk-or-xxx
|
||||
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image-preview
|
||||
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image
|
||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
# OPENROUTER_HTTP_REFERER=https://your-app.example.com
|
||||
# OPENROUTER_TITLE=Your App Name
|
||||
|
||||
# Google
|
||||
GOOGLE_API_KEY=xxx
|
||||
GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
||||
GOOGLE_IMAGE_MODEL=gemini-3-pro-image
|
||||
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
||||
|
||||
# DashScope (Aliyun Tongyi Wanxiang)
|
||||
@@ -1167,7 +1286,7 @@ ZAI_IMAGE_MODEL=glm-image
|
||||
# MiniMax
|
||||
MINIMAX_API_KEY=xxx
|
||||
MINIMAX_IMAGE_MODEL=image-01
|
||||
# MINIMAX_BASE_URL=https://api.minimax.io
|
||||
# MINIMAX_BASE_URL=https://api.minimaxi.com
|
||||
|
||||
# Replicate
|
||||
REPLICATE_API_TOKEN=r8_xxx
|
||||
@@ -1268,7 +1387,9 @@ This project was inspired by and builds upon the following open source projects:
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Unless otherwise noted, this repository is licensed under the [MIT License](./LICENSE).
|
||||
|
||||
Published ClawHub skills follow ClawHub registry rules and are distributed under `MIT-0`. Third-party code and assets retain their original licenses where noted.
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
+163
-41
@@ -2,7 +2,17 @@
|
||||
|
||||
[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
|
||||
npx skills add jimliu/baoyu-skills
|
||||
```
|
||||
|
||||
### Codex 项目级安装
|
||||
|
||||
如果只在某个项目中使用部分技能,不需要安装整个插件。Codex 会扫描项目里的 `.agents/skills`,可以只把需要的 skill 按整个目录复制或软链接到当前项目:
|
||||
|
||||
```text
|
||||
<project>/.agents/skills/baoyu-cover-image/SKILL.md
|
||||
<project>/.agents/skills/baoyu-article-illustrator/SKILL.md
|
||||
<project>/.agents/skills/baoyu-post-to-wechat/SKILL.md
|
||||
```
|
||||
|
||||
公众号文章发布的最小组合通常是:
|
||||
|
||||
- `baoyu-cover-image`
|
||||
- `baoyu-article-illustrator`
|
||||
- `baoyu-post-to-wechat`
|
||||
|
||||
不需要单独安装 `baoyu-markdown-to-html`,`baoyu-post-to-wechat` 已内置 Markdown 到公众号可用 HTML 的转换流程。只有需要先把原始文本或草稿整理成 Markdown 文章结构(标题、摘要、层级标题、加粗、列表等)时,再额外安装 `baoyu-format-markdown`。
|
||||
|
||||
公众号 API 凭证按作用范围放置:
|
||||
|
||||
- 全局:`~/.baoyu-skills/.env`
|
||||
- 项目:`<project>/.baoyu-skills/.env`
|
||||
|
||||
项目级 `.env` 适合只给当前项目使用,注意不要提交到 Git。
|
||||
|
||||
### 发布到 ClawHub / OpenClaw
|
||||
|
||||
现在这个仓库支持把每个 `skills/baoyu-*` 目录作为独立 ClawHub skill 发布。
|
||||
@@ -32,7 +69,7 @@ npx skills add jimliu/baoyu-skills
|
||||
ClawHub 按“单个 skill”安装,不是把整个 marketplace 一次性装进去。发布后,用户可以按需安装:
|
||||
|
||||
```bash
|
||||
clawhub install baoyu-imagine
|
||||
clawhub install baoyu-image-gen
|
||||
clawhub install baoyu-markdown-to-html
|
||||
```
|
||||
|
||||
@@ -40,7 +77,7 @@ clawhub install baoyu-markdown-to-html
|
||||
|
||||
### 注册插件市场
|
||||
|
||||
在 Claude Code 中运行:
|
||||
在 Agent 中运行:
|
||||
|
||||
```bash
|
||||
/plugin marketplace add JimLiu/baoyu-skills
|
||||
@@ -64,7 +101,7 @@ clawhub install baoyu-markdown-to-html
|
||||
|
||||
**方式三:告诉 Agent**
|
||||
|
||||
直接告诉 Claude Code:
|
||||
直接告诉 Agent:
|
||||
|
||||
> 请帮我安装 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)
|
||||
3. 选择 **baoyu-skills**
|
||||
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)
|
||||
|
||||
内容生成和发布技能。
|
||||
@@ -579,7 +628,7 @@ clawhub install baoyu-markdown-to-html
|
||||
|
||||
```bash
|
||||
# 发布文字
|
||||
/baoyu-post-to-x "Hello from Claude Code!"
|
||||
/baoyu-post-to-x "Hello from AI Agent!"
|
||||
|
||||
# 发布带图片
|
||||
/baoyu-post-to-x "看看这个" --image photo.png
|
||||
@@ -612,8 +661,9 @@ clawhub install baoyu-markdown-to-html
|
||||
|
||||
| 方式 | 速度 | 要求 |
|
||||
|------|------|------|
|
||||
| API(推荐) | 快 | API 凭证 |
|
||||
| API(推荐) | 快 | API 凭证(本机 IP 在公众号白名单内) |
|
||||
| 浏览器 | 慢 | Chrome,登录会话 |
|
||||
| 远程 API | 快 | API 凭证 + 一台 IP 在公众号白名单内、可 SSH 登录的服务器 |
|
||||
|
||||
**API 配置**(更快的发布方式):
|
||||
|
||||
@@ -631,6 +681,8 @@ WECHAT_APP_SECRET=你的AppSecret
|
||||
|
||||
**浏览器方式**(无需 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` 管理多个微信公众号:
|
||||
|
||||
```bash
|
||||
@@ -653,8 +705,8 @@ accounts:
|
||||
default_author: 宝玉
|
||||
need_open_comment: 1
|
||||
only_fans_can_comment: 0
|
||||
app_id: 你的微信AppID
|
||||
app_secret: 你的微信AppSecret
|
||||
app_id: 你的微信 AppID
|
||||
app_secret: 你的微信 AppSecret
|
||||
- name: AI 工具集
|
||||
alias: ai-tools
|
||||
default_publish_method: browser
|
||||
@@ -712,67 +764,67 @@ accounts:
|
||||
|
||||
AI 驱动的生成后端。
|
||||
|
||||
#### baoyu-imagine
|
||||
#### baoyu-image-gen
|
||||
|
||||
基于 AI SDK 的图像生成,支持 OpenAI GPT Image 2、Azure OpenAI、Google、OpenRouter、DashScope(阿里通义万相)、MiniMax、即梦(Jimeng)、豆包(Seedream)和 Replicate API。支持文生图、参考图、宽高比、自定义尺寸、批量生成和质量预设。
|
||||
|
||||
```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 分辨率)
|
||||
/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 --model gpt-image-2
|
||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openai --model gpt-image-2
|
||||
|
||||
# Azure OpenAI(model 为部署名称)
|
||||
/baoyu-imagine --prompt "一只猫" --image cat.png --provider azure --model gpt-image-2
|
||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider azure --model gpt-image-2
|
||||
|
||||
# OpenRouter
|
||||
/baoyu-imagine --prompt "一只猫" --image cat.png --provider openrouter
|
||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider 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(阿里通义万相)
|
||||
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider 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
|
||||
/baoyu-imagine --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai
|
||||
/baoyu-image-gen --prompt "一张带清晰中文标题的科技海报" --image out.png --provider zai
|
||||
|
||||
# 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 + 角色参考图
|
||||
/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)
|
||||
/baoyu-imagine --prompt "一只猫" --image cat.png --provider replicate
|
||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider replicate
|
||||
|
||||
# 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
|
||||
/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)
|
||||
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
|
||||
# 豆包(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)
|
||||
/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,7 +835,7 @@ AI 驱动的生成后端。
|
||||
| `--image` | 输出图片路径(必需) |
|
||||
| `--batchfile` | 多图批量生成的 JSON 文件 |
|
||||
| `--jobs` | 批量模式的并发 worker 数 |
|
||||
| `--provider` | `google`、`openai`、`azure`、`openrouter`、`dashscope`、`zai`、`minimax`、`jimeng`、`seedream` 或 `replicate` |
|
||||
| `--provider` | `google`、`openai`、`azure`、`openrouter`、`dashscope`、`zai`、`minimax`、`jimeng`、`seedream`、`replicate` 或 `agnes` |
|
||||
| `--model`, `-m` | 模型 ID 或部署名。Azure 使用部署名;OpenRouter 使用完整模型 ID;Z.AI 使用 `glm-image`;MiniMax 使用 `image-01` / `image-01-live` |
|
||||
| `--ar` | 宽高比(如 `16:9`、`1:1`、`4:3`) |
|
||||
| `--size` | 尺寸(如 `1024x1024`;`gpt-image-2` 支持最长边不超过 3840px 的有效自定义尺寸) |
|
||||
@@ -813,8 +865,8 @@ AI 驱动的生成后端。
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-2` |
|
||||
| `AZURE_OPENAI_DEPLOYMENT` | Azure 默认部署名 | - |
|
||||
| `AZURE_OPENAI_IMAGE_MODEL` | 兼容旧配置的 Azure 部署/模型别名 | `gpt-image-2` |
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image-preview` |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image` |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `qwen-image-2.0-pro` |
|
||||
| `ZAI_IMAGE_MODEL` | Z.AI 模型 | `glm-image` |
|
||||
| `BIGMODEL_IMAGE_MODEL` | Z.AI 模型向后兼容别名 | `glm-image` |
|
||||
@@ -834,7 +886,7 @@ AI 驱动的生成后端。
|
||||
| `DASHSCOPE_BASE_URL` | 自定义 DashScope 端点 | - |
|
||||
| `ZAI_BASE_URL` | 自定义 Z.AI 端点 | `https://api.z.ai/api/paas/v4` |
|
||||
| `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 端点 | - |
|
||||
| `JIMENG_BASE_URL` | 自定义即梦端点 | `https://visual.volcengineapi.com` |
|
||||
| `JIMENG_REGION` | 即梦区域 | `cn-north-1` |
|
||||
@@ -851,15 +903,15 @@ AI 驱动的生成后端。
|
||||
- MiniMax 参考图会走 `subject_reference`,当前能力更偏角色 / 人像一致性。
|
||||
- 即梦不支持参考图。
|
||||
- 豆包参考图能力仅适用于 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 的参数能力按模型家族区分:nano-banana 走 `--quality` / `--ar`,Seedream 走校验后的 `--size` / `--ar`,Wan 走校验后的 `--size`(`--ar` 会先在本地换算成具体尺寸)。
|
||||
|
||||
**服务商自动选择**:
|
||||
1. 如果指定了 `--provider` → 使用指定的
|
||||
2. 如果传了 `--ref` 且未指定 provider → 依次尝试 Google、OpenAI、Azure、OpenRouter、Replicate、Seedream,最后是 MiniMax
|
||||
2. 如果传了 `--ref` 且未指定 provider → 依次尝试 Google、OpenAI、Azure、OpenRouter、Replicate、Seedream、MiniMax,最后是 Agnes
|
||||
3. 如果只有一个 API 密钥 → 使用对应服务商
|
||||
4. 如果多个可用 → 默认使用 Google,然后依次为 OpenAI、Azure、OpenRouter、DashScope、Z.AI、MiniMax、Replicate、即梦、豆包
|
||||
4. 如果多个可用 → 默认使用 Google,然后依次为 OpenAI、Azure、OpenRouter、DashScope、Z.AI、MiniMax、Replicate、即梦、豆包、Agnes
|
||||
|
||||
#### baoyu-danger-gemini-web
|
||||
|
||||
@@ -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` 文件中设置:
|
||||
|
||||
**加载优先级**(高优先级覆盖低优先级):
|
||||
1. 命令行环境变量(如 `OPENAI_API_KEY=xxx /baoyu-imagine ...`)
|
||||
1. 命令行环境变量(如 `OPENAI_API_KEY=xxx /baoyu-image-gen ...`)
|
||||
2. `process.env`(系统环境变量)
|
||||
3. `<cwd>/.baoyu-skills/.env`(项目级)
|
||||
4. `~/.baoyu-skills/.env`(用户级)
|
||||
@@ -1144,14 +1264,14 @@ AZURE_OPENAI_DEPLOYMENT=gpt-image-2
|
||||
|
||||
# OpenRouter
|
||||
OPENROUTER_API_KEY=sk-or-xxx
|
||||
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image-preview
|
||||
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image
|
||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
# OPENROUTER_HTTP_REFERER=https://your-app.example.com
|
||||
# OPENROUTER_TITLE=你的应用名
|
||||
|
||||
# Google
|
||||
GOOGLE_API_KEY=xxx
|
||||
GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
||||
GOOGLE_IMAGE_MODEL=gemini-3-pro-image
|
||||
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
||||
|
||||
# DashScope(阿里通义万相)
|
||||
@@ -1167,7 +1287,7 @@ ZAI_IMAGE_MODEL=glm-image
|
||||
# MiniMax
|
||||
MINIMAX_API_KEY=xxx
|
||||
MINIMAX_IMAGE_MODEL=image-01
|
||||
# MINIMAX_BASE_URL=https://api.minimax.io
|
||||
# MINIMAX_BASE_URL=https://api.minimaxi.com
|
||||
|
||||
# Replicate
|
||||
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
|
||||
|
||||
|
||||
@@ -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 | 50–90 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. **5–10× 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 |
|
||||
@@ -165,11 +165,11 @@ Standard snippet (copy verbatim):
|
||||
|
||||
When this skill needs to render an image:
|
||||
|
||||
- **Use whatever image-generation tool or skill is available** in the current runtime — e.g., Codex `imagegen`, Hermes `image_generate`, `baoyu-imagine`, or any equivalent the user has installed.
|
||||
- **Use whatever image-generation tool or skill is available** in the current runtime — e.g., Codex `imagegen`, Cursor `GenerateImage`, Hermes `image_generate`, `baoyu-image-gen`, or any equivalent the user has installed.
|
||||
- **If multiple are available**, ask the user **once** at the start which to use (batch with any other initial questions).
|
||||
- **If none are available**, tell the user and ask how to proceed.
|
||||
|
||||
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
|
||||
|
||||
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
|
||||
Concrete tool names (`imagegen`, `GenerateImage`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Image Generation Tools
|
||||
|
||||
Skills in this repo are loaded by multiple agent runtimes (Claude Code, Codex, Hermes, other agents, bare CLI). Each runtime exposes a different image-generation capability — some have a runtime-native tool (Codex `imagegen`, Hermes `image_generate`), others rely on an installed skill (`baoyu-imagine`, or user-defined). This document defines the canonical **backend-selection rule** every skill that renders images follows so skills stay portable.
|
||||
Skills in this repo are loaded by multiple agent runtimes (Claude Code, Codex, Cursor, Hermes, other agents, bare CLI). Each runtime exposes a different image-generation capability — some have a runtime-native tool (Codex `imagegen`, Cursor `GenerateImage`, Hermes `image_generate`), others rely on an installed skill (`baoyu-image-gen`, or user-defined). This document defines the canonical **backend-selection rule** every skill that renders images follows so skills stay portable.
|
||||
|
||||
## The Rule
|
||||
|
||||
@@ -9,11 +9,15 @@ 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):
|
||||
- If the current runtime exposes a native image tool (e.g., Codex `imagegen`, Hermes `image_generate`), use it. Runtime-native tools are preferred by default — agents that know their own tool inventory should surface the native one here.
|
||||
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-imagine`), use it.
|
||||
- **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
|
||||
@@ -24,7 +28,7 @@ Each image-consuming skill's `EXTEND.md` carries a single `preferred_image_backe
|
||||
|---|---|
|
||||
| `auto` (default) | Apply the auto-select rule — runtime-native preferred, fall back to only installed backend, ask if multiple non-native. |
|
||||
| `ask` | Always confirm the backend on every run, even when a runtime-native tool exists. |
|
||||
| `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) | Pin this backend when available; fall back to `auto` if it isn't. |
|
||||
| `<backend-id>` (e.g., `codex-imagegen`, `baoyu-image-gen`, `GenerateImage`, `image_generate`) | Pin this backend when available; fall back to `auto` if it isn't. |
|
||||
|
||||
The field is **absent-equals-auto**: older `EXTEND.md` files without this field behave exactly as if `preferred_image_backend: auto` were set. No schema version bump is needed to introduce it.
|
||||
|
||||
@@ -38,8 +42,8 @@ Each `SKILL.md` that renders images includes **exactly one** `## Image Generatio
|
||||
|
||||
Each skill's `references/config/preferences-schema.md` (and its `EXTEND.md` template in `first-time-setup.md`) lists `preferred_image_backend` alongside other preference fields. First-time setup does NOT ask the user about the backend — `auto` is set silently. Users who want to pin a specific backend edit `EXTEND.md` later, and each skill's `## Changing Preferences` section documents the common one-line edits.
|
||||
|
||||
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) in this document and in SKILL.md are **examples** — agents in other runtimes apply the rule above and substitute the local equivalent. Skill-specific parameters for these backends are illustrative; runtimes without those knobs can omit them.
|
||||
Concrete tool names (`imagegen`, `GenerateImage`, `image_generate`, `baoyu-image-gen`) in this document and in SKILL.md are **examples** — agents in other runtimes apply the rule above and substitute the local equivalent. Skill-specific parameters for these backends are illustrative; runtimes without those knobs can omit them.
|
||||
|
||||
## Backend Skills Are Exempt
|
||||
|
||||
Skills that **are themselves** image-generation backends — currently `baoyu-imagine`, `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.
|
||||
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.
|
||||
|
||||
@@ -28,7 +28,7 @@ Skills that require image generation MUST delegate to available image generation
|
||||
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
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ 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: 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
|
||||
|
||||
`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.
|
||||
|
||||
Generated
+19
-1
@@ -5,6 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "baoyu-skills",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -1787,6 +1788,10 @@
|
||||
"resolved": "packages/baoyu-chrome-cdp",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/baoyu-codex-imagegen": {
|
||||
"resolved": "packages/baoyu-codex-imagegen",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/baoyu-fetch": {
|
||||
"resolved": "packages/baoyu-fetch",
|
||||
"link": true
|
||||
@@ -5013,13 +5018,25 @@
|
||||
}
|
||||
},
|
||||
"packages/baoyu-chrome-cdp": {
|
||||
"version": "0.1.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.2.0"
|
||||
}
|
||||
},
|
||||
"packages/baoyu-codex-imagegen": {
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"codex-imagegen": "src/main.ts"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.2.0"
|
||||
}
|
||||
},
|
||||
"packages/baoyu-fetch": {
|
||||
"version": "0.1.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"chrome-launcher": "^1.2.1",
|
||||
@@ -5047,7 +5064,8 @@
|
||||
}
|
||||
},
|
||||
"packages/baoyu-md": {
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
"front-matter": "^4.0.2",
|
||||
|
||||
+3
-1
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "baoyu-skills",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"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": {
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
|
||||
+2029
File diff suppressed because one or more lines are too long
+2029
File diff suppressed because one or more lines are too long
+729
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "baoyu-chrome-cdp",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist",
|
||||
"src/**/*.ts",
|
||||
"assets",
|
||||
"!src/**/*.test.ts"
|
||||
],
|
||||
"exports": {
|
||||
@@ -14,7 +16,14 @@
|
||||
"require": "./dist/index.cjs",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./src/*": "./src/*"
|
||||
"./mermaid": {
|
||||
"types": "./src/mermaid.ts",
|
||||
"import": "./dist/mermaid.js",
|
||||
"require": "./dist/mermaid.cjs",
|
||||
"default": "./dist/mermaid.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./assets/*": "./assets/*"
|
||||
},
|
||||
"description": "Shared Chrome DevTools Protocol utilities for baoyu skills.",
|
||||
"main": "./dist/index.cjs",
|
||||
@@ -33,7 +42,7 @@
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build-shared-package.mjs",
|
||||
"build": "node ../../scripts/build-shared-package.mjs --entry-out src/index.ts:index --entry-out src/mermaid.ts:mermaid --asset assets:assets",
|
||||
"prepack": "bun run build"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# baoyu-codex-imagegen
|
||||
|
||||
Generate images via Codex CLI's built-in `image_gen` tool from non-Codex runtimes (e.g., Claude Code). The wrapper spawns `codex exec --json` and lets the user's existing Codex subscription drive image generation — **no `OPENAI_API_KEY` required**.
|
||||
|
||||
This package implements the `preferred_image_backend: codex-imagegen` config key referenced across the `baoyu-skills` plugin and is the engine behind `baoyu-image-gen --provider codex-cli`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
packages/baoyu-codex-imagegen/
|
||||
├── src/
|
||||
│ ├── main.ts # CLI orchestrator (executable via `#!/usr/bin/env bun`)
|
||||
│ ├── spawn.ts # codex exec child-process wrapper
|
||||
│ ├── parser.ts # JSONL event-stream parser
|
||||
│ ├── validator.ts # Output PNG / image_gen-invocation verification
|
||||
│ ├── cache.ts # SHA256 idempotency cache + file lock
|
||||
│ ├── logger.ts # Structured JSONL logging
|
||||
│ ├── types.ts # Shared types and `GenError`
|
||||
│ └── *.test.ts # Bun unit tests
|
||||
└── package.json # `bin` points to `src/main.ts`
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
npm install -g @openai/codex
|
||||
codex login # signs in with your OpenAI account (subscription)
|
||||
codex --version # confirm >= 0.130
|
||||
```
|
||||
|
||||
`bun` is required for running the wrapper:
|
||||
|
||||
```bash
|
||||
brew install oven-sh/bun/bun
|
||||
```
|
||||
|
||||
If `bun` is not on `PATH`, `npx -y bun src/main.ts …` works as a fallback.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Inline prompt (executes via shebang once bun is on PATH)
|
||||
./src/main.ts \
|
||||
--image /tmp/cat.png \
|
||||
--prompt "A friendly orange cat, watercolor"
|
||||
|
||||
# Or invoke bun explicitly
|
||||
bun src/main.ts \
|
||||
--image cover.png \
|
||||
--prompt-file prompts/01-cover.md \
|
||||
--aspect 16:9 \
|
||||
--cache-dir ~/.cache/baoyu-codex-imagegen
|
||||
|
||||
# Without bun installed
|
||||
npx -y bun src/main.ts --image cover.png --prompt "..."
|
||||
```
|
||||
|
||||
Stdout emits a single JSON line:
|
||||
|
||||
```json
|
||||
{"status":"ok","path":"…","bytes":1234567,"elapsed_seconds":62,"thread_id":"…","attempts":1,"cached":false,"usage":{…}}
|
||||
```
|
||||
|
||||
On failure:
|
||||
|
||||
```json
|
||||
{"status":"error","path":"…","bytes":0,"error":"…","error_kind":"timeout"}
|
||||
```
|
||||
|
||||
`error_kind` values: `codex_not_installed`, `invalid_args`, `prompt_file_missing`, `spawn_failed`, `timeout`, `no_image_gen_tool_use`, `output_missing`, `invalid_png`, `agent_refused`, `lock_busy`.
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--image <path>` | Output PNG path (required) |
|
||||
| `--prompt <text>` | Prompt text |
|
||||
| `--prompt-file <path>` | Read prompt from file (mutually exclusive with `--prompt`) |
|
||||
| `--aspect <ratio>` | Aspect ratio (`1:1`, `16:9`, `9:16`, `4:3`, `2.35:1`). Default: `1:1` |
|
||||
| `--ref <file>` | Reference image (repeatable) |
|
||||
| `--timeout <ms>` | Codex exec timeout in ms. Default: `300000` |
|
||||
| `--retries <n>` | Retry attempts on retryable errors. Default: `2` |
|
||||
| `--retry-delay <ms>` | Base retry delay (exponential). Default: `1500` |
|
||||
| `--cache-dir <path>` | Enable idempotency cache. Disabled by default. |
|
||||
| `--log-file <path>` | Append structured JSONL log |
|
||||
| `-v, --verbose` | Verbose stderr logging |
|
||||
| `-h, --help` | Show help |
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cd packages/baoyu-codex-imagegen
|
||||
bun test
|
||||
```
|
||||
|
||||
## Trade-offs
|
||||
|
||||
- 5–10× slower than direct OpenAI API calls (except on cache hits)
|
||||
- Uses your Codex subscription — programmatic use of `codex exec` falls into the same terms as interactive use
|
||||
- Requires `codex` CLI and active login session
|
||||
|
||||
See [`docs/codex-imagegen-backend.md`](../../docs/codex-imagegen-backend.md) for the full background.
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "baoyu-codex-imagegen",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"description": "Generate images via Codex CLI's built-in image_gen tool from non-Codex runtimes (Claude Code, Hermes, …).",
|
||||
"bin": {
|
||||
"codex-imagegen": "./src/main.ts"
|
||||
},
|
||||
"files": [
|
||||
"src/**/*.ts",
|
||||
"!src/**/*.test.ts"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/main.ts",
|
||||
"default": "./src/main.ts"
|
||||
},
|
||||
"./src/*": "./src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"smoke": "bun src/main.ts --help"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/JimLiu/baoyu-skills.git",
|
||||
"directory": "packages/baoyu-codex-imagegen"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/JimLiu/baoyu-skills/issues"
|
||||
},
|
||||
"homepage": "https://github.com/JimLiu/baoyu-skills/tree/main/packages/baoyu-codex-imagegen#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.2.0"
|
||||
}
|
||||
}
|
||||
@@ -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(" ");
|
||||
}
|
||||
Executable
+332
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env bun
|
||||
import { readFile, mkdir, copyFile, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { GenError, type CliOptions, type GenerateResult } from "./types.ts";
|
||||
import { runCodexExec } from "./spawn.ts";
|
||||
import { hasImageGenEvidence, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts";
|
||||
import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts";
|
||||
import { JsonLogger } from "./logger.ts";
|
||||
|
||||
const HELP = `codex-imagegen — generate images via Codex CLI's image_gen tool
|
||||
|
||||
Usage:
|
||||
codex-imagegen --image <output.png> [--prompt <text> | --prompt-file <path>] [options]
|
||||
|
||||
Required:
|
||||
--image <path> Output PNG path
|
||||
--prompt <text> Prompt text (or use --prompt-file)
|
||||
--prompt-file <path> Read prompt from file
|
||||
|
||||
Options:
|
||||
--aspect <ratio> Aspect ratio (1:1, 16:9, 9:16, 4:3, 2.35:1). Default: 1:1
|
||||
--ref <file> Reference image (repeatable)
|
||||
--timeout <ms> Codex exec timeout in ms. Default: 300000
|
||||
--retries <n> Retry attempts on retryable errors. Default: 2
|
||||
--retry-delay <ms> Base retry delay (exponential). Default: 1500
|
||||
--cache-dir <path> Enable idempotency cache. Disabled by default.
|
||||
--log-file <path> Append JSONL log
|
||||
-v, --verbose Verbose stderr logging
|
||||
-h, --help Show this help
|
||||
|
||||
Stdout: single JSON line on success or failure.
|
||||
`;
|
||||
|
||||
const SHELL_METACHAR = /[;|&`$<>\n\r()'"]/;
|
||||
|
||||
function assertSafePath(label: string, value: string): void {
|
||||
if (SHELL_METACHAR.test(value)) {
|
||||
throw new GenError(
|
||||
"invalid_args",
|
||||
`${label} contains shell metacharacters and would be unsafe to interpolate into the codex instruction: ${value}`,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const opts: CliOptions = {
|
||||
prompt: "",
|
||||
promptFile: null,
|
||||
outputPath: "",
|
||||
aspect: "1:1",
|
||||
refImages: [],
|
||||
timeoutMs: 300_000,
|
||||
retries: 2,
|
||||
retryDelayMs: 1500,
|
||||
cacheDir: null,
|
||||
logFile: null,
|
||||
verbose: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const next = () => argv[++i];
|
||||
switch (a) {
|
||||
case "--prompt": opts.prompt = next(); break;
|
||||
case "--prompt-file": opts.promptFile = next(); break;
|
||||
case "--image": opts.outputPath = next(); break;
|
||||
case "--aspect": opts.aspect = next(); break;
|
||||
case "--ref": opts.refImages.push(next()); break;
|
||||
case "--timeout": opts.timeoutMs = Number(next()); break;
|
||||
case "--retries": opts.retries = Number(next()); break;
|
||||
case "--retry-delay": opts.retryDelayMs = Number(next()); break;
|
||||
case "--cache-dir": opts.cacheDir = next(); break;
|
||||
case "--log-file": opts.logFile = next(); break;
|
||||
case "-v":
|
||||
case "--verbose": opts.verbose = true; break;
|
||||
case "-h":
|
||||
case "--help": process.stdout.write(HELP); process.exit(0);
|
||||
default: throw new GenError("invalid_args", `Unknown argument: ${a}`, false);
|
||||
}
|
||||
}
|
||||
if (!opts.outputPath) throw new GenError("invalid_args", "--image is required", false);
|
||||
if (opts.prompt && opts.promptFile) {
|
||||
throw new GenError("invalid_args", "--prompt and --prompt-file are mutually exclusive", false);
|
||||
}
|
||||
if (!opts.prompt && !opts.promptFile) {
|
||||
throw new GenError("invalid_args", "--prompt or --prompt-file required", false);
|
||||
}
|
||||
|
||||
// Resolve every filesystem path to absolute up front, so behavior is
|
||||
// independent of the caller's cwd. This matters when the wrapper is
|
||||
// invoked from a skill running in an arbitrary working directory.
|
||||
const cwd = process.cwd();
|
||||
const toAbs = (p: string) => (path.isAbsolute(p) ? p : path.resolve(cwd, p));
|
||||
|
||||
opts.outputPath = toAbs(opts.outputPath);
|
||||
if (opts.promptFile) opts.promptFile = toAbs(opts.promptFile);
|
||||
opts.refImages = opts.refImages.map(toAbs);
|
||||
if (opts.cacheDir) opts.cacheDir = toAbs(opts.cacheDir);
|
||||
if (opts.logFile) opts.logFile = toAbs(opts.logFile);
|
||||
|
||||
// The output and ref paths are interpolated raw into the agent instruction
|
||||
// sent to `codex exec --sandbox danger-full-access`. A path containing shell
|
||||
// metacharacters could be misread by the agent's shell when it cp's the
|
||||
// result into place. Reject upfront rather than trusting the agent to quote.
|
||||
assertSafePath("--image path", opts.outputPath);
|
||||
for (const ref of opts.refImages) assertSafePath("--ref path", ref);
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function loadPrompt(opts: CliOptions): Promise<string> {
|
||||
if (opts.prompt) return opts.prompt;
|
||||
const file = opts.promptFile!;
|
||||
try {
|
||||
return await readFile(file, "utf-8");
|
||||
} catch {
|
||||
throw new GenError("prompt_file_missing", `Prompt file not found: ${file}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
function buildInstruction(prompt: string, opts: CliOptions): string {
|
||||
const refHint = opts.refImages.length > 0
|
||||
? `\nREFERENCE IMAGES (attached above): ${opts.refImages.length} image(s) provided for style/composition guidance.\n`
|
||||
: "";
|
||||
return `You have an internal tool called image_gen for image generation. You MUST call it before doing anything else.
|
||||
|
||||
TASK: Generate an image with the spec below, then save to disk.
|
||||
|
||||
PROMPT:
|
||||
${prompt}
|
||||
|
||||
ASPECT RATIO: ${opts.aspect}
|
||||
OUTPUT PATH: ${opts.outputPath}
|
||||
${refHint}
|
||||
STEPS:
|
||||
1. Call image_gen with the prompt and aspect ratio above${opts.refImages.length > 0 ? " (using the attached reference images for guidance)" : ""}.
|
||||
2. Move or copy ONLY the image produced by that image_gen call from Codex default location ($CODEX_HOME/generated_images/...) to: ${opts.outputPath}
|
||||
3. Verify with: ls -la ${opts.outputPath}
|
||||
4. Reply with ONLY this JSON line (no markdown fences, no other text):
|
||||
{"status":"ok","path":"${opts.outputPath}","bytes":<file_size_in_bytes>}
|
||||
|
||||
HARD CONSTRAINTS:
|
||||
- Do NOT search for, find, inspect, reuse, or copy any pre-existing files from $CODEX_HOME/generated_images/ or any other directory.
|
||||
- Do NOT run ls/find/rg/grep/glob over $CODEX_HOME/generated_images/ before image_gen has been called.
|
||||
- You MUST call image_gen first. Only after image_gen completes may you copy the newly created file from this turn.
|
||||
- Do NOT use curl, wget, Python, or any external API.
|
||||
- Do NOT use bash to fabricate an image; only image_gen produces real pixels.
|
||||
- Use ONLY the image_gen internal tool.`;
|
||||
}
|
||||
|
||||
async function attemptGenerate(
|
||||
opts: CliOptions,
|
||||
instruction: string,
|
||||
attempt: number,
|
||||
log: JsonLogger,
|
||||
): Promise<{ bytes: number; threadId: string | null; usage: any; toolCalls: any[] }> {
|
||||
await log.info("attempt.start", { attempt, output: opts.outputPath, aspect: opts.aspect });
|
||||
|
||||
const run = await runCodexExec({
|
||||
instruction,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
refImages: opts.refImages,
|
||||
});
|
||||
|
||||
await log.info("codex.completed", {
|
||||
duration_ms: run.durationMs,
|
||||
thread_id: run.threadId,
|
||||
tool_calls: run.toolCalls.length,
|
||||
usage: run.usage,
|
||||
raw_log: run.rawLogPath,
|
||||
});
|
||||
|
||||
// verify: thread id must be present
|
||||
if (!run.threadId) {
|
||||
throw new GenError("agent_refused", "No thread id in event stream");
|
||||
}
|
||||
|
||||
// verify image_gen ran in THIS thread. A PNG in this thread's
|
||||
// generated_images dir is the real signal (image_gen does not surface as a
|
||||
// stream item); the stream check is a forward-compatible fallback. The #185
|
||||
// shortcut (copying an unrelated history image) yields neither.
|
||||
const ver = await verifyImageGenWasInvoked(run.threadId);
|
||||
if (!hasImageGenEvidence(run.toolCalls, ver.ok)) {
|
||||
throw new GenError(
|
||||
"no_image_gen_tool_use",
|
||||
`image_gen was not invoked (no image_gen event in stream; ${ver.reason})`,
|
||||
);
|
||||
}
|
||||
|
||||
// verify output
|
||||
const { bytes } = await verifyOutput(opts.outputPath);
|
||||
|
||||
return {
|
||||
bytes,
|
||||
threadId: run.threadId,
|
||||
usage: run.usage,
|
||||
toolCalls: run.toolCalls.map((tc) => ({ tool: tc.tool, status: tc.status })),
|
||||
};
|
||||
}
|
||||
|
||||
async function generate(opts: CliOptions, log: JsonLogger): Promise<GenerateResult> {
|
||||
const startEpoch = Date.now();
|
||||
const prompt = await loadPrompt(opts);
|
||||
|
||||
// Cache lookup
|
||||
if (opts.cacheDir) {
|
||||
const key = cacheKey(prompt, opts.aspect, opts.refImages);
|
||||
const cached = await lookupCache(opts.cacheDir, key);
|
||||
if (cached) {
|
||||
await mkdir(path.dirname(opts.outputPath), { recursive: true });
|
||||
await copyFile(cached, opts.outputPath);
|
||||
const s = await stat(opts.outputPath);
|
||||
await log.info("cache.hit", { key, source: cached });
|
||||
return {
|
||||
status: "ok",
|
||||
path: opts.outputPath,
|
||||
bytes: s.size,
|
||||
elapsed_seconds: 0,
|
||||
thread_id: null,
|
||||
attempts: 0,
|
||||
cached: true,
|
||||
usage: null,
|
||||
tool_calls: [],
|
||||
};
|
||||
}
|
||||
await log.info("cache.miss", { key });
|
||||
}
|
||||
|
||||
// lock to prevent concurrent codex exec
|
||||
const lockDir = opts.cacheDir ?? path.join(homedir(), ".cache", "baoyu-codex-imagegen");
|
||||
const lock = new FileLock(path.join(lockDir, "codex-exec.lock"));
|
||||
try {
|
||||
await lock.acquire(60_000);
|
||||
} catch (e) {
|
||||
throw new GenError("lock_busy", String(e), false);
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(opts.outputPath), { recursive: true });
|
||||
const instruction = buildInstruction(prompt, opts);
|
||||
|
||||
let lastErr: GenError | null = null;
|
||||
let lastAttempt = 0;
|
||||
try {
|
||||
for (let attempt = 1; attempt <= opts.retries + 1; attempt++) {
|
||||
lastAttempt = attempt;
|
||||
try {
|
||||
const result = await attemptGenerate(opts, instruction, attempt, log);
|
||||
|
||||
// write to cache
|
||||
if (opts.cacheDir) {
|
||||
const key = cacheKey(prompt, opts.aspect, opts.refImages);
|
||||
await storeCache(opts.cacheDir, key, opts.outputPath);
|
||||
await log.info("cache.stored", { key });
|
||||
}
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
path: opts.outputPath,
|
||||
bytes: result.bytes,
|
||||
elapsed_seconds: Math.round((Date.now() - startEpoch) / 1000),
|
||||
thread_id: result.threadId,
|
||||
attempts: attempt,
|
||||
cached: false,
|
||||
usage: result.usage,
|
||||
tool_calls: result.toolCalls,
|
||||
};
|
||||
} catch (e) {
|
||||
lastErr = e instanceof GenError ? e : new GenError("spawn_failed", String(e));
|
||||
await log.warn("attempt.failed", {
|
||||
attempt,
|
||||
kind: lastErr.kind,
|
||||
retryable: lastErr.retryable,
|
||||
error: lastErr.message,
|
||||
});
|
||||
if (!lastErr.retryable || attempt > opts.retries) break;
|
||||
const wait = opts.retryDelayMs * Math.pow(2, attempt - 1);
|
||||
await log.info("retry.wait", { wait_ms: wait, next_attempt: attempt + 1 });
|
||||
await delay(wait);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
|
||||
const err = lastErr ?? new GenError("spawn_failed", "Unknown failure");
|
||||
err.attempts = lastAttempt;
|
||||
throw err;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let opts: CliOptions;
|
||||
try {
|
||||
opts = parseArgs(process.argv.slice(2));
|
||||
} catch (e) {
|
||||
const err = e instanceof GenError ? e : new GenError("invalid_args", String(e), false);
|
||||
process.stderr.write(`Error: ${err.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const log = new JsonLogger(opts.logFile, opts.verbose);
|
||||
await log.info("start", { output: opts.outputPath, aspect: opts.aspect, refs: opts.refImages.length });
|
||||
|
||||
try {
|
||||
const result = await generate(opts, log);
|
||||
await log.info("done", { bytes: result.bytes, attempts: result.attempts, cached: result.cached });
|
||||
process.stdout.write(JSON.stringify(result) + "\n");
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
const err = e instanceof GenError ? e : new GenError("spawn_failed", String(e));
|
||||
await log.error("failed", { kind: err.kind, error: err.message, attempts: err.attempts ?? 0 });
|
||||
const out: GenerateResult = {
|
||||
status: "error",
|
||||
path: opts.outputPath,
|
||||
bytes: 0,
|
||||
elapsed_seconds: 0,
|
||||
thread_id: null,
|
||||
attempts: err.attempts ?? 0,
|
||||
cached: false,
|
||||
usage: null,
|
||||
tool_calls: [],
|
||||
error: err.message,
|
||||
error_kind: err.kind,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out) + "\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,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 };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "baoyu-fetch",
|
||||
"version": "0.1.2",
|
||||
"license": "MIT",
|
||||
"description": "Read URLs into high-quality Markdown or JSON with Chrome CDP and site adapters.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -19,6 +19,10 @@ describe("parseYouTubeVideoId", () => {
|
||||
test("parses shorts URLs", () => {
|
||||
expect(parseYouTubeVideoId(new URL("https://www.youtube.com/shorts/abc123"))).toBe("abc123");
|
||||
});
|
||||
|
||||
test("parses embed URLs", () => {
|
||||
expect(parseYouTubeVideoId(new URL("https://www.youtube.com/embed/abc123"))).toBe("abc123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseYouTubeDescriptionChapters", () => {
|
||||
|
||||
@@ -52,6 +52,11 @@ export function parseYouTubeVideoId(url: URL): string | null {
|
||||
return liveMatch[1];
|
||||
}
|
||||
|
||||
const embedMatch = url.pathname.match(/^\/embed\/([^/?#]+)/);
|
||||
if (embedMatch) {
|
||||
return embedMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+288
-5129
File diff suppressed because one or more lines are too long
Vendored
+288
-5129
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "baoyu-md",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
|
||||
@@ -28,6 +28,32 @@ test("replaceMarkdownImagesWithPlaceholders rewrites markdown and tracks image m
|
||||
]);
|
||||
});
|
||||
|
||||
test("replaceMarkdownImagesWithPlaceholders supports Obsidian image wikilinks in document order", () => {
|
||||
const result = replaceMarkdownImagesWithPlaceholders(
|
||||
`Intro\n\n![[a.png]]\n\n\n\n![[c.webp|C alt]]\n\n![[note]]`,
|
||||
"IMG_",
|
||||
);
|
||||
|
||||
assert.equal(result.markdown, `Intro\n\nIMG_1\n\nIMG_2\n\nIMG_3\n\n![[note]]`);
|
||||
assert.deepEqual(result.images, [
|
||||
{ alt: "", originalPath: "a.png", placeholder: "IMG_1" },
|
||||
{ alt: "B", originalPath: "b.jpg", placeholder: "IMG_2" },
|
||||
{ alt: "C alt", originalPath: "c.webp", placeholder: "IMG_3" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("replaceMarkdownImagesWithPlaceholders supports Obsidian image wikilinks with paths", () => {
|
||||
const result = replaceMarkdownImagesWithPlaceholders(
|
||||
`![[Attachments/screenshot.png]]`,
|
||||
"IMG_",
|
||||
);
|
||||
|
||||
assert.equal(result.markdown, `IMG_1`);
|
||||
assert.deepEqual(result.images, [
|
||||
{ alt: "", originalPath: "Attachments/screenshot.png", placeholder: "IMG_1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("image extension and local fallback resolution handle common path variants", async (t) => {
|
||||
assert.equal(getImageExtension("https://example.com/a.jpeg?x=1"), "jpeg");
|
||||
assert.equal(getImageExtension("/tmp/figure"), "png");
|
||||
@@ -45,6 +71,70 @@ test("image extension and local fallback resolution handle common path variants"
|
||||
assert.equal(resolved, path.join(baseDir, "figure.webp"));
|
||||
});
|
||||
|
||||
test("resolveImagePath falls back to Attachments subdirectory before extension variants", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-attachments-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
const attachmentsDir = path.join(baseDir, "Attachments");
|
||||
await fs.mkdir(attachmentsDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "figure.webp"), "webp");
|
||||
await fs.writeFile(path.join(attachmentsDir, "figure.png"), "png");
|
||||
|
||||
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
|
||||
assert.equal(resolved, path.join(attachmentsDir, "figure.png"));
|
||||
});
|
||||
|
||||
test("resolveImagePath prefers original path before Attachments fallback", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-attachments-original-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
const attachmentsDir = path.join(baseDir, "Attachments");
|
||||
await fs.mkdir(attachmentsDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "figure.png"), "png");
|
||||
await fs.writeFile(path.join(attachmentsDir, "figure.png"), "attachment png");
|
||||
|
||||
const resolved = await resolveImagePath("figure.png", baseDir, tempDir, "test");
|
||||
assert.equal(resolved, path.join(baseDir, "figure.png"));
|
||||
});
|
||||
|
||||
test("resolveImagePath decodes URL-encoded filenames with spaces", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-urlencoded-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "Pasted image 20260524.png"), "png");
|
||||
|
||||
const resolved = await resolveImagePath("Pasted%20image%2020260524.png", baseDir, tempDir, "test");
|
||||
assert.equal(resolved, path.join(baseDir, "Pasted image 20260524.png"));
|
||||
});
|
||||
|
||||
test("resolveImagePath keeps literal percent filenames usable", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-percent-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const baseDir = path.join(root, "article");
|
||||
const tempDir = path.join(root, "tmp");
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await fs.writeFile(path.join(baseDir, "100% complete.png"), "png");
|
||||
await fs.writeFile(path.join(baseDir, "diagram%23hash.png"), "png");
|
||||
|
||||
const malformedPercent = await resolveImagePath("100% complete.png", baseDir, tempDir, "test");
|
||||
assert.equal(malformedPercent, path.join(baseDir, "100% complete.png"));
|
||||
|
||||
const literalEncodedPercent = await resolveImagePath("diagram%23hash.png", baseDir, tempDir, "test");
|
||||
assert.equal(literalEncodedPercent, path.join(baseDir, "diagram%23hash.png"));
|
||||
});
|
||||
|
||||
test("resolveContentImages resolves image placeholders against the content directory", async (t) => {
|
||||
const root = await makeTempDir("baoyu-md-content-images-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
+123
-13
@@ -23,16 +23,39 @@ export function replaceMarkdownImagesWithPlaceholders(
|
||||
} {
|
||||
const images: ImagePlaceholder[] = [];
|
||||
let imageCounter = 0;
|
||||
let lastIndex = 0;
|
||||
let rewritten = "";
|
||||
|
||||
const rewritten = markdown.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, src) => {
|
||||
const imagePattern = /!\[([^\]]*)\]\(([^)]+)\)|!\[\[([^\]\n]+)\]\]/g;
|
||||
for (const match of markdown.matchAll(imagePattern)) {
|
||||
const fullMatch = match[0];
|
||||
const matchIndex = match.index ?? 0;
|
||||
const markdownAlt = match[1];
|
||||
const markdownSrc = match[2];
|
||||
const wikilinkTarget = match[3];
|
||||
const wikilinkImage = wikilinkTarget
|
||||
? parseObsidianImageWikilink(wikilinkTarget)
|
||||
: null;
|
||||
|
||||
if (wikilinkTarget && !wikilinkImage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalPath = wikilinkImage?.originalPath ?? markdownSrc ?? "";
|
||||
const alt = wikilinkImage?.alt ?? markdownAlt ?? "";
|
||||
const placeholder = `${placeholderPrefix}${++imageCounter}`;
|
||||
|
||||
rewritten += markdown.slice(lastIndex, matchIndex);
|
||||
images.push({
|
||||
alt,
|
||||
originalPath: src,
|
||||
originalPath,
|
||||
placeholder,
|
||||
});
|
||||
return placeholder;
|
||||
});
|
||||
rewritten += placeholder;
|
||||
lastIndex = matchIndex + fullMatch.length;
|
||||
}
|
||||
|
||||
rewritten += markdown.slice(lastIndex);
|
||||
|
||||
return { images, markdown: rewritten };
|
||||
}
|
||||
@@ -103,10 +126,7 @@ export async function resolveImagePath(
|
||||
return localPath;
|
||||
}
|
||||
|
||||
const resolved = path.isAbsolute(imagePath)
|
||||
? imagePath
|
||||
: path.resolve(baseDir, imagePath);
|
||||
return resolveLocalWithFallback(resolved, logLabel);
|
||||
return resolveLocalImagePath(imagePath, baseDir, logLabel);
|
||||
}
|
||||
|
||||
export async function resolveContentImages(
|
||||
@@ -127,11 +147,79 @@ export async function resolveContentImages(
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveLocalWithFallback(resolved: string, logLabel: string): string {
|
||||
function parseObsidianImageWikilink(target: string): {
|
||||
originalPath: string;
|
||||
alt: string;
|
||||
} | null {
|
||||
const separatorIndex = target.indexOf("|");
|
||||
const originalPath = (separatorIndex === -1
|
||||
? target
|
||||
: target.slice(0, separatorIndex)).trim();
|
||||
const alt = separatorIndex === -1 ? "" : target.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (!hasExplicitImageExtension(originalPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { originalPath, alt };
|
||||
}
|
||||
|
||||
function hasExplicitImageExtension(value: string): boolean {
|
||||
return /\.(?:jpe?g|png|gif|webp)(?:[?#].*)?$/i.test(value);
|
||||
}
|
||||
|
||||
function resolveLocalImagePath(imagePath: string, baseDir: string, logLabel: string): string {
|
||||
const decoded = safeDecodeImagePath(imagePath);
|
||||
const decodedResolved = resolveAgainstBaseDir(decoded, baseDir);
|
||||
const decodedWithFallback = resolveLocalWithFallback(
|
||||
decodedResolved,
|
||||
logLabel,
|
||||
buildAttachmentFallbackPath(decoded, baseDir),
|
||||
);
|
||||
|
||||
if (decoded === imagePath || fs.existsSync(decodedWithFallback)) {
|
||||
return decodedWithFallback;
|
||||
}
|
||||
|
||||
return resolveLocalWithFallback(
|
||||
resolveAgainstBaseDir(imagePath, baseDir),
|
||||
logLabel,
|
||||
buildAttachmentFallbackPath(imagePath, baseDir),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLocalWithFallback(
|
||||
resolved: string,
|
||||
logLabel: string,
|
||||
attachmentResolved?: string,
|
||||
): string {
|
||||
if (fs.existsSync(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
if (attachmentResolved && fs.existsSync(attachmentResolved)) {
|
||||
logImageFallback(resolved, attachmentResolved, logLabel);
|
||||
return attachmentResolved;
|
||||
}
|
||||
|
||||
const originalAlternative = findExtensionFallback(resolved);
|
||||
if (originalAlternative) {
|
||||
logImageFallback(resolved, originalAlternative, logLabel);
|
||||
return originalAlternative;
|
||||
}
|
||||
|
||||
if (attachmentResolved) {
|
||||
const attachmentAlternative = findExtensionFallback(attachmentResolved);
|
||||
if (attachmentAlternative) {
|
||||
logImageFallback(resolved, attachmentAlternative, logLabel);
|
||||
return attachmentAlternative;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function findExtensionFallback(resolved: string): string | null {
|
||||
const ext = path.extname(resolved);
|
||||
const base = ext ? resolved.slice(0, -ext.length) : resolved;
|
||||
const alternatives = [
|
||||
@@ -146,11 +234,33 @@ function resolveLocalWithFallback(resolved: string, logLabel: string): string {
|
||||
|
||||
for (const alternative of alternatives) {
|
||||
if (!fs.existsSync(alternative)) continue;
|
||||
console.error(
|
||||
`[${logLabel}] Image fallback: ${path.basename(resolved)} -> ${path.basename(alternative)}`,
|
||||
);
|
||||
return alternative;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
return null;
|
||||
}
|
||||
|
||||
function logImageFallback(fromPath: string, toPath: string, logLabel: string): void {
|
||||
console.error(
|
||||
`[${logLabel}] Image fallback: ${path.basename(fromPath)} -> ${path.basename(toPath)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function safeDecodeImagePath(imagePath: string): string {
|
||||
try {
|
||||
return decodeURIComponent(imagePath);
|
||||
} catch {
|
||||
return imagePath;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAgainstBaseDir(imagePath: string, baseDir: string): string {
|
||||
return path.isAbsolute(imagePath) ? imagePath : path.resolve(baseDir, imagePath);
|
||||
}
|
||||
|
||||
function buildAttachmentFallbackPath(imagePath: string, baseDir: string): string | undefined {
|
||||
if (path.isAbsolute(imagePath)) {
|
||||
return undefined;
|
||||
}
|
||||
return path.resolve(baseDir, "Attachments", imagePath);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export * from "./document.js";
|
||||
export * from "./extend-config.js";
|
||||
export * from "./html-builder.js";
|
||||
export * from "./images.js";
|
||||
export * from "./mermaid-preprocess.js";
|
||||
export * from "./mermaid-utils.js";
|
||||
export * from "./renderer.js";
|
||||
export * from "./themes.js";
|
||||
export * from "./types.js";
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
preprocessMermaidInMarkdown,
|
||||
type MermaidRenderFn,
|
||||
} from "./mermaid-preprocess.ts";
|
||||
|
||||
function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mermaid-preprocess-test-"));
|
||||
return fn(dir).finally(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
|
||||
const stubPngBytes = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]);
|
||||
|
||||
const stubRender: MermaidRenderFn = async (_code, outPath) => {
|
||||
await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
|
||||
await fs.promises.writeFile(outPath, stubPngBytes);
|
||||
};
|
||||
|
||||
test("preprocessMermaidInMarkdown skips when disabled", async () => {
|
||||
await withTempDir(async (baseDir) => {
|
||||
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
|
||||
const result = await preprocessMermaidInMarkdown(markdown, {
|
||||
baseDir,
|
||||
renderFn: stubRender,
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(result.markdown, markdown);
|
||||
assert.equal(result.images.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test("preprocessMermaidInMarkdown skips when renderFn is missing", async () => {
|
||||
await withTempDir(async (baseDir) => {
|
||||
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
|
||||
const result = await preprocessMermaidInMarkdown(markdown, { baseDir });
|
||||
assert.equal(result.markdown, markdown);
|
||||
assert.equal(result.images.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test("preprocessMermaidInMarkdown deduplicates identical blocks via hashed cache", async () => {
|
||||
await withTempDir(async (baseDir) => {
|
||||
const block = "```mermaid\ngraph TD\nA-->B\n```";
|
||||
const markdown = `${block}\n\nsome text\n\n${block}\n\nother text\n\n\`\`\`mermaid\nflowchart LR\nX-->Y\n\`\`\``;
|
||||
|
||||
let renderCalls = 0;
|
||||
const renderFn: MermaidRenderFn = async (code, outPath) => {
|
||||
renderCalls += 1;
|
||||
await stubRender(code, outPath, {});
|
||||
};
|
||||
|
||||
const result = await preprocessMermaidInMarkdown(markdown, {
|
||||
baseDir,
|
||||
renderFn,
|
||||
});
|
||||
|
||||
assert.equal(renderCalls, 2, "should render two distinct blocks");
|
||||
assert.equal(result.images.length, 3, "all three blocks produce image entries");
|
||||
const uniqueHashes = new Set(result.images.map((image) => image.hash));
|
||||
assert.equal(uniqueHashes.size, 2);
|
||||
|
||||
const matches = result.markdown.match(/!\[Mermaid diagram\]/g) ?? [];
|
||||
assert.equal(matches.length, 3);
|
||||
assert.ok(!result.markdown.includes("```mermaid"));
|
||||
});
|
||||
});
|
||||
|
||||
test("preprocessMermaidInMarkdown reuses cached files (cached=true when file exists)", async () => {
|
||||
await withTempDir(async (baseDir) => {
|
||||
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
|
||||
|
||||
let renderCalls = 0;
|
||||
const renderFn: MermaidRenderFn = async (code, outPath) => {
|
||||
renderCalls += 1;
|
||||
await stubRender(code, outPath, {});
|
||||
};
|
||||
|
||||
const first = await preprocessMermaidInMarkdown(markdown, { baseDir, renderFn });
|
||||
assert.equal(renderCalls, 1);
|
||||
assert.equal(first.images[0]!.cached, false);
|
||||
|
||||
const second = await preprocessMermaidInMarkdown(markdown, { baseDir, renderFn });
|
||||
assert.equal(renderCalls, 1, "second pass hits cache");
|
||||
assert.equal(second.images[0]!.cached, true);
|
||||
});
|
||||
});
|
||||
|
||||
test("preprocessMermaidInMarkdown survives renderFn errors and keeps raw block", async () => {
|
||||
await withTempDir(async (baseDir) => {
|
||||
const markdown = "```mermaid\ninvalid syntax!!\n```\n\nrest";
|
||||
const errors: string[] = [];
|
||||
const failingRender: MermaidRenderFn = async () => {
|
||||
throw new Error("render boom");
|
||||
};
|
||||
|
||||
const result = await preprocessMermaidInMarkdown(markdown, {
|
||||
baseDir,
|
||||
renderFn: failingRender,
|
||||
onError: (error) => {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(errors.length, 1);
|
||||
assert.equal(errors[0], "render boom");
|
||||
assert.equal(result.images.length, 0);
|
||||
assert.ok(result.markdown.includes("```mermaid"));
|
||||
assert.ok(result.markdown.includes("rest"));
|
||||
});
|
||||
});
|
||||
|
||||
test("preprocessMermaidInMarkdown writes PNGs under imgs/.mermaid-cache/", async () => {
|
||||
await withTempDir(async (baseDir) => {
|
||||
const markdown = "```mermaid\ngraph TD\nA-->B\n```";
|
||||
const result = await preprocessMermaidInMarkdown(markdown, {
|
||||
baseDir,
|
||||
renderFn: stubRender,
|
||||
});
|
||||
assert.equal(result.images.length, 1);
|
||||
const image = result.images[0]!;
|
||||
assert.ok(image.localPath.includes(path.join("imgs", ".mermaid-cache")));
|
||||
assert.ok(image.mdRef.includes("imgs/.mermaid-cache/"));
|
||||
assert.ok(fs.existsSync(image.localPath));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
MERMAID_VERSION,
|
||||
extractMermaidBlocks,
|
||||
hashMermaidCode,
|
||||
replaceMermaidBlocks,
|
||||
type MermaidBlock,
|
||||
} from "./mermaid-utils.js";
|
||||
|
||||
export interface MermaidRenderOptions {
|
||||
theme?: string;
|
||||
scale?: number;
|
||||
background?: string;
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
export type MermaidRenderFn = (
|
||||
code: string,
|
||||
outputPath: string,
|
||||
options: MermaidRenderOptions,
|
||||
) => Promise<void>;
|
||||
|
||||
export interface MermaidPreprocessOptions extends MermaidRenderOptions {
|
||||
baseDir: string;
|
||||
imgSubdir?: string;
|
||||
renderFn?: MermaidRenderFn;
|
||||
enabled?: boolean;
|
||||
alt?: string;
|
||||
onError?: (error: unknown, block: MermaidBlock) => void;
|
||||
}
|
||||
|
||||
export interface MermaidPreprocessedImage {
|
||||
raw: string;
|
||||
code: string;
|
||||
hash: string;
|
||||
localPath: string;
|
||||
mdRef: string;
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
export interface MermaidPreprocessResult {
|
||||
markdown: string;
|
||||
images: MermaidPreprocessedImage[];
|
||||
}
|
||||
|
||||
export async function preprocessMermaidInMarkdown(
|
||||
markdown: string,
|
||||
options: MermaidPreprocessOptions,
|
||||
): Promise<MermaidPreprocessResult> {
|
||||
const {
|
||||
baseDir,
|
||||
imgSubdir = "imgs/.mermaid-cache",
|
||||
renderFn,
|
||||
enabled = true,
|
||||
theme,
|
||||
scale,
|
||||
background,
|
||||
minWidth,
|
||||
alt = "Mermaid diagram",
|
||||
onError,
|
||||
} = options;
|
||||
|
||||
if (!enabled || !renderFn) {
|
||||
return { markdown, images: [] };
|
||||
}
|
||||
|
||||
const blocks = extractMermaidBlocks(markdown);
|
||||
if (blocks.length === 0) {
|
||||
return { markdown, images: [] };
|
||||
}
|
||||
|
||||
const cacheDir = path.resolve(baseDir, imgSubdir);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
|
||||
const replacements = new Map<string, string>();
|
||||
const images: MermaidPreprocessedImage[] = [];
|
||||
const renderedHashes = new Set<string>();
|
||||
|
||||
for (const block of blocks) {
|
||||
const hash = hashMermaidCode({
|
||||
code: block.code,
|
||||
theme,
|
||||
scale,
|
||||
background,
|
||||
minWidth,
|
||||
version: MERMAID_VERSION,
|
||||
});
|
||||
const filename = `mermaid-${hash}.png`;
|
||||
const localPath = path.join(cacheDir, filename);
|
||||
const mdRef = `})`;
|
||||
|
||||
const cached = fs.existsSync(localPath);
|
||||
|
||||
if (!cached && !renderedHashes.has(hash)) {
|
||||
try {
|
||||
await renderFn(block.code, localPath, { theme, scale, background, minWidth });
|
||||
renderedHashes.add(hash);
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(error, block);
|
||||
} else {
|
||||
console.error(
|
||||
`[mermaid] render failed for block (hash ${hash}): ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(localPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
replacements.set(block.raw, mdRef);
|
||||
images.push({
|
||||
raw: block.raw,
|
||||
code: block.code,
|
||||
hash,
|
||||
localPath,
|
||||
mdRef,
|
||||
cached,
|
||||
});
|
||||
}
|
||||
|
||||
const newMarkdown = replaceMermaidBlocks(markdown, replacements);
|
||||
return { markdown: newMarkdown, images };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
MERMAID_VERSION,
|
||||
extractMermaidBlocks,
|
||||
hashMermaidCode,
|
||||
replaceMermaidBlocks,
|
||||
} from "./mermaid-utils.ts";
|
||||
|
||||
test("extractMermaidBlocks finds fenced mermaid blocks at the top level", () => {
|
||||
const markdown = `Intro
|
||||
|
||||
\`\`\`mermaid
|
||||
graph TD
|
||||
A --> B
|
||||
\`\`\`
|
||||
|
||||
Outro`;
|
||||
|
||||
const blocks = extractMermaidBlocks(markdown);
|
||||
assert.equal(blocks.length, 1);
|
||||
assert.equal(blocks[0]!.code.trim(), "graph TD\n A --> B");
|
||||
assert.equal(blocks[0]!.infoString, "");
|
||||
assert.ok(blocks[0]!.raw.includes("```mermaid"));
|
||||
});
|
||||
|
||||
test("extractMermaidBlocks preserves info-string suffixes", () => {
|
||||
const markdown = "```mermaid theme=dark\nflowchart LR\n A --> B\n```";
|
||||
const blocks = extractMermaidBlocks(markdown);
|
||||
assert.equal(blocks.length, 1);
|
||||
assert.equal(blocks[0]!.infoString, "theme=dark");
|
||||
});
|
||||
|
||||
test("extractMermaidBlocks finds blocks nested inside lists", () => {
|
||||
const markdown = `Steps:
|
||||
|
||||
1. First, render the diagram:
|
||||
|
||||
\`\`\`mermaid
|
||||
sequenceDiagram
|
||||
Alice->>Bob: Hello
|
||||
\`\`\`
|
||||
|
||||
2. Then do something else.`;
|
||||
|
||||
const blocks = extractMermaidBlocks(markdown);
|
||||
assert.equal(blocks.length, 1);
|
||||
assert.equal(blocks[0]!.code.includes("sequenceDiagram"), true);
|
||||
});
|
||||
|
||||
test("extractMermaidBlocks ignores non-mermaid fences and empty blocks", () => {
|
||||
const markdown = `\`\`\`ts
|
||||
const x = 1;
|
||||
\`\`\`
|
||||
|
||||
\`\`\`mermaid
|
||||
|
||||
\`\`\`
|
||||
|
||||
\`\`\`mermaidsomething
|
||||
not a real lang
|
||||
\`\`\``;
|
||||
|
||||
const blocks = extractMermaidBlocks(markdown);
|
||||
assert.equal(blocks.length, 1);
|
||||
assert.equal(blocks[0]!.infoString, "something");
|
||||
assert.equal(blocks[0]!.code.trim(), "not a real lang");
|
||||
});
|
||||
|
||||
test("replaceMermaidBlocks performs exact string replacement", () => {
|
||||
const markdown = "before\n\n```mermaid\ngraph TD\nA-->B\n```\n\nafter";
|
||||
const blocks = extractMermaidBlocks(markdown);
|
||||
const map = new Map([[blocks[0]!.raw, ""]]);
|
||||
const replaced = replaceMermaidBlocks(markdown, map);
|
||||
assert.equal(replaced, "before\n\n\n\nafter");
|
||||
});
|
||||
|
||||
test("replaceMermaidBlocks leaves markdown unchanged when no replacements match", () => {
|
||||
const markdown = "hello\n\nworld";
|
||||
const replaced = replaceMermaidBlocks(markdown, new Map([["nope", "x"]]));
|
||||
assert.equal(replaced, markdown);
|
||||
});
|
||||
|
||||
test("hashMermaidCode is stable for the same inputs", () => {
|
||||
const a = hashMermaidCode({ code: "graph TD\nA-->B" });
|
||||
const b = hashMermaidCode({ code: "graph TD\nA-->B" });
|
||||
assert.equal(a, b);
|
||||
assert.equal(a.length, 12);
|
||||
});
|
||||
|
||||
test("hashMermaidCode defaults to 2x render scale", () => {
|
||||
const implicit = hashMermaidCode({ code: "graph TD\nA-->B" });
|
||||
const explicit = hashMermaidCode({ code: "graph TD\nA-->B", scale: 2 });
|
||||
assert.equal(implicit, explicit);
|
||||
});
|
||||
|
||||
test("hashMermaidCode ignores trailing whitespace", () => {
|
||||
const a = hashMermaidCode({ code: "graph TD\nA-->B" });
|
||||
const b = hashMermaidCode({ code: "graph TD\nA-->B \n\n" });
|
||||
assert.equal(a, b);
|
||||
});
|
||||
|
||||
test("hashMermaidCode reflects theme/scale/background/version changes", () => {
|
||||
const base = hashMermaidCode({ code: "graph TD\nA-->B" });
|
||||
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", theme: "dark" }));
|
||||
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", scale: 3 }));
|
||||
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", minWidth: 860 }));
|
||||
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", background: "#000" }));
|
||||
assert.notEqual(base, hashMermaidCode({ code: "graph TD\nA-->B", version: "x.y.z" }));
|
||||
});
|
||||
|
||||
test("MERMAID_VERSION matches the vendored bundle (10.x)", () => {
|
||||
assert.match(MERMAID_VERSION, /^10\./);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Marked, type Tokens } from "marked";
|
||||
|
||||
export const MERMAID_VERSION = "10.9.1";
|
||||
|
||||
export interface MermaidBlock {
|
||||
raw: string;
|
||||
code: string;
|
||||
infoString: string;
|
||||
}
|
||||
|
||||
export interface HashMermaidInput {
|
||||
code: string;
|
||||
theme?: string;
|
||||
scale?: number;
|
||||
background?: string;
|
||||
minWidth?: number;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export function extractMermaidBlocks(markdown: string): MermaidBlock[] {
|
||||
const blocks: MermaidBlock[] = [];
|
||||
const lexer = new Marked({ breaks: true });
|
||||
const tokens = lexer.lexer(markdown);
|
||||
walkTokens(tokens, (token) => {
|
||||
if (token.type !== "code") return;
|
||||
const codeToken = token as Tokens.Code;
|
||||
const lang = (codeToken.lang ?? "").trim();
|
||||
if (!lang.startsWith("mermaid")) return;
|
||||
const infoString = lang.slice("mermaid".length).trim();
|
||||
const code = codeToken.text ?? "";
|
||||
if (code.trim() === "") return;
|
||||
blocks.push({
|
||||
raw: codeToken.raw,
|
||||
code,
|
||||
infoString,
|
||||
});
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function replaceMermaidBlocks(
|
||||
markdown: string,
|
||||
replacements: Map<string, string>,
|
||||
): string {
|
||||
let result = markdown;
|
||||
for (const [raw, replacement] of replacements) {
|
||||
if (!raw || replacement === undefined) continue;
|
||||
result = result.split(raw).join(replacement);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function hashMermaidCode(input: HashMermaidInput): string {
|
||||
const payload = JSON.stringify({
|
||||
code: input.code.replace(/\s+$/g, ""),
|
||||
theme: input.theme ?? "default",
|
||||
scale: input.scale ?? 2,
|
||||
minWidth: input.minWidth ?? null,
|
||||
background: input.background ?? "white",
|
||||
version: input.version ?? MERMAID_VERSION,
|
||||
});
|
||||
return createHash("sha256").update(payload).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
type AnyToken = { type?: string; tokens?: AnyToken[]; items?: AnyToken[] };
|
||||
|
||||
function walkTokens(tokens: AnyToken[], visit: (token: AnyToken) => void): void {
|
||||
for (const token of tokens) {
|
||||
visit(token);
|
||||
if (Array.isArray(token.tokens)) walkTokens(token.tokens, visit);
|
||||
if (Array.isArray(token.items)) walkTokens(token.items, visit);
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,6 @@ function wrapInlineCode(value: string): string {
|
||||
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||
const footnotes: [number, string, string][] = [];
|
||||
let footnoteIndex = 0;
|
||||
let codeIndex = 0;
|
||||
const listOrderedStack: boolean[] = [];
|
||||
const listCounters: number[] = [];
|
||||
const isBrowser = typeof window !== "undefined";
|
||||
@@ -208,20 +207,7 @@ export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||
|
||||
code({ text, lang = "" }: Tokens.Code): string {
|
||||
if (lang.startsWith("mermaid")) {
|
||||
if (isBrowser) {
|
||||
clearTimeout(codeIndex as any);
|
||||
codeIndex = setTimeout(async () => {
|
||||
const windowRef = typeof window !== "undefined" ? (window as any) : undefined;
|
||||
if (windowRef && windowRef.mermaid) {
|
||||
const mermaid = windowRef.mermaid;
|
||||
await mermaid.run();
|
||||
} else {
|
||||
const mermaid = await import("mermaid");
|
||||
await mermaid.default.run();
|
||||
}
|
||||
}, 0) as any as number;
|
||||
}
|
||||
return `<pre class="mermaid">${text}</pre>`;
|
||||
return `<pre class="mermaid">${escapeHtml(text)}</pre>`;
|
||||
}
|
||||
const langText = lang.split(" ")[0];
|
||||
const isLanguageRegistered = hljs.getLanguage(langText);
|
||||
|
||||
@@ -9,27 +9,33 @@ import { pathToFileURL } from "node:url";
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const packageDir = path.resolve(options.packageDir);
|
||||
const entryPath = path.resolve(packageDir, options.entry);
|
||||
const outDir = path.resolve(packageDir, options.outDir);
|
||||
|
||||
await fs.rm(outDir, { recursive: true, force: true });
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
|
||||
const bun = resolveBunRuntime();
|
||||
runBuild(bun, {
|
||||
entryPath,
|
||||
external: options.external,
|
||||
format: "esm",
|
||||
outfile: path.join(outDir, "index.js"),
|
||||
});
|
||||
const cjsOutfile = path.join(outDir, "index.cjs");
|
||||
runBuild(bun, {
|
||||
entryPath,
|
||||
external: options.external,
|
||||
format: "cjs",
|
||||
outfile: cjsOutfile,
|
||||
});
|
||||
await scrubCommonJsSourceFileUrls(cjsOutfile, packageDir);
|
||||
const entries = options.entries.length > 0
|
||||
? options.entries
|
||||
: [{ source: options.entry, name: "index" }];
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.resolve(packageDir, entry.source);
|
||||
runBuild(bun, {
|
||||
entryPath,
|
||||
external: options.external,
|
||||
format: "esm",
|
||||
outfile: path.join(outDir, `${entry.name}.js`),
|
||||
});
|
||||
const cjsOutfile = path.join(outDir, `${entry.name}.cjs`);
|
||||
runBuild(bun, {
|
||||
entryPath,
|
||||
external: options.external,
|
||||
format: "cjs",
|
||||
outfile: cjsOutfile,
|
||||
});
|
||||
await scrubCommonJsSourceFileUrls(cjsOutfile, packageDir);
|
||||
}
|
||||
|
||||
for (const asset of options.assets) {
|
||||
const source = path.resolve(packageDir, asset.source);
|
||||
@@ -45,6 +51,7 @@ function parseArgs(argv) {
|
||||
outDir: "dist",
|
||||
external: [],
|
||||
assets: [],
|
||||
entries: [],
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
@@ -57,6 +64,10 @@ function parseArgs(argv) {
|
||||
options.entry = readArgValue(argv, ++index, arg);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--entry-out") {
|
||||
options.entries.push(parseEntryOutArg(readArgValue(argv, ++index, arg)));
|
||||
continue;
|
||||
}
|
||||
if (arg === "--out-dir") {
|
||||
options.outDir = readArgValue(argv, ++index, arg);
|
||||
continue;
|
||||
@@ -79,6 +90,14 @@ function parseArgs(argv) {
|
||||
return options;
|
||||
}
|
||||
|
||||
function parseEntryOutArg(value) {
|
||||
const [source, name] = value.split(":");
|
||||
if (!source || !name) {
|
||||
throw new Error(`Invalid --entry-out value: ${value} (expected <source>:<name>)`);
|
||||
}
|
||||
return { source, name };
|
||||
}
|
||||
|
||||
function readArgValue(argv, index, flag) {
|
||||
const value = argv[index];
|
||||
if (!value) {
|
||||
@@ -153,12 +172,13 @@ function printUsage() {
|
||||
console.log(`Usage: build-shared-package.mjs [options]
|
||||
|
||||
Options:
|
||||
--package-dir <dir> Package root (default: current directory)
|
||||
--entry <file> Entry file relative to package root (default: src/index.ts)
|
||||
--out-dir <dir> Output directory relative to package root (default: dist)
|
||||
--external <name> Leave a module external (can be repeated)
|
||||
--asset <src[:dst]> Copy an asset directory/file into out-dir (can be repeated)
|
||||
-h, --help Show help`);
|
||||
--package-dir <dir> Package root (default: current directory)
|
||||
--entry <file> Entry file relative to package root (default: src/index.ts)
|
||||
--entry-out <src:name> Add an entry that emits <name>.js / <name>.cjs (repeatable)
|
||||
--out-dir <dir> Output directory relative to package root (default: dist)
|
||||
--external <name> Leave a module external (can be repeated)
|
||||
--asset <src[:dst]> Copy an asset directory/file into out-dir (can be repeated)
|
||||
-h, --help Show help`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
@@ -25,6 +25,37 @@ const MIME_MAP = {
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
|
||||
export async function readSkillMetadataVersion(root) {
|
||||
const skillFile = await findSkillMarkdown(root);
|
||||
const source = await fs.readFile(skillFile, "utf8");
|
||||
const version = readSkillFrontmatterVersion(source);
|
||||
if (!version) {
|
||||
throw new Error(`Missing version in ${path.relative(process.cwd(), skillFile) || skillFile}`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
export async function validateSkillMetadataVersion(root, expectedVersion) {
|
||||
const actualVersion = await readSkillMetadataVersion(root);
|
||||
if (actualVersion !== expectedVersion) {
|
||||
throw new Error(
|
||||
`SKILL.md version mismatch for ${path.basename(path.resolve(root))}: expected ${expectedVersion}, found ${actualVersion}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function readSkillFrontmatterVersion(source) {
|
||||
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source);
|
||||
if (!match) return null;
|
||||
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
const versionMatch = /^version:\s*["']?([^"'\s#]+)["']?\s*(?:#.*)?$/.exec(line.trim());
|
||||
if (versionMatch) return versionMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function listReleaseFiles(root) {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const files = [];
|
||||
@@ -53,6 +84,20 @@ export async function listReleaseFiles(root) {
|
||||
return files;
|
||||
}
|
||||
|
||||
async function findSkillMarkdown(root) {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
for (const name of ["SKILL.md", "skill.md"]) {
|
||||
const candidate = path.join(resolvedRoot, name);
|
||||
try {
|
||||
const stat = await fs.stat(candidate);
|
||||
if (stat.isFile()) return candidate;
|
||||
} catch {
|
||||
// Try the next supported skill filename.
|
||||
}
|
||||
}
|
||||
throw new Error(`Missing SKILL.md in ${resolvedRoot}`);
|
||||
}
|
||||
|
||||
export async function validateSelfContainedRelease(root) {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const files = await listReleaseFiles(root);
|
||||
|
||||
@@ -6,6 +6,9 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
listReleaseFiles,
|
||||
readSkillFrontmatterVersion,
|
||||
readSkillMetadataVersion,
|
||||
validateSkillMetadataVersion,
|
||||
validateSelfContainedRelease,
|
||||
} from "./release-files.mjs";
|
||||
|
||||
@@ -45,6 +48,34 @@ test("listReleaseFiles skips generated paths and returns sorted relative paths",
|
||||
);
|
||||
});
|
||||
|
||||
test("readSkillFrontmatterVersion reads quoted and unquoted versions", () => {
|
||||
assert.equal(readSkillFrontmatterVersion("---\nname: demo\nversion: 1.2.3\n---\n"), "1.2.3");
|
||||
assert.equal(readSkillFrontmatterVersion("---\nversion: \"2.0.0\"\n---\n"), "2.0.0");
|
||||
assert.equal(readSkillFrontmatterVersion("# Missing frontmatter\n"), null);
|
||||
});
|
||||
|
||||
test("validateSkillMetadataVersion accepts matching SKILL.md version", async (t) => {
|
||||
const root = await makeTempDir("baoyu-release-version-ok-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
await writeFile(path.join(root, "SKILL.md"), "---\nname: demo\nversion: 1.2.3\n---\n");
|
||||
|
||||
assert.equal(await readSkillMetadataVersion(root), "1.2.3");
|
||||
await assert.doesNotReject(() => validateSkillMetadataVersion(root, "1.2.3"));
|
||||
});
|
||||
|
||||
test("validateSkillMetadataVersion rejects mismatched SKILL.md version", async (t) => {
|
||||
const root = await makeTempDir("baoyu-release-version-mismatch-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
await writeFile(path.join(root, "SKILL.md"), "---\nname: demo\nversion: 1.2.3\n---\n");
|
||||
|
||||
await assert.rejects(
|
||||
() => validateSkillMetadataVersion(root, "1.2.4"),
|
||||
/SKILL\.md version mismatch/,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateSelfContainedRelease accepts file dependencies that stay within the release root", async (t) => {
|
||||
const root = await makeTempDir("baoyu-release-ok-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
const SKILL_PATH_PATTERN = /^skills\/([^/]+)\//;
|
||||
const CONVENTIONAL_SUBJECT_PATTERN =
|
||||
/^(?<type>[a-z][a-z0-9-]*)(?:\((?<scope>[^()\n]+)\))?(?<breaking>!)?: (?<description>\S[\s\S]*)$/;
|
||||
|
||||
export function parseConventionalCommitSubject(subject) {
|
||||
const match = CONVENTIONAL_SUBJECT_PATTERN.exec(subject.trim());
|
||||
if (!match?.groups) return null;
|
||||
|
||||
return {
|
||||
type: match.groups.type,
|
||||
scope: match.groups.scope ?? "",
|
||||
breaking: Boolean(match.groups.breaking),
|
||||
description: match.groups.description,
|
||||
};
|
||||
}
|
||||
|
||||
export function changedSkillsForPaths(paths) {
|
||||
const skills = new Set();
|
||||
for (const filePath of paths) {
|
||||
const normalizedPath = filePath.replaceAll("\\", "/");
|
||||
const match = SKILL_PATH_PATTERN.exec(normalizedPath);
|
||||
if (match) skills.add(match[1]);
|
||||
}
|
||||
return [...skills].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function validateSkillReleaseCommit({ commit = "", subject, paths }) {
|
||||
const skills = changedSkillsForPaths(paths);
|
||||
if (skills.length === 0) return [];
|
||||
|
||||
const parsed = parseConventionalCommitSubject(subject);
|
||||
if (parsed) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
commit,
|
||||
subject,
|
||||
skills,
|
||||
message: `Commit ${formatCommit(commit)} changes ${formatSkills(skills)} but its subject is not a Conventional Commit: ${subject}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function formatSkillReleaseFailures(failures) {
|
||||
if (failures.length === 0) return "";
|
||||
|
||||
const lines = [
|
||||
"Skill release commit check failed.",
|
||||
"",
|
||||
"Commits that touch skills/<name>/** must use Conventional Commit subjects so per-skill release tooling can derive a version bump.",
|
||||
"Example: fix(baoyu-post-to-wechat): handle WeChat editor focus",
|
||||
"",
|
||||
];
|
||||
|
||||
for (const failure of failures) {
|
||||
lines.push(`- ${failure.message}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatCommit(commit) {
|
||||
return commit ? commit.slice(0, 12) : "<unknown>";
|
||||
}
|
||||
|
||||
function formatSkills(skills) {
|
||||
return skills.map((skill) => `skills/${skill}/**`).join(", ");
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
changedSkillsForPaths,
|
||||
formatSkillReleaseFailures,
|
||||
parseConventionalCommitSubject,
|
||||
validateSkillReleaseCommit,
|
||||
} from "./skill-release-guard.mjs";
|
||||
|
||||
test("parseConventionalCommitSubject accepts scoped, unscoped, and breaking subjects", () => {
|
||||
assert.deepEqual(parseConventionalCommitSubject("fix(baoyu-post-to-wechat): repair editor paste"), {
|
||||
type: "fix",
|
||||
scope: "baoyu-post-to-wechat",
|
||||
breaking: false,
|
||||
description: "repair editor paste",
|
||||
});
|
||||
assert.deepEqual(parseConventionalCommitSubject("feat!: change skill metadata format"), {
|
||||
type: "feat",
|
||||
scope: "",
|
||||
breaking: true,
|
||||
description: "change skill metadata format",
|
||||
});
|
||||
assert.equal(parseConventionalCommitSubject("Fix WeChat browser article publishing"), null);
|
||||
});
|
||||
|
||||
test("changedSkillsForPaths returns sorted unique skills", () => {
|
||||
assert.deepEqual(
|
||||
changedSkillsForPaths([
|
||||
"README.md",
|
||||
"skills/baoyu-post-to-wechat/scripts/wechat-article.ts",
|
||||
"skills/baoyu-post-to-wechat/SKILL.md",
|
||||
"skills/baoyu-url-to-markdown/SKILL.md",
|
||||
]),
|
||||
["baoyu-post-to-wechat", "baoyu-url-to-markdown"],
|
||||
);
|
||||
});
|
||||
|
||||
test("validateSkillReleaseCommit ignores non-skill changes", () => {
|
||||
assert.deepEqual(
|
||||
validateSkillReleaseCommit({
|
||||
subject: "Fix test workflow",
|
||||
paths: [".github/workflows/test.yml"],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("validateSkillReleaseCommit rejects non-conventional skill commit subjects", () => {
|
||||
const failures = validateSkillReleaseCommit({
|
||||
commit: "81377416b4a7",
|
||||
subject: "Fix WeChat browser article publishing",
|
||||
paths: ["skills/baoyu-post-to-wechat/scripts/wechat-article.ts"],
|
||||
});
|
||||
|
||||
assert.equal(failures.length, 1);
|
||||
assert.match(failures[0]!.message, /Conventional Commit/);
|
||||
assert.match(formatSkillReleaseFailures(failures), /fix\(baoyu-post-to-wechat\):/);
|
||||
});
|
||||
|
||||
test("validateSkillReleaseCommit accepts conventional skill commit subjects", () => {
|
||||
assert.deepEqual(
|
||||
validateSkillReleaseCommit({
|
||||
subject: "fix(browser): ensure tab activation before copy/paste in WeChat editor",
|
||||
paths: ["skills/baoyu-post-to-wechat/scripts/wechat-article.ts"],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -5,7 +5,12 @@ import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { listReleaseFiles, mimeType, validateSelfContainedRelease } from "./lib/release-files.mjs";
|
||||
import {
|
||||
listReleaseFiles,
|
||||
mimeType,
|
||||
validateSelfContainedRelease,
|
||||
validateSkillMetadataVersion,
|
||||
} from "./lib/release-files.mjs";
|
||||
|
||||
const DEFAULT_REGISTRY = "https://clawhub.ai";
|
||||
|
||||
@@ -21,6 +26,7 @@ async function main() {
|
||||
? await fs.readFile(path.resolve(options.changelogFile), "utf8")
|
||||
: "";
|
||||
|
||||
await validateSkillMetadataVersion(skillDir, options.version);
|
||||
await validateSelfContainedRelease(skillDir);
|
||||
const files = await listReleaseFiles(skillDir);
|
||||
if (files.length === 0) {
|
||||
|
||||
+46
-15
@@ -6,7 +6,13 @@ import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
import { listReleaseFiles, mimeType, validateSelfContainedRelease } from "./lib/release-files.mjs";
|
||||
import {
|
||||
listReleaseFiles,
|
||||
mimeType,
|
||||
readSkillMetadataVersion,
|
||||
validateSelfContainedRelease,
|
||||
validateSkillMetadataVersion,
|
||||
} from "./lib/release-files.mjs";
|
||||
|
||||
const DEFAULT_REGISTRY = "https://clawhub.ai";
|
||||
|
||||
@@ -38,9 +44,11 @@ async function main() {
|
||||
|
||||
const locals = await mapWithConcurrency(skills, options.concurrency, async (skill) => {
|
||||
const files = await collectReleaseFiles(skill.folder);
|
||||
const localVersion = await readSkillMetadataVersion(skill.folder);
|
||||
const fingerprint = buildFingerprint(files);
|
||||
return {
|
||||
...skill,
|
||||
localVersion,
|
||||
fileCount: files.length,
|
||||
fingerprint,
|
||||
};
|
||||
@@ -119,12 +127,12 @@ async function main() {
|
||||
for (const candidate of actionable) {
|
||||
const version =
|
||||
candidate.status === "new"
|
||||
? "1.0.0"
|
||||
: bumpSemver(candidate.latestVersion, options.bump);
|
||||
? candidate.localVersion
|
||||
: resolveUpdateVersion(candidate, options.bump);
|
||||
|
||||
console.log(`Publishing ${candidate.slug}@${version}`);
|
||||
try {
|
||||
const files = await collectReleaseFiles(candidate.folder);
|
||||
const files = await collectReleaseFiles(candidate.folder, version);
|
||||
await publishSkill({
|
||||
registry,
|
||||
token: config.token,
|
||||
@@ -325,7 +333,10 @@ async function hasSkillMarker(folder) {
|
||||
);
|
||||
}
|
||||
|
||||
async function collectReleaseFiles(root) {
|
||||
async function collectReleaseFiles(root, expectedVersion = "") {
|
||||
if (expectedVersion) {
|
||||
await validateSkillMetadataVersion(root, expectedVersion);
|
||||
}
|
||||
await validateSelfContainedRelease(root);
|
||||
return listReleaseFiles(root);
|
||||
}
|
||||
@@ -423,28 +434,48 @@ async function mapWithConcurrency(items, limit, fn) {
|
||||
|
||||
function formatCandidate(candidate, bump) {
|
||||
if (candidate.status === "new") {
|
||||
return `${candidate.slug} NEW (${candidate.fileCount} files)`;
|
||||
return `${candidate.slug} NEW ${candidate.localVersion} (${candidate.fileCount} files)`;
|
||||
}
|
||||
return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${bumpSemver(
|
||||
candidate.latestVersion,
|
||||
return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${resolveUpdateVersion(
|
||||
candidate,
|
||||
bump
|
||||
)} (${candidate.fileCount} files)`;
|
||||
}
|
||||
|
||||
function bumpSemver(version, bump) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
|
||||
if (!match) {
|
||||
throw new Error(`Invalid semver: ${version}`);
|
||||
function resolveUpdateVersion(candidate, bump) {
|
||||
if (compareSemver(candidate.localVersion, candidate.latestVersion) > 0) {
|
||||
return candidate.localVersion;
|
||||
}
|
||||
const major = Number(match[1]);
|
||||
const minor = Number(match[2]);
|
||||
const patch = Number(match[3]);
|
||||
return bumpSemver(candidate.latestVersion, bump);
|
||||
}
|
||||
|
||||
function compareSemver(left, right) {
|
||||
const leftParts = parseSemver(left);
|
||||
const rightParts = parseSemver(right);
|
||||
for (let index = 0; index < leftParts.length; index += 1) {
|
||||
if (leftParts[index] !== rightParts[index]) {
|
||||
return leftParts[index] - rightParts[index];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function bumpSemver(version, bump) {
|
||||
const [major, minor, patch] = parseSemver(version);
|
||||
|
||||
if (bump === "major") return `${major + 1}.0.0`;
|
||||
if (bump === "minor") return `${major}.${minor + 1}.0`;
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
|
||||
function parseSemver(version) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
|
||||
if (!match) {
|
||||
throw new Error(`Invalid semver: ${version}`);
|
||||
}
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
function sanitizeSlug(value) {
|
||||
return value
|
||||
.trim()
|
||||
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# Sync packages/baoyu-codex-imagegen/src/*.ts (excluding tests) into
|
||||
# skills/baoyu-image-gen/scripts/codex-imagegen/ so the skill stays
|
||||
# self-contained (no `../../../../packages/...` lookups at runtime).
|
||||
#
|
||||
# Run this whenever packages/baoyu-codex-imagegen/src/ changes,
|
||||
# and always before tagging a release.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SRC_DIR="$REPO_ROOT/packages/baoyu-codex-imagegen/src"
|
||||
DST_DIR="$REPO_ROOT/skills/baoyu-image-gen/scripts/codex-imagegen"
|
||||
|
||||
if [[ ! -d "$SRC_DIR" ]]; then
|
||||
echo "Error: source dir missing: $SRC_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DST_DIR"
|
||||
|
||||
changed=0
|
||||
for f in cache.ts logger.ts main.ts parser.ts spawn.ts types.ts validator.ts; do
|
||||
src="$SRC_DIR/$f"
|
||||
dst="$DST_DIR/$f"
|
||||
if [[ ! -f "$src" ]]; then
|
||||
echo "Error: missing source file: $src" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$dst" ]] || ! cmp -s "$src" "$dst"; then
|
||||
cp "$src" "$dst"
|
||||
echo "synced: $f"
|
||||
changed=$((changed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
chmod +x "$DST_DIR/main.ts"
|
||||
|
||||
# Drop any stale .ts files in DST that don't exist in SRC.
|
||||
for dst in "$DST_DIR"/*.ts; do
|
||||
[[ -e "$dst" ]] || continue
|
||||
name="$(basename "$dst")"
|
||||
case "$name" in
|
||||
*.test.ts) rm -f "$dst"; echo "removed test artifact: $name" ;;
|
||||
*)
|
||||
if [[ ! -f "$SRC_DIR/$name" ]]; then
|
||||
rm -f "$dst"
|
||||
echo "removed stale: $name"
|
||||
changed=$((changed + 1))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$changed" -eq 0 ]]; then
|
||||
echo "codex-imagegen sync: up to date"
|
||||
else
|
||||
echo "codex-imagegen sync: $changed file(s) updated"
|
||||
fi
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import {
|
||||
formatSkillReleaseFailures,
|
||||
validateSkillReleaseCommit,
|
||||
} from "./lib/skill-release-guard.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const ZERO_SHA = /^0{40}$/;
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const range = await resolveRange(options);
|
||||
const commits = await listCommits(range);
|
||||
|
||||
const failures = [];
|
||||
for (const commit of commits) {
|
||||
const [subject, paths] = await Promise.all([readCommitSubject(commit), readCommitPaths(commit)]);
|
||||
failures.push(...validateSkillReleaseCommit({ commit, subject, paths }));
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(formatSkillReleaseFailures(failures));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Skill release commit check passed (${commits.length} commit${commits.length === 1 ? "" : "s"} checked).`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
base: "",
|
||||
head: "HEAD",
|
||||
range: "",
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--base") {
|
||||
options.base = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--head") {
|
||||
options.head = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--range") {
|
||||
options.range = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "-h" || arg === "--help") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: verify-skill-release-commits.mjs [--base <rev> --head <rev> | --range <rev-range>]
|
||||
|
||||
Checks non-merge commits in the selected range. Any commit that touches
|
||||
skills/<name>/** must use a Conventional Commit subject, for example:
|
||||
fix(baoyu-post-to-wechat): handle WeChat editor focus
|
||||
|
||||
Without explicit arguments, GitHub Actions event metadata is used when
|
||||
available. Otherwise the fallback range is HEAD^..HEAD.`);
|
||||
}
|
||||
|
||||
async function resolveRange(options) {
|
||||
if (options.range) {
|
||||
return { args: [options.range], label: options.range };
|
||||
}
|
||||
|
||||
if (options.base) {
|
||||
return {
|
||||
args: [`${options.base}..${options.head || "HEAD"}`],
|
||||
label: `${options.base}..${options.head || "HEAD"}`,
|
||||
};
|
||||
}
|
||||
|
||||
const githubRange = await resolveGitHubRange();
|
||||
if (githubRange) return githubRange;
|
||||
|
||||
return { args: ["HEAD^..HEAD"], label: "HEAD^..HEAD" };
|
||||
}
|
||||
|
||||
async function resolveGitHubRange() {
|
||||
const eventPath = process.env.GITHUB_EVENT_PATH;
|
||||
if (!eventPath) return null;
|
||||
|
||||
let event = null;
|
||||
try {
|
||||
event = JSON.parse(await fs.readFile(eventPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (event?.pull_request?.base?.sha) {
|
||||
const base = event.pull_request.base.sha;
|
||||
const head = process.env.GITHUB_SHA || "HEAD";
|
||||
return { args: [`${base}..${head}`], label: `${base}..${head}` };
|
||||
}
|
||||
|
||||
if (event?.before && !ZERO_SHA.test(event.before)) {
|
||||
const head = event.after || process.env.GITHUB_SHA || "HEAD";
|
||||
return { args: [`${event.before}..${head}`], label: `${event.before}..${head}` };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listCommits(range) {
|
||||
const output = await git(["rev-list", "--no-merges", "--reverse", ...range.args]);
|
||||
return output ? output.split("\n").filter(Boolean) : [];
|
||||
}
|
||||
|
||||
async function readCommitSubject(commit) {
|
||||
return git(["log", "-1", "--format=%s", commit]);
|
||||
}
|
||||
|
||||
async function readCommitPaths(commit) {
|
||||
const output = await git(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", commit]);
|
||||
return output ? output.split("\n").filter(Boolean) : [];
|
||||
}
|
||||
|
||||
async function git(args) {
|
||||
const { stdout } = await execFileAsync("git", args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return stdout.trimEnd();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-article-illustrator
|
||||
description: Analyzes article structure, identifies positions requiring visual aids, generates illustrations with Type × Style × Palette three-dimension approach. Use when user asks to "illustrate article", "add images", "generate images for article", or "为文章配图".
|
||||
version: 1.58.0
|
||||
version: 1.117.4
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-article-illustrator
|
||||
@@ -28,16 +28,39 @@ When this 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 `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):
|
||||
- If the current runtime exposes a native image tool (e.g., Codex `imagegen`, Hermes `image_generate`), use it. Runtime-native tools are preferred by default — agents that know their own tool inventory should surface the native one here.
|
||||
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-imagine`), use it.
|
||||
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-image-gen`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime exposes no native `imagegen` skill but the `codex` CLI is on `PATH` with an active `codex login`, route through `baoyu-image-gen --provider codex-cli` (preferred), or — if baoyu-image-gen is unavailable — invoke the bundled wrapper directly. Details, parameters, and the runtime-discovery procedure live in [references/codex-imagegen.md](references/codex-imagegen.md) — load that file only when this branch is selected.
|
||||
- **Cursor (`GenerateImage`)** — if the runtime exposes a native `GenerateImage` tool, you are running inside Cursor and it outranks any non-native skill the same way Codex `imagegen` does. Two hard caveats: (a) it has no aspect-ratio parameter — state the target aspect ratio / dimensions explicitly in the prompt text passed as `description`; (b) it does not accept an output directory — it saves to a tool-managed location, so after generation copy/move the file to the skill's expected output path (e.g., `outputs/.../NN-xxx.png`). Reference images go in `reference_image_paths`.
|
||||
- **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way.
|
||||
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-image-gen`), use it.
|
||||
- Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions.
|
||||
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.
|
||||
|
||||
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace labels, captions, or any other text inside an already generated illustration. If text is wrong or unclear, regenerate from a corrected prompt, redraw with less or no on-image text, or ask the user which imperfect candidate to keep.
|
||||
|
||||
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
||||
|
||||
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
|
||||
|
||||
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
|
||||
Concrete tool names (`imagegen`, `GenerateImage`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
|
||||
|
||||
## Batch Generation Policy
|
||||
|
||||
After every prompt file for the run has been saved and verified, generate images in batches by default.
|
||||
|
||||
Priority order:
|
||||
|
||||
1. Use the chosen backend's native batch / multi-task interface if it exists. Each task must keep its own prompt file, output path, aspect ratio, and direct reference images.
|
||||
2. If no native batch interface exists but the runtime can issue parallel tool calls, dispatch up to `generation_batch_size` images at a time. Default: `4`. An explicit user request in the current message, such as `--batch-size 4` or "并行4张一起生成", overrides EXTEND.md.
|
||||
3. If neither native batch nor parallel tool calls are available, generate sequentially.
|
||||
|
||||
Rules:
|
||||
|
||||
- Never start the first batch until all prompt files for that batch exist on disk.
|
||||
- Retry failed items once without regenerating successful items.
|
||||
- Do not use subagents merely to parallelize image rendering. Use subagents only for separate prompt iteration or creative exploration.
|
||||
|
||||
## Confirmation Policy
|
||||
|
||||
@@ -52,7 +75,7 @@ Default behavior: **confirm before generation**.
|
||||
|
||||
Users may supply reference images via `--ref <files...>` or by providing file paths / pasting images in conversation. Refs guide style, palette, composition, or subject for specific illustrations.
|
||||
|
||||
Full detection, storage, and processing rules are in [references/workflow.md](references/workflow.md) (Step 1.0 saves to `references/NN-ref-{slug}.{ext}`; Step 5.3 processes per-illustration usage `direct | style | palette`). When the chosen backend supports batch input, `direct`-usage entries in each prompt file's `references:` frontmatter should be propagated into its batch payload so backends can pass them through (e.g. `baoyu-imagine` accepts `ref` per task).
|
||||
Full detection, storage, and processing rules are in [references/workflow.md](references/workflow.md) (Step 1.0 saves to `references/NN-ref-{slug}.{ext}`; Step 5.3 processes per-illustration usage `direct | style | palette`). When the chosen backend supports batch input, `direct`-usage entries in each prompt file's `references:` frontmatter should be propagated into its batch payload so backends can pass them through (e.g. `baoyu-image-gen` accepts `ref` per task).
|
||||
|
||||
## Three Dimensions
|
||||
|
||||
@@ -164,7 +187,8 @@ Full template: [references/workflow.md](references/workflow.md#step-4-generate-o
|
||||
4. LABELS **MUST** include article-specific data: actual numbers, terms, metrics, quotes
|
||||
5. **DO NOT** pass ad-hoc inline prompts to `--prompt` without saving prompt files first
|
||||
6. Select the backend via the `## Image Generation Tools` rule at the top: use whatever is available; if multiple, ask the user once. Do this once per session before any generation.
|
||||
7. **Execution strategy**: When multiple illustrations have saved prompt files and the task is now plain generation, prefer the chosen backend's batch interface (if it offers one) over spawning subagents. Use subagents only when each image still needs separate prompt iteration or creative exploration. If the backend has no batch interface, generate sequentially.
|
||||
- **`codex-imagegen` invocation**: when the rule resolves to `codex-imagegen`, see [references/codex-imagegen.md](references/codex-imagegen.md) for the invocation contract (preferred `baoyu-image-gen --provider codex-cli` path, runtime wrapper discovery, parameter notes, stdout schema, batch semantics).
|
||||
7. **Execution strategy**: Generate in batches per the `## Batch Generation Policy`: backend native batch first, runtime parallel tool calls second, sequential only as fallback. Default batch size is 4 unless EXTEND.md or the current request overrides it.
|
||||
8. Process references (`direct`/`style`/`palette`) per prompt frontmatter
|
||||
9. Apply watermark if EXTEND.md enabled
|
||||
10. Generate from saved prompt files; retry once on failure
|
||||
@@ -214,6 +238,12 @@ When input is **pasted content** (no file path), always uses `illustrations/{top
|
||||
| Add | Position → Prompt → Generate → Update outline → Insert |
|
||||
| Delete | Delete files → Remove reference → Update outline |
|
||||
|
||||
Text correction policy:
|
||||
|
||||
- If any rendered text (labels, captions, etc.) is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
|
||||
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
|
||||
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
|
||||
|
||||
## References
|
||||
|
||||
| File | Content |
|
||||
@@ -234,7 +264,8 @@ EXTEND.md lives at the first matching path listed in Step 1.5. Three ways to cha
|
||||
- **Common one-line edits**:
|
||||
- `preferred_image_backend: auto` — default; runtime-native tool wins, falls back to the only installed backend, asks only if multiple non-native are present.
|
||||
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
|
||||
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
|
||||
- `preferred_image_backend: baoyu-image-gen` — pin to the baoyu-image-gen skill.
|
||||
- `preferred_image_backend: ask` — confirm backend every run.
|
||||
- `generation_batch_size: 4` — default number of images to render concurrently when the runtime supports parallel generation calls.
|
||||
- `preferred_type: infographic`, `preferred_style: notion`, `preferred_palette: macaron`, `language: zh`.
|
||||
- `default_output_dir: imgs-subdir` — where to write generated images relative to the article.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# `codex-imagegen` Wrapper Invocation
|
||||
|
||||
Load this reference only when the [Image Generation Tools](../SKILL.md#image-generation-tools) rule has resolved to `codex-imagegen` — i.e., the current runtime exposes no native `imagegen` skill but `codex` CLI is on `PATH` with an active `codex login`.
|
||||
|
||||
## Preferred path: route through `baoyu-image-gen`
|
||||
|
||||
If the `baoyu-image-gen` skill is available in this runtime, **always** invoke through it rather than calling the wrapper directly. It handles retry/cache/batch/EXTEND.md preferences uniformly with every other provider.
|
||||
|
||||
```bash
|
||||
${BUN_X} <baoyu-image-gen-base>/scripts/main.ts \
|
||||
--provider codex-cli \
|
||||
--image <ABSOLUTE_output> \
|
||||
--promptfiles <ABSOLUTE_prompts/NN-{type}-{slug}.md> \
|
||||
--ar <ratio> \
|
||||
[--ref <ABSOLUTE_file>]...
|
||||
```
|
||||
|
||||
Resolve `<baoyu-image-gen-base>` the same way you resolve any sibling skill — through your runtime's skill registry (`Skill` tool, plugin marketplace, or `$HOME/.baoyu-skills/baoyu-image-gen/`).
|
||||
|
||||
## Fallback: spawn the wrapper directly
|
||||
|
||||
Only when `baoyu-image-gen` is NOT installed in the current runtime. Discover the wrapper's location at runtime — do NOT hard-code `../../packages/...` from this skill:
|
||||
|
||||
1. **Honor explicit override**: if `$BAOYU_CODEX_IMAGEGEN_BIN` is set and points to a real file, use that path. It may be `.ts` (spawn `bun <path>`) or `.sh`/binary (spawn directly).
|
||||
2. **Search the plugin root**: walk up from this skill's directory looking for `packages/baoyu-codex-imagegen/src/main.ts`. If found, that is the wrapper. Spawn it with `bun`.
|
||||
3. **Last resort**: tell the user that `codex-imagegen` is not available in this runtime and ask whether to install the `baoyu-skills` plugin (or set `BAOYU_CODEX_IMAGEGEN_BIN`) or pick another backend.
|
||||
|
||||
Once located, the invocation shape is:
|
||||
|
||||
```bash
|
||||
bun <WRAPPER>/main.ts \
|
||||
--image <ABSOLUTE_output> \
|
||||
--prompt-file <ABSOLUTE_prompts/NN-{type}-{slug}.md> \
|
||||
--aspect <ratio> \
|
||||
[--ref <ABSOLUTE_file>]... \
|
||||
[--timeout <ms>] \
|
||||
[--cache-dir ~/.cache/baoyu-codex-imagegen] \
|
||||
[--log-file <ABSOLUTE_jsonl_log_path>]
|
||||
```
|
||||
|
||||
If `bun` is missing, `npx -y bun <WRAPPER>/main.ts ...` works as a fallback.
|
||||
|
||||
## Parameter notes
|
||||
|
||||
- **All filesystem inputs** are auto-resolved against `process.cwd()` when relative, but agents should pass absolute paths to be robust against cwd drift.
|
||||
- **`--timeout`** defaults to `300000` (5 min) per `codex exec` attempt. Raise (e.g. `--timeout 600000` for 10 min) on slow networks or large prompts.
|
||||
- **`--cache-dir`** is off by default. Enable for repeatable runs to skip redundant generations of the same prompt+aspect+refs.
|
||||
- **Authentication**: the wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` is read or sent.
|
||||
|
||||
## Stdout contract
|
||||
|
||||
Single JSON line:
|
||||
|
||||
- Success: `{"status":"ok","path":"...","bytes":N,"elapsed_seconds":N,"thread_id":"...","attempts":N,"cached":bool,...}`
|
||||
- Failure: `{"status":"error","path":"...","bytes":0,"error":"...","error_kind":"..."}`
|
||||
|
||||
`error_kind` values: `codex_not_installed`, `invalid_args`, `prompt_file_missing`, `spawn_failed`, `timeout`, `no_image_gen_tool_use`, `output_missing`, `invalid_png`, `agent_refused`, `lock_busy`.
|
||||
|
||||
On retryable errors (timeout, spawn_failed, no_image_gen_tool_use, output_missing, invalid_png, agent_refused), ask the user whether to retry or fall back to another backend.
|
||||
|
||||
## Batch semantics
|
||||
|
||||
- Codex `image_gen` returns **one image per call** (`n=1` only). Multi-image jobs must dispatch one wrapper call per image.
|
||||
- The wrapper does NOT accept a `--sessionId` flag. Chain/scene consistency must come from `--ref` reference images.
|
||||
- `--size` and `--quality` are silently ignored — Codex picks pixel dimensions from `--aspect`.
|
||||
@@ -129,12 +129,15 @@ preferred_style:
|
||||
default_output_dir: imgs-subdir # same-dir | imgs-subdir | illustrations-subdir | independent
|
||||
language: null
|
||||
preferred_image_backend: auto
|
||||
generation_batch_size: 4
|
||||
custom_styles: []
|
||||
---
|
||||
```
|
||||
|
||||
`preferred_image_backend: auto` is the baked-in default — first-time setup does not ask about it. The `## Image Generation Tools` rule in SKILL.md then picks the runtime-native tool (Codex `imagegen`, Hermes `image_generate`, etc.) when available, and falls back to installed backends.
|
||||
|
||||
`generation_batch_size: 4` is the baked-in default for batch rendering. The current user request may override it for one run.
|
||||
|
||||
## Modifying Preferences Later
|
||||
|
||||
See the `## Changing Preferences` section in `SKILL.md` for the canonical list of common edits (pin backend, change defaults, retrigger setup). Full schema: `preferences-schema.md`.
|
||||
|
||||
@@ -28,6 +28,8 @@ default_output_dir: null # same-dir|illustrations-subdir|independent
|
||||
|
||||
preferred_image_backend: auto # auto|ask|<backend-id>
|
||||
|
||||
generation_batch_size: 4 # 1-8, used when backend/runtime supports batch or parallel generation
|
||||
|
||||
custom_styles:
|
||||
- name: my-style
|
||||
description: "Style description"
|
||||
@@ -54,7 +56,8 @@ custom_styles:
|
||||
| `preferred_palette` | string | null | Palette override (macaron, warm, neon, or null) |
|
||||
| `language` | string | null | Output language (null = auto-detect) |
|
||||
| `default_output_dir` | enum | null | Output directory preference (null = ask each time) |
|
||||
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
||||
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-image-gen`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
||||
| `generation_batch_size` | int | 4 | Number of images to dispatch per batch when the backend has native batch support or the runtime can issue parallel generation calls. Clamp invalid values to 1-8. Current user request overrides this value. |
|
||||
| `custom_styles` | array | [] | User-defined styles |
|
||||
|
||||
## Position Options
|
||||
@@ -118,6 +121,8 @@ language: zh
|
||||
|
||||
preferred_image_backend: codex-imagegen
|
||||
|
||||
generation_batch_size: 4
|
||||
|
||||
custom_styles:
|
||||
- name: corporate
|
||||
description: "Professional B2B style"
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
# Specify density
|
||||
/baoyu-article-illustrator path/to/article.md --density rich
|
||||
|
||||
# Generate up to 4 images in parallel after prompts are saved
|
||||
/baoyu-article-illustrator path/to/article.md --batch-size 4
|
||||
|
||||
# Direct content input (paste mode)
|
||||
/baoyu-article-illustrator
|
||||
[paste content]
|
||||
@@ -31,6 +34,7 @@
|
||||
| `--style <name>` | Visual style (see references/styles.md) |
|
||||
| `--preset <name>` | Shorthand for type + style combo (see [references/style-presets.md](references/style-presets.md)) |
|
||||
| `--density <level>` | Image count: minimal / balanced / rich |
|
||||
| `--batch-size <n>` | Temporary generation batch size for this run. Default: `generation_batch_size` from EXTEND.md, otherwise 4. Clamp to 1-8. |
|
||||
|
||||
## Input Modes
|
||||
|
||||
|
||||
@@ -335,8 +335,11 @@ Prompt Files:
|
||||
**DO NOT** pass ad-hoc inline text to `--prompt` without first saving prompt files. The generation command should either use `--promptfiles prompts/NN-{type}-{slug}.md` or read the saved file content for `--prompt`.
|
||||
|
||||
**Execution choice**:
|
||||
- If multiple illustrations already have saved prompt files and the task is now plain generation, prefer the chosen backend's batch interface (if it offers one); otherwise generate sequentially
|
||||
- Use subagents only when each illustration still needs separate prompt rewriting, style exploration, or other per-image reasoning before generation
|
||||
- If multiple illustrations already have saved prompt files and the task is now plain generation, use batch generation by default.
|
||||
- Prefer the chosen backend's native batch / multi-task interface when available.
|
||||
- If the backend has no native batch interface but the runtime can issue parallel tool calls, dispatch up to `generation_batch_size` tasks at a time. Default: `4`. The current user request overrides EXTEND.md.
|
||||
- Generate sequentially only when neither backend batch nor runtime parallel calls are available.
|
||||
- Use subagents only when each illustration still needs separate prompt rewriting, style exploration, or other per-image reasoning before generation. Do not use subagents just to parallelize rendering.
|
||||
|
||||
**CRITICAL - References in Frontmatter**:
|
||||
- Only add `references` field if files ACTUALLY EXIST in `references/` directory
|
||||
@@ -345,7 +348,14 @@ Prompt Files:
|
||||
|
||||
### 5.2 Select Generation Skill
|
||||
|
||||
Check available skills. If multiple, ask user.
|
||||
Follow the `## Image Generation Tools` rule at the top of `SKILL.md`. Concretely:
|
||||
|
||||
- If `imagegen` is in your available-skills list (Codex), use it — invoke via the `Skill` tool with `skill: "imagegen"`.
|
||||
- Else if the EXTEND.md pin is available, use it.
|
||||
- Else if exactly one non-native backend is installed, use it.
|
||||
- Else, ask the user.
|
||||
|
||||
**Do not generate SVG, HTML, or any code-based vector as a substitute for the raster image.** If no raster backend can be resolved, ask the user how to proceed.
|
||||
|
||||
### 5.3 Process References ⚠️ REQUIRED if references saved in Step 1.0
|
||||
|
||||
@@ -371,7 +381,7 @@ Check available skills. If multiple, ask user.
|
||||
|
||||
| Skill Supports `--ref` | Action |
|
||||
|------------------------|--------|
|
||||
| Yes (e.g., baoyu-imagine with Google) | Pass reference images via `--ref` |
|
||||
| Yes (e.g., baoyu-image-gen with Google) | Pass reference images via `--ref` |
|
||||
| No | Convert to text description, append to prompt |
|
||||
|
||||
**Verification**: Before generating, confirm reference processing:
|
||||
@@ -387,12 +397,18 @@ Add: `Include a subtle watermark "[content]" at [position].`
|
||||
|
||||
### 5.5 Generate
|
||||
|
||||
1. For each illustration:
|
||||
- **Backup rule**: If image file exists, rename to `NN-{type}-{slug}-backup-YYYYMMDD-HHMMSS.md`
|
||||
- If references with `direct` usage: include `--ref` parameter
|
||||
- Generate image
|
||||
2. After each: "Generated X/N"
|
||||
3. On failure: retry once, then log and continue
|
||||
1. Build a generation task list from saved prompt files:
|
||||
- `prompt_file`: `{output-dir}/prompts/NN-{type}-{slug}.md`
|
||||
- `output_file`: `{output-dir}/NN-{type}-{slug}.png`
|
||||
- `aspect_ratio`: from prompt frontmatter or prompt body
|
||||
- `refs`: only verified `direct` references from prompt frontmatter
|
||||
2. **Backup rule**: Before dispatching a task, if its output image already exists, rename it to `NN-{type}-{slug}-backup-YYYYMMDD-HHMMSS.{ext}`.
|
||||
3. Dispatch tasks in batches:
|
||||
- Native batch backend: send all eligible tasks, or chunks of `generation_batch_size` if the backend has a practical limit.
|
||||
- Runtime parallel calls: issue up to `generation_batch_size` image calls concurrently, then continue with the next chunk.
|
||||
- Sequential fallback: process one task at a time.
|
||||
4. After each completed task, record: "Generated X/N: filename".
|
||||
5. On failure: retry the failed task once from the same saved prompt file. Keep successful outputs and continue.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-comic
|
||||
description: Knowledge comic creator supporting multiple art styles and tones. Creates original educational comics with detailed panel layouts and sequential image generation. Use when user asks to create "知识漫画", "教育漫画", "biography comic", "tutorial comic", or "Logicomix-style comic".
|
||||
version: 1.56.1
|
||||
description: Knowledge comic creator supporting multiple art styles and tones. Creates original educational comics with detailed panel layouts and batch-capable image generation. Use when user asks to create "知识漫画", "教育漫画", "biography comic", "tutorial comic", or "Logicomix-style comic".
|
||||
version: 1.117.4
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-comic
|
||||
@@ -32,16 +32,40 @@ When this 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 `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):
|
||||
- If the current runtime exposes a native image tool (e.g., Codex `imagegen`, Hermes `image_generate`), use it. Runtime-native tools are preferred by default — agents that know their own tool inventory should surface the native one here.
|
||||
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-imagine`), use it.
|
||||
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-image-gen`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime exposes no native `imagegen` skill but the `codex` CLI is on `PATH` with an active `codex login`, route through `baoyu-image-gen --provider codex-cli` (preferred), or — if baoyu-image-gen is unavailable — invoke the bundled wrapper directly. Details, parameters, and the runtime-discovery procedure live in [references/codex-imagegen.md](references/codex-imagegen.md) — load that file only when this branch is selected.
|
||||
- **Cursor (`GenerateImage`)** — if the runtime exposes a native `GenerateImage` tool, you are running inside Cursor and it outranks any non-native skill the same way Codex `imagegen` does. Two hard caveats: (a) it has no aspect-ratio parameter — state the target aspect ratio / dimensions explicitly in the prompt text passed as `description`; (b) it does not accept an output directory — it saves to a tool-managed location, so after generation copy/move the file to the skill's expected output path (e.g., `outputs/.../NN-xxx.png`). Reference images go in `reference_image_paths`.
|
||||
- **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way.
|
||||
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-image-gen`), use it.
|
||||
- Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions.
|
||||
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.
|
||||
|
||||
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace dialogue, sound effects, panel labels, or any other text inside an already generated comic page. If text is wrong or unclear, regenerate from a corrected prompt, redraw the page with less or no on-image text, or ask the user which imperfect candidate to keep.
|
||||
|
||||
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
||||
|
||||
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
|
||||
|
||||
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
|
||||
Concrete tool names (`imagegen`, `GenerateImage`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
|
||||
|
||||
## Batch Generation Policy
|
||||
|
||||
After every prompt file for the current generation group has been saved and verified, generate images in batches by default.
|
||||
|
||||
Priority order:
|
||||
|
||||
1. Use the chosen backend's native batch / multi-task interface if it exists. Each task must keep its own prompt file, output path, aspect ratio, session ID, and direct reference images.
|
||||
2. If no native batch interface exists but the runtime can issue parallel tool calls, dispatch up to `generation_batch_size` images at a time. Default: `4`. An explicit user request in the current message, such as `--batch-size 4` or "并行4张一起生成", overrides EXTEND.md.
|
||||
3. If neither native batch nor parallel tool calls are available, generate sequentially.
|
||||
|
||||
Rules:
|
||||
|
||||
- Honor workflow dependencies first: generate `characters/characters.png` before pages that use it as a reference.
|
||||
- Never start the first page batch until all selected page prompt files exist on disk.
|
||||
- Retry failed items once without regenerating successful items.
|
||||
- Do not use subagents merely to parallelize image rendering. Use subagents only for separate prompt iteration or creative exploration.
|
||||
|
||||
## Reference Images
|
||||
|
||||
@@ -87,6 +111,7 @@ references:
|
||||
| `--aspect` | 3:4 (default, portrait), 4:3 (landscape), 16:9 (widescreen) | Page aspect ratio |
|
||||
| `--lang` | auto (default), zh, en, ja, etc. | Output language |
|
||||
| `--ref <files...>` | File paths | Reference images applied to every page for style / palette / scene guidance. See [Reference Images](#reference-images) above. |
|
||||
| `--batch-size <n>` | 1-8 | Temporary page generation batch size for this run. Default: `generation_batch_size` from EXTEND.md, otherwise 4. |
|
||||
|
||||
### Partial Workflow Options
|
||||
|
||||
@@ -222,7 +247,9 @@ Analyze → [Check Existing?] → [Confirm: Style + Reviews] → Storyboard →
|
||||
|
||||
### Step 7: Image Generation
|
||||
|
||||
**Pick a backend once per session** using the `## Image Generation Tools` rule at the top. If the backend is a repo skill (e.g., `baoyu-imagine`), read its `SKILL.md` and use its documented interface rather than its scripts.
|
||||
**Pick a backend once per session** using the `## Image Generation Tools` rule at the top. If the backend is a repo skill (e.g., `baoyu-image-gen`), read its `SKILL.md` and use its documented interface rather than its scripts.
|
||||
|
||||
**`codex-imagegen` invocation**: when the rule resolves to `codex-imagegen`, see [references/codex-imagegen.md](references/codex-imagegen.md) for the invocation contract (preferred `baoyu-image-gen --provider codex-cli` path, runtime wrapper discovery, parameter notes, stdout schema, batch semantics — n=1 per call so page batches must dispatch one wrapper call per page).
|
||||
|
||||
**7.1 Character sheet** — generate it (to `characters/characters.png`, aspect `4:3`) when the comic is multi-page with recurring characters. Skip for simple presets (e.g., four-panel minimalist) or single-page comics. Compress to JPEG before use-as-`--ref` (`sips -s format jpeg -s formatOptions 80 …` on macOS, `pngquant --quality=65-80 …` elsewhere) to avoid payload failures. The prompt file at `characters/characters.md` must exist before invoking the backend.
|
||||
|
||||
@@ -234,6 +261,8 @@ Analyze → [Check Existing?] → [Confirm: Style + Reviews] → Storyboard →
|
||||
| Exists | Not supported | Prepend character descriptions to every prompt file |
|
||||
| Skipped | — | All descriptions inline in prompt |
|
||||
|
||||
**Execution strategy**: Generate the character sheet first when needed. Then build the selected page task list from saved prompt files and dispatch pages in batches per the `## Batch Generation Policy`: backend native batch first, runtime parallel tool calls second, sequential only as fallback. `--regenerate N` and `--images-only` apply the same batching rules to the selected existing prompts.
|
||||
|
||||
**Backup rule**: existing `prompts/…md` and `…png` files → rename with `-backup-YYYYMMDD-HHMMSS` suffix before regenerating. Aspect ratio from storyboard (default `3:4`; preset may override).
|
||||
|
||||
**`--ref` failure recovery**: compress sheet → retry → still fails → drop `--ref` and embed character descriptions in the prompt text.
|
||||
@@ -254,7 +283,7 @@ If EXTEND.md is not found, first-time setup is **blocking** — complete it befo
|
||||
| Found | Read, parse, display summary → continue |
|
||||
| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → save EXTEND.md → continue |
|
||||
|
||||
**EXTEND.md supports**: watermark, preferred art/tone/layout, custom style definitions, character presets, language preference. Schema: [references/config/preferences-schema.md](references/config/preferences-schema.md).
|
||||
**EXTEND.md supports**: watermark, preferred art/tone/layout, custom style definitions, character presets, language preference, preferred image backend, generation batch size. Schema: [references/config/preferences-schema.md](references/config/preferences-schema.md).
|
||||
|
||||
## References
|
||||
|
||||
@@ -290,6 +319,12 @@ If EXTEND.md is not found, first-time setup is **blocking** — complete it befo
|
||||
|
||||
**IMPORTANT**: When updating pages, ALWAYS update the prompt file (`prompts/NN-{cover|page}-[slug].md`) FIRST before regenerating. This ensures changes are documented and reproducible.
|
||||
|
||||
Text correction policy:
|
||||
|
||||
- If dialogue, sound effects, panel labels, or any other rendered text is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
|
||||
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
|
||||
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
|
||||
|
||||
## Notes
|
||||
|
||||
- Image generation: 10-30 seconds per page
|
||||
@@ -311,6 +346,7 @@ EXTEND.md lives at `.baoyu-skills/baoyu-comic/EXTEND.md` (project) or `~/.baoyu-
|
||||
- **Common one-line edits**:
|
||||
- `preferred_image_backend: auto` — default; runtime-native tool wins, falls back to the only installed backend, asks only if multiple non-native are present.
|
||||
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
|
||||
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
|
||||
- `preferred_image_backend: baoyu-image-gen` — pin to the baoyu-image-gen skill.
|
||||
- `preferred_image_backend: ask` — confirm backend every run.
|
||||
- `generation_batch_size: 4` — default number of page images to render concurrently when the backend/runtime supports batch or parallel generation.
|
||||
- `watermark.enabled: true`, `preferred_art`, `preferred_tone`, `preferred_layout`, `language` — shift the auto-selection defaults and cosmetic choices.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# `codex-imagegen` Wrapper Invocation
|
||||
|
||||
Load this reference only when the [Image Generation Tools](../SKILL.md#image-generation-tools) rule has resolved to `codex-imagegen` — i.e., the current runtime exposes no native `imagegen` skill but `codex` CLI is on `PATH` with an active `codex login`.
|
||||
|
||||
## Preferred path: route through `baoyu-image-gen`
|
||||
|
||||
If the `baoyu-image-gen` skill is available in this runtime, **always** invoke through it rather than calling the wrapper directly. It handles retry/cache/batch/EXTEND.md preferences uniformly with every other provider.
|
||||
|
||||
```bash
|
||||
${BUN_X} <baoyu-image-gen-base>/scripts/main.ts \
|
||||
--provider codex-cli \
|
||||
--image <ABSOLUTE_output> \
|
||||
--promptfiles <ABSOLUTE_prompts/NN-{cover|page}-[slug].md> \
|
||||
--ar <ratio> \
|
||||
[--ref <ABSOLUTE_file>]...
|
||||
```
|
||||
|
||||
Resolve `<baoyu-image-gen-base>` the same way you resolve any sibling skill — through your runtime's skill registry (`Skill` tool, plugin marketplace, or `$HOME/.baoyu-skills/baoyu-image-gen/`).
|
||||
|
||||
## Fallback: spawn the wrapper directly
|
||||
|
||||
Only when `baoyu-image-gen` is NOT installed in the current runtime. Discover the wrapper's location at runtime — do NOT hard-code `../../packages/...` from this skill:
|
||||
|
||||
1. **Honor explicit override**: if `$BAOYU_CODEX_IMAGEGEN_BIN` is set and points to a real file, use that path. It may be `.ts` (spawn `bun <path>`) or `.sh`/binary (spawn directly).
|
||||
2. **Search the plugin root**: walk up from this skill's directory looking for `packages/baoyu-codex-imagegen/src/main.ts`. If found, that is the wrapper. Spawn it with `bun`.
|
||||
3. **Last resort**: tell the user that `codex-imagegen` is not available in this runtime and ask whether to install the `baoyu-skills` plugin (or set `BAOYU_CODEX_IMAGEGEN_BIN`) or pick another backend.
|
||||
|
||||
Once located, the invocation shape is:
|
||||
|
||||
```bash
|
||||
bun <WRAPPER>/main.ts \
|
||||
--image <ABSOLUTE_output> \
|
||||
--prompt-file <ABSOLUTE_prompts/NN-{cover|page}-[slug].md> \
|
||||
--aspect <ratio> \
|
||||
[--ref <ABSOLUTE_file>]... \
|
||||
[--timeout <ms>] \
|
||||
[--cache-dir ~/.cache/baoyu-codex-imagegen] \
|
||||
[--log-file <ABSOLUTE_jsonl_log_path>]
|
||||
```
|
||||
|
||||
If `bun` is missing, `npx -y bun <WRAPPER>/main.ts ...` works as a fallback.
|
||||
|
||||
## Parameter notes
|
||||
|
||||
- **All filesystem inputs** are auto-resolved against `process.cwd()` when relative, but agents should pass absolute paths to be robust against cwd drift.
|
||||
- **`--timeout`** defaults to `300000` (5 min) per `codex exec` attempt. Raise (e.g. `--timeout 600000` for 10 min) on slow networks or large prompts.
|
||||
- **`--cache-dir`** is off by default. Enable for repeatable runs to skip redundant generations of the same prompt+aspect+refs.
|
||||
- **Authentication**: the wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` is read or sent.
|
||||
|
||||
## Stdout contract
|
||||
|
||||
Single JSON line:
|
||||
|
||||
- Success: `{"status":"ok","path":"...","bytes":N,"elapsed_seconds":N,"thread_id":"...","attempts":N,"cached":bool,...}`
|
||||
- Failure: `{"status":"error","path":"...","bytes":0,"error":"...","error_kind":"..."}`
|
||||
|
||||
`error_kind` values: `codex_not_installed`, `invalid_args`, `prompt_file_missing`, `spawn_failed`, `timeout`, `no_image_gen_tool_use`, `output_missing`, `invalid_png`, `agent_refused`, `lock_busy`.
|
||||
|
||||
On retryable errors (timeout, spawn_failed, no_image_gen_tool_use, output_missing, invalid_png, agent_refused), ask the user whether to retry or fall back to another backend.
|
||||
|
||||
## Batch semantics
|
||||
|
||||
- Codex `image_gen` returns **one image per call** (`n=1` only). Multi-image jobs must dispatch one wrapper call per image.
|
||||
- The wrapper does NOT accept a `--sessionId` flag. Chain/scene consistency must come from `--ref` reference images.
|
||||
- `--size` and `--quality` are silently ignored — Codex picks pixel dimensions from `--aspect`.
|
||||
@@ -143,12 +143,15 @@ preferred_layout: null
|
||||
preferred_aspect: null
|
||||
language: [selected or null]
|
||||
preferred_image_backend: auto
|
||||
generation_batch_size: 4
|
||||
character_presets: []
|
||||
---
|
||||
```
|
||||
|
||||
`preferred_image_backend: auto` is the baked-in default — first-time setup does not ask about it. The `## Image Generation Tools` rule in SKILL.md then picks the runtime-native tool (Codex `imagegen`, Hermes `image_generate`, etc.) when available, and falls back to installed backends.
|
||||
|
||||
`generation_batch_size: 4` is the baked-in default for page batch rendering. The current user request may override it for one run.
|
||||
|
||||
## Modifying Preferences Later
|
||||
|
||||
See the `## Changing Preferences` section in `SKILL.md` for the canonical list of common edits (pin backend, change defaults, retrigger setup). Full schema: `config/preferences-schema.md`.
|
||||
|
||||
@@ -25,6 +25,8 @@ language: null # zh|en|ja|ko|auto
|
||||
|
||||
preferred_image_backend: auto # auto|ask|<backend-id>
|
||||
|
||||
generation_batch_size: 4 # 1-8, used when backend/runtime supports batch or parallel page generation
|
||||
|
||||
character_presets:
|
||||
- name: my-characters
|
||||
roles:
|
||||
@@ -48,7 +50,8 @@ character_presets:
|
||||
| `preferred_layout` | string | null | Layout preference or null |
|
||||
| `preferred_aspect` | string | null | Aspect ratio (3:4, 4:3, 16:9) |
|
||||
| `language` | string | null | Output language (null = auto-detect) |
|
||||
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
||||
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-image-gen`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
||||
| `generation_batch_size` | int | 4 | Number of page images to dispatch per batch when the backend has native batch support or the runtime can issue parallel generation calls. Clamp invalid values to 1-8. Current user request overrides this value. |
|
||||
| `character_presets` | array | [] | Preset character roles for styles like ohmsha |
|
||||
|
||||
## Art Style Options
|
||||
@@ -127,6 +130,8 @@ language: zh
|
||||
|
||||
preferred_image_backend: codex-imagegen
|
||||
|
||||
generation_batch_size: 4
|
||||
|
||||
character_presets:
|
||||
- name: tech-tutorial
|
||||
roles:
|
||||
|
||||
@@ -448,7 +448,7 @@ Character sheet is recommended for multi-page comics with recurring characters,
|
||||
| Exists | No `--ref` support | **B**: Embed character descriptions in every prompt |
|
||||
| Skipped | — | **C**: Prompt file contains all descriptions inline |
|
||||
|
||||
**Strategy A: Using `--ref` parameter** (e.g., baoyu-imagine)
|
||||
**Strategy A: Using `--ref` parameter** (e.g., baoyu-image-gen)
|
||||
|
||||
- Read the chosen image generation skill's `SKILL.md`
|
||||
- Invoke that installed skill via its documented interface, not by calling its scripts directly
|
||||
@@ -488,12 +488,19 @@ When character sheet was skipped or `--ref` failed:
|
||||
- No `--ref` parameter needed
|
||||
- Rely on detailed text descriptions for character consistency
|
||||
|
||||
**For each page (cover + pages)**:
|
||||
1. Read prompt from `prompts/NN-{cover|page}-[slug].md`
|
||||
2. **Backup rule**: If image file exists, rename to `NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.png`
|
||||
3. Generate image using Strategy A, B, or C
|
||||
4. Save to `NN-{cover|page}-[slug].png`
|
||||
5. Report progress after each generation: "Generated X/N: [page title]"
|
||||
**Page batch generation (cover + pages)**:
|
||||
1. Build a page task list from selected saved prompts:
|
||||
- `prompt_file`: `prompts/NN-{cover|page}-[slug].md`
|
||||
- `output_file`: `NN-{cover|page}-[slug].png`
|
||||
- `aspect_ratio`: from storyboard (default `3:4`; preset may override)
|
||||
- `refs`: character sheet and verified direct user refs when Strategy A is active
|
||||
2. **Backup rule**: Before dispatching a task, if its image file exists, rename it to `NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.png`.
|
||||
3. Dispatch tasks in batches:
|
||||
- Native batch backend: send all eligible page tasks, or chunks of `generation_batch_size` if the backend has a practical limit.
|
||||
- Runtime parallel calls: issue up to `generation_batch_size` image calls concurrently, then continue with the next chunk.
|
||||
- Sequential fallback: process one page at a time.
|
||||
4. After each completed task, report: "Generated X/N: [page title]".
|
||||
5. On failure, retry the failed task once from the same saved prompt file. Keep successful outputs and continue.
|
||||
|
||||
**Session Management**:
|
||||
If image generation skill supports `--sessionId`:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-cover-image
|
||||
description: Generates article cover images with 5 dimensions (type, palette, rendering, text, mood) combining 11 color palettes and 7 rendering styles. Supports cinematic (2.35:1), widescreen (16:9), and square (1:1) aspects. Use when user asks to "generate cover image", "create article cover", or "make cover".
|
||||
version: 1.56.2
|
||||
version: 1.117.5
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-cover-image
|
||||
@@ -28,16 +28,23 @@ When this 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 `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):
|
||||
- If the current runtime exposes a native image tool (e.g., Codex `imagegen`, Hermes `image_generate`), use it. Runtime-native tools are preferred by default — agents that know their own tool inventory should surface the native one here.
|
||||
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-imagine`), use it.
|
||||
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-image-gen`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime exposes no native `imagegen` skill but the `codex` CLI is on `PATH` with an active `codex login`, route through `baoyu-image-gen --provider codex-cli` (preferred), or — if baoyu-image-gen is unavailable — invoke the bundled wrapper directly. Details, parameters, and the runtime-discovery procedure live in [references/codex-imagegen.md](references/codex-imagegen.md) — load that file only when this branch is selected.
|
||||
- **Cursor (`GenerateImage`)** — if the runtime exposes a native `GenerateImage` tool, you are running inside Cursor and it outranks any non-native skill the same way Codex `imagegen` does. Two hard caveats: (a) it has no aspect-ratio parameter — state the target aspect ratio / dimensions explicitly in the prompt text passed as `description`; (b) it does not accept an output directory — it saves to a tool-managed location, so after generation copy/move the file to the skill's expected output path (e.g., `outputs/.../NN-xxx.png`). Reference images go in `reference_image_paths`.
|
||||
- **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way.
|
||||
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-image-gen`), use it.
|
||||
- Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions.
|
||||
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.
|
||||
|
||||
**⛔ Never repair rendered text by painting over a generated bitmap.** Do not use ImageMagick, Pillow, Canvas, SVG, HTML/CSS, OCR scripts, or any other programmatic overlay to cover, rewrite, erase, stroke, or replace title/subtitle text inside an already generated cover image. If text is wrong or unclear, regenerate from a corrected prompt, switch to a lower-text or no-title variant, or ask the user which imperfect candidate to keep.
|
||||
|
||||
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
||||
|
||||
**Prompt file requirement (hard)**: write each image's full, final prompt to a standalone file under `prompts/` (naming: `NN-{type}-[slug].md`) BEFORE invoking any backend. The backend receives the prompt file (or its content); the file is the reproducibility record and lets you switch backends without regenerating prompts.
|
||||
|
||||
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
|
||||
Concrete tool names (`imagegen`, `GenerateImage`, `image_generate`, `baoyu-image-gen`) above are examples — substitute the local equivalents under the same rule.
|
||||
|
||||
## Confirmation Policy
|
||||
|
||||
@@ -209,7 +216,9 @@ Save to `prompts/cover.md`. Template: [references/workflow/prompt-template.md](r
|
||||
4. **Process references** from prompt frontmatter:
|
||||
- `direct` usage → pass via `--ref` (use ref-capable backend)
|
||||
- `style`/`palette` → extract traits, append to prompt
|
||||
5. **Generate**: Call the chosen backend with the prompt file, output path, aspect ratio
|
||||
5. **Generate**: Call the chosen backend with the prompt file, output path, aspect ratio.
|
||||
- **`codex-imagegen`**: see [references/codex-imagegen.md](references/codex-imagegen.md) for the invocation contract (preferred `baoyu-image-gen --provider codex-cli` path, runtime wrapper discovery, parameter notes, stdout schema, batch semantics).
|
||||
- **Codex `imagegen` (native)** or other runtime-native tools / `baoyu-image-gen` skill: per the rule in `## Image Generation Tools` above.
|
||||
6. On failure: auto-retry once
|
||||
|
||||
### Step 5: Completion Report
|
||||
@@ -238,6 +247,12 @@ Files:
|
||||
| **Regenerate** | Backup → Update prompt file FIRST → Regenerate |
|
||||
| **Change dimension** | Backup → Confirm new value → Update prompt → Regenerate |
|
||||
|
||||
Text correction policy:
|
||||
|
||||
- If the title/subtitle is misspelled, garbled, hard to read, or visually weak, do not patch the bitmap with code.
|
||||
- For text-correction regenerations, write a new prompt file and a new output path so the flawed candidate is preserved for comparison.
|
||||
- Post-processing is limited to crop, resize, compression, or format conversion that does not alter text or the main composition.
|
||||
|
||||
## Composition Principles
|
||||
|
||||
- **Whitespace**: 40-60% breathing room
|
||||
@@ -254,7 +269,7 @@ EXTEND.md lives at the path noted in **Step 0**. Three ways to change it:
|
||||
- **Common one-line edits**:
|
||||
- `preferred_image_backend: auto` — default; runtime-native tool wins, falls back to the only installed backend, asks only if multiple non-native are present.
|
||||
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
|
||||
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
|
||||
- `preferred_image_backend: baoyu-image-gen` — pin to the baoyu-image-gen skill.
|
||||
- `preferred_image_backend: ask` — confirm backend every run.
|
||||
- `watermark.enabled: true`, `preferred_type`, `preferred_palette`, `preferred_rendering`, `default_aspect`, `quick_mode: true`, `language` — shift the auto-selection defaults and confirmation flow.
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# `codex-imagegen` Wrapper Invocation
|
||||
|
||||
Load this reference only when the [Image Generation Tools](../SKILL.md#image-generation-tools) rule has resolved to `codex-imagegen` — i.e., the current runtime exposes no native `imagegen` skill but `codex` CLI is on `PATH` with an active `codex login`.
|
||||
|
||||
## Preferred path: route through `baoyu-image-gen`
|
||||
|
||||
If the `baoyu-image-gen` skill is available in this runtime, **always** invoke through it rather than calling the wrapper directly. It handles retry/cache/batch/EXTEND.md preferences uniformly with every other provider.
|
||||
|
||||
```bash
|
||||
${BUN_X} <baoyu-image-gen-base>/scripts/main.ts \
|
||||
--provider codex-cli \
|
||||
--image <ABSOLUTE_output> \
|
||||
--promptfiles <ABSOLUTE_prompts/01-cover-[slug].md> \
|
||||
--ar <ratio> \
|
||||
[--ref <ABSOLUTE_file>]...
|
||||
```
|
||||
|
||||
Resolve `<baoyu-image-gen-base>` the same way you resolve any sibling skill — through your runtime's skill registry (`Skill` tool, plugin marketplace, or `$HOME/.baoyu-skills/baoyu-image-gen/`).
|
||||
|
||||
## Fallback: spawn the wrapper directly
|
||||
|
||||
Only when `baoyu-image-gen` is NOT installed in the current runtime. Discover the wrapper's location at runtime — do NOT hard-code `../../packages/...` from this skill:
|
||||
|
||||
1. **Honor explicit override**: if `$BAOYU_CODEX_IMAGEGEN_BIN` is set and points to a real file, use that path. It may be `.ts` (spawn `bun <path>`) or `.sh`/binary (spawn directly).
|
||||
2. **Search the plugin root**: walk up from this skill's directory looking for `packages/baoyu-codex-imagegen/src/main.ts`. If found, that is the wrapper. Spawn it with `bun`.
|
||||
3. **Last resort**: tell the user that `codex-imagegen` is not available in this runtime and ask whether to install the `baoyu-skills` plugin (or set `BAOYU_CODEX_IMAGEGEN_BIN`) or pick another backend.
|
||||
|
||||
Once located, the invocation shape is:
|
||||
|
||||
```bash
|
||||
bun <WRAPPER>/main.ts \
|
||||
--image <ABSOLUTE_output> \
|
||||
--prompt-file <ABSOLUTE_prompts/01-cover-[slug].md> \
|
||||
--aspect <ratio> \
|
||||
[--ref <ABSOLUTE_file>]... \
|
||||
[--timeout <ms>] \
|
||||
[--cache-dir ~/.cache/baoyu-codex-imagegen] \
|
||||
[--log-file <ABSOLUTE_jsonl_log_path>]
|
||||
```
|
||||
|
||||
If `bun` is missing, `npx -y bun <WRAPPER>/main.ts ...` works as a fallback.
|
||||
|
||||
## Parameter notes
|
||||
|
||||
- **All filesystem inputs** are auto-resolved against `process.cwd()` when relative, but agents should pass absolute paths to be robust against cwd drift.
|
||||
- **`--timeout`** defaults to `300000` (5 min) per `codex exec` attempt. Raise (e.g. `--timeout 600000` for 10 min) on slow networks or large prompts.
|
||||
- **`--cache-dir`** is off by default. Enable for repeatable runs to skip redundant generations of the same prompt+aspect+refs.
|
||||
- **Authentication**: the wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` is read or sent.
|
||||
|
||||
## Stdout contract
|
||||
|
||||
Single JSON line:
|
||||
|
||||
- Success: `{"status":"ok","path":"...","bytes":N,"elapsed_seconds":N,"thread_id":"...","attempts":N,"cached":bool,...}`
|
||||
- Failure: `{"status":"error","path":"...","bytes":0,"error":"...","error_kind":"..."}`
|
||||
|
||||
`error_kind` values: `codex_not_installed`, `invalid_args`, `prompt_file_missing`, `spawn_failed`, `timeout`, `no_image_gen_tool_use`, `output_missing`, `invalid_png`, `agent_refused`, `lock_busy`.
|
||||
|
||||
On retryable errors (timeout, spawn_failed, no_image_gen_tool_use, output_missing, invalid_png, agent_refused), ask the user whether to retry or fall back to another backend.
|
||||
|
||||
## Batch semantics
|
||||
|
||||
- Codex `image_gen` returns **one image per call** (`n=1` only). Multi-image jobs must dispatch one wrapper call per image.
|
||||
- The wrapper does NOT accept a `--sessionId` flag. Chain/scene consistency must come from `--ref` reference images.
|
||||
- `--size` and `--quality` are silently ignored — Codex picks pixel dimensions from `--aspect`.
|
||||
@@ -62,7 +62,7 @@ custom_palettes:
|
||||
| `default_aspect` | string | "2.35:1" | Default aspect ratio |
|
||||
| `quick_mode` | bool | false | Skip confirmation step |
|
||||
| `language` | string | null | Output language (null = auto-detect) |
|
||||
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
||||
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-image-gen`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
||||
| `custom_palettes` | array | [] | User-defined palettes |
|
||||
|
||||
## Type Options
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-danger-gemini-web
|
||||
description: Generates images and text via reverse-engineered Gemini Web API. Supports text generation, image generation from prompts, reference images for vision input, and multi-turn conversations. Use when other skills need image generation backend, or when user requests "generate image with Gemini", "Gemini text generation", or needs vision-capable AI generation.
|
||||
version: 1.56.1
|
||||
version: 1.56.2
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-danger-gemini-web
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
"": {
|
||||
"name": "baoyu-danger-gemini-web-scripts",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "^0.1.0",
|
||||
"baoyu-chrome-cdp": "^0.1.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@0.1.0", "", {}, "sha512-Hk1yolVrlIlzMCKXjc21yAJP0dttun+SaPRcW7HL9/mmwZ9kedQ6fFgxf8M91I+/Fe348sPbdYVhSAmYHzVunQ=="],
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@0.1.1", "", {}, "sha512-OR3PQ7NzJDykCXl20TnkZuwvNQ0hsVC2czje93P72xQaA3vKOyPN/Q1CwEgKuYzP7Rka4Fdh4HvURj6AoNR7Tg=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { collect_generated_image_urls_from_response_parts } from "./client.ts";
|
||||
|
||||
test("response part fallback finds generated images when legacy generated markers are absent", () => {
|
||||
const generatedUrl = "https://lh3.googleusercontent.com/gg-dl/example-generated-image";
|
||||
const initialCandidate = ["rcid-1", ["image generated successfully"]];
|
||||
const imageCandidate = [
|
||||
"rcid-1",
|
||||
["image generated successfully"],
|
||||
{ nestedPayload: [{ media: generatedUrl }] },
|
||||
];
|
||||
const responseJson = [
|
||||
["wrb.fr", null, JSON.stringify([null, [], null, null, [initialCandidate]])],
|
||||
["wrb.fr", null, JSON.stringify([null, [], null, null, [imageCandidate]])],
|
||||
];
|
||||
|
||||
assert.equal(initialCandidate[12], undefined);
|
||||
assert.equal(
|
||||
/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/.test(String(initialCandidate[1]?.[0])),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(collect_generated_image_urls_from_response_parts(responseJson, 0, 0), [generatedUrl]);
|
||||
});
|
||||
@@ -37,6 +37,8 @@ type InitOptions = {
|
||||
|
||||
type RequestKwargs = RequestInit & { timeout_ms?: number };
|
||||
|
||||
const GENERATED_IMAGE_URL_PREFIX = 'https://lh3.googleusercontent.com/gg-dl/';
|
||||
|
||||
function normalize_headers(h?: HeadersInit): Record<string, string> {
|
||||
if (!h) return {};
|
||||
if (Array.isArray(h)) return Object.fromEntries(h.map(([k, v]) => [k, v]));
|
||||
@@ -78,6 +80,59 @@ function collect_strings(root: unknown, accept: (s: string) => boolean, limit: n
|
||||
return out;
|
||||
}
|
||||
|
||||
function collect_generated_image_urls(root: unknown, limit: number = 4): string[] {
|
||||
return collect_strings(root, (s) => s.startsWith(GENERATED_IMAGE_URL_PREFIX), limit);
|
||||
}
|
||||
|
||||
function parse_response_part_body(part: unknown): unknown[] | null {
|
||||
if (!Array.isArray(part)) return null;
|
||||
const part_body = get_nested_value<string | null>(part, [2], null);
|
||||
if (!part_body) return null;
|
||||
try {
|
||||
const part_json = JSON.parse(part_body) as unknown;
|
||||
return Array.isArray(part_json) ? part_json : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function find_generated_image_part(
|
||||
response_json: unknown[],
|
||||
body_index: number,
|
||||
candidate_index: number,
|
||||
limit: number = 4,
|
||||
): { body: unknown[]; urls: string[] } | null {
|
||||
for (let part_index = body_index; part_index < response_json.length; part_index++) {
|
||||
const part_json = parse_response_part_body(response_json[part_index]);
|
||||
if (!part_json) continue;
|
||||
const cand = get_nested_value<unknown>(part_json, [4, candidate_index], null);
|
||||
if (!cand) continue;
|
||||
const urls = collect_generated_image_urls(cand, limit);
|
||||
if (urls.length > 0) return { body: part_json, urls };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function collect_generated_image_urls_from_response_parts(
|
||||
response_json: unknown[],
|
||||
body_index: number,
|
||||
candidate_index: number,
|
||||
limit: number = 4,
|
||||
): string[] {
|
||||
return find_generated_image_part(response_json, body_index, candidate_index, limit)?.urls ?? [];
|
||||
}
|
||||
|
||||
function push_generated_images(
|
||||
generated_images: GeneratedImage[],
|
||||
urls: string[],
|
||||
proxy: string | null,
|
||||
cookies: Record<string, string>,
|
||||
): void {
|
||||
for (const url of urls) {
|
||||
generated_images.push(new GeneratedImage(url, '[Generated Image]', '', proxy, cookies));
|
||||
}
|
||||
}
|
||||
|
||||
export class GeminiClient extends GemMixin {
|
||||
public cookies: Record<string, string> = {};
|
||||
public proxy: string | null = null;
|
||||
@@ -401,35 +456,19 @@ export class GeminiClient extends GemMixin {
|
||||
const generated_images: GeneratedImage[] = [];
|
||||
const wants_generated =
|
||||
get_nested_value(candidate, [12, 7, 0], null) != null ||
|
||||
/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/.test(text);
|
||||
/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/.test(text) ||
|
||||
// Gemini 3 no longer emits the legacy marker in the candidate text; the generated
|
||||
// image arrives in a later response part. Detect it from the raw response instead.
|
||||
/\/gg-dl\//.test(txt) ||
|
||||
/image_generation_content\/\d+/.test(txt);
|
||||
|
||||
if (wants_generated) {
|
||||
let img_body: unknown[] | null = null;
|
||||
for (let part_index = body_index; part_index < (response_json as unknown[]).length; part_index++) {
|
||||
const part = (response_json as unknown[])[part_index];
|
||||
if (!Array.isArray(part)) continue;
|
||||
const part_body = get_nested_value<string | null>(part, [2], null);
|
||||
if (!part_body) continue;
|
||||
try {
|
||||
const part_json = JSON.parse(part_body) as unknown[];
|
||||
const cand = get_nested_value<unknown>(part_json, [4, candidate_index], null);
|
||||
if (!cand) continue;
|
||||
const image_part = find_generated_image_part(response_json as unknown[], body_index, candidate_index, 1);
|
||||
const img_body = image_part?.body ?? null;
|
||||
|
||||
const urls = collect_strings(cand, (s) => s.startsWith('https://lh3.googleusercontent.com/gg-dl/'), 1);
|
||||
if (urls.length > 0) {
|
||||
img_body = part_json;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!img_body) {
|
||||
throw new ImageGenerationError(
|
||||
'Failed to parse generated images. Please update gemini_webapi to the latest version. If the error persists and is caused by the package, please report it on GitHub.',
|
||||
);
|
||||
}
|
||||
|
||||
const img_candidate = get_nested_value<unknown[]>(img_body, [4, candidate_index], []);
|
||||
// Not every candidate carries a generated image (e.g. multi-candidate responses).
|
||||
// If this candidate has no image part, skip extraction instead of failing the whole call.
|
||||
const img_candidate = img_body ? get_nested_value<unknown[]>(img_body, [4, candidate_index], []) : [];
|
||||
const finished = get_nested_value<string | null>(img_candidate, [1, 0], null);
|
||||
if (finished) {
|
||||
text = finished.replace(/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/g, '').trimEnd();
|
||||
@@ -452,13 +491,22 @@ export class GeminiClient extends GemMixin {
|
||||
}
|
||||
|
||||
if (generated_images.length === 0) {
|
||||
const urls = collect_strings(img_candidate, (s) => s.startsWith('https://lh3.googleusercontent.com/gg-dl/'), 4);
|
||||
for (const url of urls) {
|
||||
generated_images.push(new GeneratedImage(url, '[Generated Image]', '', this.proxy, this.cookies));
|
||||
}
|
||||
push_generated_images(generated_images, collect_generated_image_urls(img_candidate), this.proxy, this.cookies);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: unconditionally scan all response parts for generated image URLs.
|
||||
// The `wants_generated` detection above relies on `candidate[12][7][0]` and an old
|
||||
// `googleusercontent.com/image_generation_content/` URL pattern, both of which no
|
||||
// longer appear in the current Gemini Web API response format.
|
||||
// When Gemini does generate images, their URLs now start with
|
||||
// `https://lh3.googleusercontent.com/gg-dl/` and are present somewhere in the
|
||||
// response parts — this fallback finds them so images are not silently dropped.
|
||||
if (generated_images.length === 0) {
|
||||
const urls = collect_generated_image_urls_from_response_parts(response_json as unknown[], body_index, candidate_index);
|
||||
push_generated_images(generated_images, urls, this.proxy, this.cookies);
|
||||
}
|
||||
|
||||
out.push(new Candidate({ rcid, text, thoughts, web_images, generated_images }));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "^0.1.0"
|
||||
"baoyu-chrome-cdp": "^0.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-danger-x-to-markdown
|
||||
description: Converts X (Twitter) tweets and articles to markdown with YAML front matter. Uses reverse-engineered API requiring user consent. Use when user mentions "X to markdown", "tweet to markdown", "save tweet", or provides x.com/twitter.com URLs for conversion.
|
||||
version: 1.56.1
|
||||
version: 1.117.3
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-danger-x-to-markdown
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
"": {
|
||||
"name": "baoyu-danger-x-to-markdown-scripts",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "^0.1.0",
|
||||
"baoyu-chrome-cdp": "^0.1.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@0.1.0", "", {}, "sha512-Hk1yolVrlIlzMCKXjc21yAJP0dttun+SaPRcW7HL9/mmwZ9kedQ6fFgxf8M91I+/Fe348sPbdYVhSAmYHzVunQ=="],
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@0.1.1", "", {}, "sha512-OR3PQ7NzJDykCXl20TnkZuwvNQ0hsVC2czje93P72xQaA3vKOyPN/Q1CwEgKuYzP7Rka4Fdh4HvURj6AoNR7Tg=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "^0.1.0"
|
||||
"baoyu-chrome-cdp": "^0.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: baoyu-diagram
|
||||
description: Create professional, dark-themed SVG diagrams of any type — architecture diagrams, flowcharts, sequence diagrams, structural diagrams, mind maps, timelines, illustrative/conceptual diagrams, and more. Use this skill whenever the user asks for any kind of technical or conceptual diagram, visualization of a system, process flow, data flow, component relationship, network topology, decision tree, org chart, state machine, or any visual representation of structure/logic/process. Also trigger when the user says "画个图" "画一个架构图" "diagram" "flowchart" "sequence diagram" "draw me a ..." or uploads content and asks to visualize it. Output is always a standalone .svg file.
|
||||
version: 1.117.3
|
||||
---
|
||||
|
||||
# Diagram Generator
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: baoyu-electron-extract
|
||||
description: Extracts resources and JavaScript from any installed Electron app (`.asar` bundle), restoring original sources from `.js.map` files when available or formatting minified code with Prettier otherwise. Use when user wants to "extract Electron app", "decompile Electron", "get the source code of <app>", "inspect app.asar", "看 Electron 应用源码", "提取 .asar", or asks how a desktop Electron app is built. Skips `node_modules` and supports both macOS and Windows.
|
||||
version: 1.119.0
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-electron-extract
|
||||
requires:
|
||||
anyBins:
|
||||
- bun
|
||||
- npx
|
||||
---
|
||||
|
||||
# Electron App Extract
|
||||
|
||||
Extracts resources and code from an installed Electron app's `app.asar`. When a `.js.map` is present, restores the original source files from the embedded `sourcesContent`; otherwise formats the minified code with Prettier. Source-map paths are resolved relative to the `.js.map` file first, so bundled paths like `../../src/main.ts` restore to readable paths such as `restored/src/main.ts` instead of hashed placeholders. Always skips `node_modules`. Works on macOS and Windows.
|
||||
|
||||
## 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.
|
||||
|
||||
## Script Directory
|
||||
|
||||
Scripts in `scripts/` subdirectory. `{baseDir}` = this SKILL.md's directory path. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun. Replace `{baseDir}` and `${BUN_X}` with actual values.
|
||||
|
||||
| Script | Purpose |
|
||||
| ----------------- | ------------------------------------------------------------------------------ |
|
||||
| `scripts/main.ts` | App discovery + asar extraction + source-map restoration + Prettier formatting |
|
||||
|
||||
## When to use
|
||||
|
||||
Use this skill whenever the user wants to look inside an installed Electron application or inspect its bundled code. Trigger phrases include:
|
||||
|
||||
- "extract Electron app", "decompile this Electron app", "unpack app.asar"
|
||||
- "show me the source of <app>", "look inside <app>", "how is <app> built"
|
||||
- "get the source code of Codex / Cursor / Discord / Slack / VS Code / Notion / Obsidian / ChatGPT desktop"
|
||||
- "提取 Electron 应用", "看 <app> 的源码", "反编译 Electron", "解包 app.asar", "还原 source map"
|
||||
|
||||
Both **app name** (e.g., `Codex`) and **absolute path** (e.g., `/Applications/Codex.app`, a `.asar` file, or a Windows install dir) are accepted. The script handles discovery for both platforms.
|
||||
|
||||
## Workflow
|
||||
|
||||
**1. Determine the input.** Ask the user for the app name or path if they haven't given one. If they want a custom output directory, ask for that too.
|
||||
|
||||
**2. Run the script.**
|
||||
|
||||
```bash
|
||||
${BUN_X} {baseDir}/scripts/main.ts "<app>" [--output <dir>] [--asar <path>] [--force]
|
||||
```
|
||||
|
||||
Start with `--dry-run` first if you're unsure whether discovery will find the right bundle — it prints the resolved paths and exits without touching the filesystem.
|
||||
|
||||
**3. Handle the result.**
|
||||
|
||||
- **Success** → report the output paths and the counts (extracted / restored / formatted).
|
||||
- **Multiple matches** → the script lists candidates and exits non-zero. Show the user the candidates, ask which one to use (via `AskUserQuestion` or the runtime equivalent), then re-run with the chosen absolute path.
|
||||
- **Existing non-empty output dir** → the script refuses without `--force`. Ask the user whether to overwrite (`--force`) or pick a new `--output` path.
|
||||
- **Unsupported platform / no match** → suggest passing `--asar /full/path/to/app.asar` if the user knows where the bundle lives.
|
||||
|
||||
**4. Point the user at the result.** The default output dir is `~/Downloads/<AppName>-electron-extract/`. The most interesting subdirectory depends on what was found:
|
||||
|
||||
- `restored/` exists → the original source tree was reconstructed from `.js.map` files; this is what to read first.
|
||||
- Only `extracted/` exists (no maps) → the JS/CSS in `extracted/` was Prettier-formatted in place; read from there.
|
||||
|
||||
## Source-map path restoration
|
||||
|
||||
The script should preserve original source names and directory structure as much as the source map allows:
|
||||
|
||||
- Resolve each `sources[]` entry with `sourceRoot` when present, then relative to the `.js.map` file's directory inside `extracted/`.
|
||||
- Collapse normal bundler-relative paths into the restored project tree. For example, `.vite/main/index.js.map` + `../../src/main.ts` becomes `restored/src/main.ts`.
|
||||
- If a source path climbs above `extracted/`, keep the readable remaining path under `restored/` instead of hashing it. For example, `.vite/main/index.js.map` + `../../../shared/src/lib/foo.ts` becomes `restored/shared/src/lib/foo.ts`.
|
||||
- Strip URL/query decorations from source names, including common `webpack://`, `file://`, and `?loader` suffixes.
|
||||
- Use `restored/__unknown/<hash>.<ext>` only when the source name is empty or cannot be reduced to a safe file path.
|
||||
- Continue skipping `node_modules` and `webpack/runtime/*` entries; these are bundler/runtime noise, not app sources.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Extract by app name (default output: ~/Downloads/Codex-electron-extract/)
|
||||
${BUN_X} {baseDir}/scripts/main.ts Codex
|
||||
|
||||
# Extract by absolute path (works for .app bundles, install dirs, or .asar files)
|
||||
${BUN_X} {baseDir}/scripts/main.ts "/Applications/Visual Studio Code.app"
|
||||
${BUN_X} {baseDir}/scripts/main.ts "C:\Users\you\AppData\Local\Programs\codex"
|
||||
${BUN_X} {baseDir}/scripts/main.ts --asar /Applications/Codex.app/Contents/Resources/app.asar Codex
|
||||
|
||||
# Custom output
|
||||
${BUN_X} {baseDir}/scripts/main.ts Codex --output ~/work/codex-source
|
||||
|
||||
# Preview discovery without writing anything
|
||||
${BUN_X} {baseDir}/scripts/main.ts Codex --dry-run
|
||||
|
||||
# Overwrite an existing output dir
|
||||
${BUN_X} {baseDir}/scripts/main.ts Codex --force
|
||||
|
||||
# Machine-readable result (one JSON line on stdout)
|
||||
${BUN_X} {baseDir}/scripts/main.ts Codex --json
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Short | Description | Default |
|
||||
| ---------------- | ----- | --------------------------------------------------------------- | ---------------------------------------- |
|
||||
| `<app>` | | App name or absolute path. Required unless `--asar` is given. | — |
|
||||
| `--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 (suppresses normal output) | false |
|
||||
|
||||
## Output layout
|
||||
|
||||
```
|
||||
~/Downloads/<AppName>-electron-extract/
|
||||
├── extract-report.json # JSON summary: counts, warnings, resolved paths
|
||||
├── extracted/ # raw asar contents (JS/CSS Prettier-formatted when no map)
|
||||
│ └── ... # node_modules left untouched (skipped from format)
|
||||
├── extracted.unpacked/ # copied from <asar>.unpacked/ if present
|
||||
│ └── ... # native modules (.node), large assets
|
||||
└── restored/ # only present if at least one .js.map was usable
|
||||
└── <original/source/tree> # rebuilt from sourcesContent in each .js.map
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **node_modules** is always skipped — both for source-map restoration and Prettier formatting — because vendored dependencies are noise when inspecting an app.
|
||||
- **Source-map restoration** only works when the `.js.map` embeds `sourcesContent`. This is the common case for modern bundlers (webpack, esbuild, Vite, rollup). If a map references external `.ts`/`.js` files without embedding them, that map is skipped and the corresponding `.js` is Prettier-formatted instead. Skipped maps are listed in `extract-report.json` under `warnings`.
|
||||
- **Readable paths over hashes** — don't treat `../` segments in source-map paths as automatically unsafe. First resolve them from the map location and then sanitize the final output path so it still stays under `restored/`. Hash fallback is only for unusable source names.
|
||||
- **App discovery** searches `/Applications` + `~/Applications` on macOS, and `%LOCALAPPDATA%\Programs`, `%PROGRAMFILES%`, `%PROGRAMFILES(X86)%`, `%APPDATA%` on Windows. If discovery finds multiple matches, the script exits and lists them — re-run with an absolute path. On Linux or other platforms, pass `--asar /path/to/app.asar` explicitly.
|
||||
- **Safety** — the script refuses to write to `/`, the user home directly, or the current working directory, and refuses to populate an existing non-empty output dir without `--force`.
|
||||
- **No global installs** — `@electron/asar` and `prettier` are resolved on-the-fly via `npx -y`. First run will be slower while npx caches them.
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { normalizeSourcePath, restoreFromMap } from "./main.ts";
|
||||
|
||||
test("normalizes Vite source map paths into readable project paths", () => {
|
||||
const extractedRoot = path.join(os.tmpdir(), "app", "extracted");
|
||||
const mapPath = path.join(extractedRoot, ".vite", "main", "index.js.map");
|
||||
|
||||
assert.equal(
|
||||
normalizeSourcePath(
|
||||
"../../src/main/agent/claude-agent.ts",
|
||||
mapPath,
|
||||
extractedRoot,
|
||||
undefined
|
||||
),
|
||||
"src/main/agent/claude-agent.ts"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
normalizeSourcePath(
|
||||
"../../../shared/src/lib/prompt-classification.ts",
|
||||
mapPath,
|
||||
extractedRoot,
|
||||
undefined
|
||||
),
|
||||
"shared/src/lib/prompt-classification.ts"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
normalizeSourcePath(
|
||||
"./src/renderer/app.tsx",
|
||||
mapPath,
|
||||
extractedRoot,
|
||||
"webpack://moss/"
|
||||
),
|
||||
"moss/src/renderer/app.tsx"
|
||||
);
|
||||
});
|
||||
|
||||
test("restores source map sources without hashing usable relative paths", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "baoyu-electron-extract-"));
|
||||
try {
|
||||
const extractedRoot = path.join(root, "extracted");
|
||||
const restoredRoot = path.join(root, "restored");
|
||||
const mapDir = path.join(extractedRoot, ".vite", "main");
|
||||
const mapPath = path.join(mapDir, "index.js.map");
|
||||
await mkdir(mapDir, { recursive: true });
|
||||
await writeFile(
|
||||
mapPath,
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
file: "index.js",
|
||||
sources: [
|
||||
"../../src/main.ts",
|
||||
"../../src/common/ipcChannels.ts",
|
||||
"../../../shared/src/lib/prompt-classification.ts",
|
||||
"webpack://moss/./src/renderer/app.tsx",
|
||||
"../../../../node_modules/pkg/index.js",
|
||||
"..\\..\\node_modules\\windows-pkg\\index.js",
|
||||
"webpack/runtime/chunk loading",
|
||||
],
|
||||
sourcesContent: [
|
||||
"export const main = true;\n",
|
||||
"export const ipc = true;\n",
|
||||
"export const shared = true;\n",
|
||||
"export const renderer = true;\n",
|
||||
"module.exports = {};\n",
|
||||
"module.exports = {};\n",
|
||||
"runtime();\n",
|
||||
],
|
||||
}),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const warnings: string[] = [];
|
||||
const restored = restoreFromMap(
|
||||
mapPath,
|
||||
extractedRoot,
|
||||
restoredRoot,
|
||||
warnings
|
||||
);
|
||||
|
||||
assert.equal(restored, 4);
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.equal(
|
||||
await readFile(path.join(restoredRoot, "src", "main.ts"), "utf8"),
|
||||
"export const main = true;\n"
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(
|
||||
path.join(restoredRoot, "src", "common", "ipcChannels.ts"),
|
||||
"utf8"
|
||||
),
|
||||
"export const ipc = true;\n"
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(
|
||||
path.join(
|
||||
restoredRoot,
|
||||
"shared",
|
||||
"src",
|
||||
"lib",
|
||||
"prompt-classification.ts"
|
||||
),
|
||||
"utf8"
|
||||
),
|
||||
"export const shared = true;\n"
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(
|
||||
path.join(restoredRoot, "moss", "src", "renderer", "app.tsx"),
|
||||
"utf8"
|
||||
),
|
||||
"export const renderer = true;\n"
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,928 @@
|
||||
#!/usr/bin/env bun
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
extname,
|
||||
isAbsolute,
|
||||
join,
|
||||
posix as posixPath,
|
||||
relative,
|
||||
resolve,
|
||||
sep,
|
||||
} from "path";
|
||||
import { createHash } from "crypto";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import { homedir } from "os";
|
||||
import { pathToFileURL } from "url";
|
||||
|
||||
interface Options {
|
||||
app: string;
|
||||
output?: string;
|
||||
asar?: string;
|
||||
force: boolean;
|
||||
skipFormat: boolean;
|
||||
skipRestore: boolean;
|
||||
noUnpacked: boolean;
|
||||
dryRun: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface ResolvedApp {
|
||||
appName: string;
|
||||
asarPath: string;
|
||||
unpackedDir: string | null;
|
||||
installRoot: string | null;
|
||||
}
|
||||
|
||||
interface Counts {
|
||||
extractedFiles: number;
|
||||
restoredFiles: number;
|
||||
formattedFiles: number;
|
||||
skippedNodeModules: number;
|
||||
unpackedFiles: number;
|
||||
}
|
||||
|
||||
interface Report {
|
||||
appName: string;
|
||||
source: {
|
||||
input: string;
|
||||
asar: string;
|
||||
platform: string;
|
||||
installRoot: string | null;
|
||||
};
|
||||
output: {
|
||||
root: string;
|
||||
extracted: string;
|
||||
unpacked: string | null;
|
||||
restored: string | null;
|
||||
};
|
||||
counts: Counts;
|
||||
warnings: string[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return `Usage: main.ts <app> [options]
|
||||
|
||||
Extract resources & JavaScript from an installed Electron app.
|
||||
|
||||
Arguments:
|
||||
<app> App name (e.g. "Codex", "Visual Studio Code") or absolute
|
||||
path to a .app bundle, install directory, or .asar file.
|
||||
|
||||
Options:
|
||||
-o, --output PATH Output directory (default: ~/Downloads/<App>-electron-extract)
|
||||
--asar PATH Override the resolved .asar path
|
||||
-f, --force Allow writing into a non-empty existing output dir
|
||||
--skip-format Skip Prettier formatting (extract only)
|
||||
--skip-restore Skip source-map restoration
|
||||
--no-unpacked Don't copy app.asar.unpacked/ alongside
|
||||
--dry-run Print resolved paths and exit without writing
|
||||
--json Emit a single JSON line summary to stdout
|
||||
-h, --help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function fail(message: string, json: boolean): never {
|
||||
if (json) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ status: "error", error: message }) + "\n"
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Options {
|
||||
const opts: Options = {
|
||||
app: "",
|
||||
force: false,
|
||||
skipFormat: false,
|
||||
skipRestore: false,
|
||||
noUnpacked: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
};
|
||||
const positional: string[] = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "-h" || a === "--help") {
|
||||
process.stdout.write(usage());
|
||||
process.exit(0);
|
||||
} else if (a === "-o" || a === "--output") {
|
||||
opts.output = argv[++i];
|
||||
} else if (a === "--asar") {
|
||||
opts.asar = argv[++i];
|
||||
} else if (a === "-f" || a === "--force") {
|
||||
opts.force = true;
|
||||
} else if (a === "--skip-format") {
|
||||
opts.skipFormat = true;
|
||||
} else if (a === "--skip-restore") {
|
||||
opts.skipRestore = true;
|
||||
} else if (a === "--no-unpacked") {
|
||||
opts.noUnpacked = true;
|
||||
} else if (a === "--dry-run") {
|
||||
opts.dryRun = true;
|
||||
} else if (a === "--json") {
|
||||
opts.json = true;
|
||||
} else if (a.startsWith("-")) {
|
||||
throw new Error(`unknown option: ${a}\n\n${usage()}`);
|
||||
} else {
|
||||
positional.push(a);
|
||||
}
|
||||
}
|
||||
if (positional.length === 0 && !opts.asar) {
|
||||
throw new Error(
|
||||
`missing <app> argument (or pass --asar PATH)\n\n${usage()}`
|
||||
);
|
||||
}
|
||||
if (positional.length > 1) {
|
||||
throw new Error(
|
||||
`too many positional arguments: ${positional.join(", ")}\n\n${usage()}`
|
||||
);
|
||||
}
|
||||
opts.app = positional[0] ?? "";
|
||||
return opts;
|
||||
}
|
||||
|
||||
function sanitizeAppName(name: string): string {
|
||||
return name.replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
function appNameFromPath(p: string): string {
|
||||
const parts = resolve(p).split(sep);
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
if (parts[i].endsWith(".app")) return parts[i].slice(0, -4);
|
||||
}
|
||||
const n = basename(p);
|
||||
if (n === "app.asar") {
|
||||
if (
|
||||
parts.length >= 3 &&
|
||||
parts[parts.length - 2].toLowerCase() === "resources"
|
||||
) {
|
||||
return parts[parts.length - 3];
|
||||
}
|
||||
if (parts.length >= 2) return parts[parts.length - 2];
|
||||
}
|
||||
if (n.endsWith(".asar")) return n.slice(0, -5);
|
||||
if (n.endsWith(".app")) return n.slice(0, -4);
|
||||
return n;
|
||||
}
|
||||
|
||||
function existsAsFile(p: string): boolean {
|
||||
try {
|
||||
return statSync(p).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function existsAsDir(p: string): boolean {
|
||||
try {
|
||||
return statSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findAsarUnderDir(dir: string): string | null {
|
||||
const candidates = [
|
||||
join(dir, "resources", "app.asar"),
|
||||
join(dir, "Resources", "app.asar"),
|
||||
join(dir, "app.asar"),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (existsAsFile(c)) return c;
|
||||
}
|
||||
if (!existsAsDir(dir)) return null;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const nested = join(dir, entry.name, "resources", "app.asar");
|
||||
if (existsAsFile(nested)) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveByPath(input: string): ResolvedApp | null {
|
||||
if (!existsSync(input)) return null;
|
||||
const st = statSync(input);
|
||||
if (st.isFile() && input.endsWith(".asar")) {
|
||||
return {
|
||||
appName: appNameFromPath(input),
|
||||
asarPath: input,
|
||||
unpackedDir: existsAsDir(input + ".unpacked")
|
||||
? input + ".unpacked"
|
||||
: null,
|
||||
installRoot: dirname(input),
|
||||
};
|
||||
}
|
||||
if (st.isDirectory()) {
|
||||
if (input.endsWith(".app")) {
|
||||
const asar = join(input, "Contents", "Resources", "app.asar");
|
||||
if (existsAsFile(asar)) {
|
||||
return {
|
||||
appName: appNameFromPath(input),
|
||||
asarPath: asar,
|
||||
unpackedDir: existsAsDir(asar + ".unpacked")
|
||||
? asar + ".unpacked"
|
||||
: null,
|
||||
installRoot: input,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const asar = findAsarUnderDir(input);
|
||||
if (asar) {
|
||||
return {
|
||||
appName: appNameFromPath(input),
|
||||
asarPath: asar,
|
||||
unpackedDir: existsAsDir(asar + ".unpacked")
|
||||
? asar + ".unpacked"
|
||||
: null,
|
||||
installRoot: input,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function listAppCandidates(name: string): string[] {
|
||||
const lower = name.toLowerCase();
|
||||
const stripped = lower.endsWith(".app") ? lower.slice(0, -4) : lower;
|
||||
const matches: string[] = [];
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const roots = ["/Applications", join(homedir(), "Applications")];
|
||||
for (const root of roots) {
|
||||
if (!existsAsDir(root)) continue;
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const en = entry.name.toLowerCase();
|
||||
const enStripped = en.endsWith(".app") ? en.slice(0, -4) : en;
|
||||
if (
|
||||
en === lower ||
|
||||
en === `${stripped}.app` ||
|
||||
enStripped === stripped
|
||||
) {
|
||||
matches.push(join(root, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (process.platform === "win32") {
|
||||
const roots = [
|
||||
process.env.LOCALAPPDATA
|
||||
? join(process.env.LOCALAPPDATA, "Programs")
|
||||
: null,
|
||||
process.env.PROGRAMFILES || null,
|
||||
process.env["PROGRAMFILES(X86)"] || null,
|
||||
process.env.APPDATA || null,
|
||||
].filter((x): x is string => Boolean(x));
|
||||
for (const root of roots) {
|
||||
if (!existsAsDir(root)) continue;
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.toLowerCase() === stripped) {
|
||||
matches.push(join(root, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function resolveApp(input: string): ResolvedApp {
|
||||
if (isAbsolute(input)) {
|
||||
const r = resolveByPath(input);
|
||||
if (r) return r;
|
||||
throw new Error(`path exists but no app.asar found at or under: ${input}`);
|
||||
}
|
||||
|
||||
if (process.platform !== "darwin" && process.platform !== "win32") {
|
||||
throw new Error(
|
||||
`platform ${process.platform} is not auto-supported. Pass --asar /path/to/app.asar to override.`
|
||||
);
|
||||
}
|
||||
|
||||
const candidates = listAppCandidates(input);
|
||||
if (candidates.length === 0) {
|
||||
throw new Error(
|
||||
`could not find an installed app matching "${input}". Try passing an absolute path or --asar.`
|
||||
);
|
||||
}
|
||||
|
||||
const resolved = candidates
|
||||
.map((c) => resolveByPath(c))
|
||||
.filter((r): r is ResolvedApp => r !== null);
|
||||
|
||||
if (resolved.length === 0) {
|
||||
throw new Error(
|
||||
`found app directories for "${input}" but none contained app.asar:\n ${candidates.join(
|
||||
"\n "
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (resolved.length > 1) {
|
||||
throw new Error(
|
||||
`multiple apps match "${input}". Re-run with an absolute path:\n ${resolved
|
||||
.map((r) => r.installRoot ?? r.asarPath)
|
||||
.join("\n ")}`
|
||||
);
|
||||
}
|
||||
return resolved[0];
|
||||
}
|
||||
|
||||
function downloadsDir(): string {
|
||||
return join(homedir(), "Downloads");
|
||||
}
|
||||
|
||||
function assertSafeOutputDir(outputDir: string, appName: string): void {
|
||||
const root =
|
||||
process.platform === "win32" ? outputDir.split(sep)[0] + sep : "/";
|
||||
const home = resolve(homedir());
|
||||
const cwd = resolve(process.cwd());
|
||||
const forbidden = [root, home, cwd];
|
||||
if (forbidden.includes(outputDir)) {
|
||||
throw new Error(`output directory is unsafe to write into: ${outputDir}`);
|
||||
}
|
||||
const base = basename(outputDir).toLowerCase();
|
||||
const tag = sanitizeAppName(appName).toLowerCase();
|
||||
if (!base.includes(tag.toLowerCase()) && !base.includes("electron-extract")) {
|
||||
throw new Error(
|
||||
`output directory basename must contain the app name (or "electron-extract"): ${outputDir}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function dirIsNonEmpty(p: string): boolean {
|
||||
try {
|
||||
return readdirSync(p).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runQuiet(
|
||||
cmd: string,
|
||||
args: string[]
|
||||
): { code: number; stderr: string; stdout: string } {
|
||||
const r = spawnSync(cmd, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (r.error) return { code: 1, stderr: r.error.message, stdout: "" };
|
||||
return {
|
||||
code: r.status ?? 1,
|
||||
stderr: r.stderr ?? "",
|
||||
stdout: r.stdout ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function runForwarded(cmd: string, args: string[]): Promise<number> {
|
||||
return new Promise((res) => {
|
||||
const proc = spawn(cmd, args, { stdio: ["ignore", "inherit", "inherit"] });
|
||||
proc.on("close", (code) => res(code ?? 1));
|
||||
proc.on("error", () => res(1));
|
||||
});
|
||||
}
|
||||
|
||||
async function extractAsar(
|
||||
asarPath: string,
|
||||
dest: string,
|
||||
json: boolean
|
||||
): Promise<void> {
|
||||
mkdirSync(dest, { recursive: true });
|
||||
const args = ["-y", "@electron/asar", "extract", asarPath, dest];
|
||||
const code = json
|
||||
? await new Promise<number>((res) => {
|
||||
const p = spawn("npx", args, { stdio: ["ignore", "ignore", "pipe"] });
|
||||
let err = "";
|
||||
p.stderr?.on("data", (d) => (err += d.toString()));
|
||||
p.on("close", (c) => {
|
||||
if ((c ?? 1) !== 0) process.stderr.write(err);
|
||||
res(c ?? 1);
|
||||
});
|
||||
p.on("error", (e) => {
|
||||
process.stderr.write(e.message);
|
||||
res(1);
|
||||
});
|
||||
})
|
||||
: await runForwarded("npx", args);
|
||||
if (code !== 0)
|
||||
throw new Error(`@electron/asar extract failed (code ${code})`);
|
||||
}
|
||||
|
||||
type FileKind = "js" | "css" | "other";
|
||||
|
||||
function classifyExt(p: string): FileKind {
|
||||
const e = extname(p).toLowerCase();
|
||||
if (e === ".js" || e === ".mjs" || e === ".cjs") return "js";
|
||||
if (e === ".css") return "css";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function walk(
|
||||
root: string,
|
||||
out: { jsFiles: string[]; cssFiles: string[] },
|
||||
counts: Counts
|
||||
): void {
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
if (entry.name === "node_modules") {
|
||||
counts.skippedNodeModules += 1;
|
||||
continue;
|
||||
}
|
||||
const full = join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full, out, counts);
|
||||
} else if (entry.isFile()) {
|
||||
counts.extractedFiles += 1;
|
||||
const k = classifyExt(full);
|
||||
if (k === "js") out.jsFiles.push(full);
|
||||
else if (k === "css") out.cssFiles.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function countFiles(dir: string): number {
|
||||
let total = 0;
|
||||
if (!existsAsDir(dir)) return 0;
|
||||
const stack = [dir];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!;
|
||||
for (const entry of readdirSync(cur, { withFileTypes: true })) {
|
||||
const full = join(cur, entry.name);
|
||||
if (entry.isDirectory()) stack.push(full);
|
||||
else if (entry.isFile()) total += 1;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function toPosix(p: string): string {
|
||||
return p.split(sep).join("/");
|
||||
}
|
||||
|
||||
function sourcePathExt(src: string): string {
|
||||
return extname(src.split(/[?#]/)[0]) || ".txt";
|
||||
}
|
||||
|
||||
function stripSourceDecorations(src: string): {
|
||||
path: string;
|
||||
hadProtocol: boolean;
|
||||
} {
|
||||
let s = src.trim();
|
||||
let hadProtocol = false;
|
||||
if (/^webpack:\/\/\/?/.test(s)) {
|
||||
hadProtocol = true;
|
||||
s = s.replace(/^webpack:\/\/\/?/, "");
|
||||
} else if (/^file:\/\//.test(s)) {
|
||||
hadProtocol = true;
|
||||
s = s.replace(/^file:\/\//, "");
|
||||
} else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(s)) {
|
||||
hadProtocol = true;
|
||||
s = s.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
|
||||
}
|
||||
return { path: s.split(/[?#]/)[0].replace(/\\/g, "/"), hadProtocol };
|
||||
}
|
||||
|
||||
function isSafeRelativePath(p: string): boolean {
|
||||
return (
|
||||
p !== "" &&
|
||||
p !== "." &&
|
||||
p !== ".." &&
|
||||
!p.startsWith("../") &&
|
||||
!p.includes("/../")
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackUnknownPath(src: string): string {
|
||||
const h = createHash("sha1").update(src).digest("hex").slice(0, 10);
|
||||
return posixPath.join("__unknown", `${h}${sourcePathExt(src)}`);
|
||||
}
|
||||
|
||||
function normalizeForOutputPath(p: string): string {
|
||||
let s = p.replace(/\\/g, "/").replace(/^[a-zA-Z]:\//, "");
|
||||
if (s.startsWith("/")) s = s.replace(/^\/+/, "");
|
||||
return posixPath.normalize(s);
|
||||
}
|
||||
|
||||
function sanitizeEscapedSourcePath(
|
||||
normalized: string,
|
||||
original: string
|
||||
): string {
|
||||
let s = normalized
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^[a-zA-Z]:\//, "")
|
||||
.replace(/^\/+/, "");
|
||||
while (s.startsWith("../")) s = s.slice(3);
|
||||
s = posixPath.normalize(s);
|
||||
if (!isSafeRelativePath(s)) return fallbackUnknownPath(original);
|
||||
return s;
|
||||
}
|
||||
|
||||
function shouldSkipRestoredSource(src: string): boolean {
|
||||
const s = src.replace(/\\/g, "/");
|
||||
return s.includes("node_modules/") || s.startsWith("webpack/runtime/");
|
||||
}
|
||||
|
||||
function sourceWithRoot(
|
||||
src: string,
|
||||
sourceRoot: unknown
|
||||
): { path: string; hadProtocol: boolean } {
|
||||
const source = stripSourceDecorations(src);
|
||||
if (typeof sourceRoot !== "string" || sourceRoot.trim() === "") return source;
|
||||
|
||||
const root = stripSourceDecorations(sourceRoot);
|
||||
const sourceIsAbsolute =
|
||||
source.path.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(source.path);
|
||||
if (!root.path || source.hadProtocol || sourceIsAbsolute) {
|
||||
return {
|
||||
path: source.path,
|
||||
hadProtocol: source.hadProtocol || root.hadProtocol,
|
||||
};
|
||||
}
|
||||
return {
|
||||
path: posixPath.join(root.path.replace(/\\/g, "/"), source.path),
|
||||
hadProtocol: source.hadProtocol || root.hadProtocol,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSourcePath(
|
||||
src: string,
|
||||
mapPath: string,
|
||||
extractedRoot: string,
|
||||
sourceRoot: unknown
|
||||
): string {
|
||||
const source = sourceWithRoot(src, sourceRoot);
|
||||
if (!source.path) return "";
|
||||
|
||||
if (
|
||||
!source.hadProtocol &&
|
||||
(source.path.startsWith("./") || source.path.startsWith("../"))
|
||||
) {
|
||||
const mapRelDir = toPosix(relative(extractedRoot, dirname(mapPath))) || ".";
|
||||
const relativeToExtracted = posixPath.normalize(
|
||||
posixPath.join(mapRelDir, source.path)
|
||||
);
|
||||
if (isSafeRelativePath(relativeToExtracted)) return relativeToExtracted;
|
||||
return sanitizeEscapedSourcePath(relativeToExtracted, src);
|
||||
}
|
||||
|
||||
const normalized = normalizeForOutputPath(source.path);
|
||||
if (isSafeRelativePath(normalized)) return normalized;
|
||||
return sanitizeEscapedSourcePath(normalized, src);
|
||||
}
|
||||
|
||||
function restoredTargetPath(
|
||||
restoredRoot: string,
|
||||
sourcePath: string
|
||||
): string | null {
|
||||
const target = resolve(restoredRoot, ...sourcePath.split("/"));
|
||||
const rel = relative(restoredRoot, target);
|
||||
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
|
||||
return target;
|
||||
}
|
||||
|
||||
interface MapData {
|
||||
sources?: unknown;
|
||||
sourcesContent?: unknown;
|
||||
sourceRoot?: unknown;
|
||||
}
|
||||
|
||||
export function restoreFromMap(
|
||||
mapPath: string,
|
||||
extractedRoot: string,
|
||||
restoredRoot: string,
|
||||
warnings: string[]
|
||||
): number {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(mapPath, "utf8");
|
||||
} catch (e: any) {
|
||||
warnings.push(`read ${mapPath}: ${e.message}`);
|
||||
return 0;
|
||||
}
|
||||
let data: MapData;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch (e: any) {
|
||||
warnings.push(`parse ${mapPath}: ${e.message}`);
|
||||
return 0;
|
||||
}
|
||||
const sources = Array.isArray(data.sources)
|
||||
? (data.sources as unknown[])
|
||||
: null;
|
||||
const contents = Array.isArray(data.sourcesContent)
|
||||
? (data.sourcesContent as unknown[])
|
||||
: null;
|
||||
if (!sources || !contents) return 0;
|
||||
if (sources.length !== contents.length) {
|
||||
warnings.push(`${mapPath}: sources/sourcesContent length mismatch`);
|
||||
}
|
||||
const n = Math.min(sources.length, contents.length);
|
||||
let written = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const src = sources[i];
|
||||
const content = contents[i];
|
||||
if (typeof src !== "string" || typeof content !== "string") continue;
|
||||
if (shouldSkipRestoredSource(src)) continue;
|
||||
const restoredPath = normalizeSourcePath(
|
||||
src,
|
||||
mapPath,
|
||||
extractedRoot,
|
||||
data.sourceRoot
|
||||
);
|
||||
if (!restoredPath) continue;
|
||||
const target = restoredTargetPath(restoredRoot, restoredPath);
|
||||
if (!target) {
|
||||
warnings.push(`${mapPath}: unsafe restored path for source ${src}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, content);
|
||||
written += 1;
|
||||
} catch (e: any) {
|
||||
warnings.push(`write ${target}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
function formatBatches(files: string[], json: boolean): Promise<void> {
|
||||
const maxArgLen = 50_000;
|
||||
let batch: string[] = [];
|
||||
let len = 0;
|
||||
const flush = async () => {
|
||||
if (batch.length === 0) return;
|
||||
const args = ["-y", "prettier", "--write", ...batch];
|
||||
const code = json
|
||||
? await new Promise<number>((res) => {
|
||||
const p = spawn("npx", args, { stdio: ["ignore", "ignore", "pipe"] });
|
||||
let err = "";
|
||||
p.stderr?.on("data", (d) => (err += d.toString()));
|
||||
p.on("close", (c) => {
|
||||
if ((c ?? 1) !== 0) process.stderr.write(err);
|
||||
res(c ?? 1);
|
||||
});
|
||||
p.on("error", (e) => {
|
||||
process.stderr.write(e.message);
|
||||
res(1);
|
||||
});
|
||||
})
|
||||
: await runForwarded("npx", args);
|
||||
if (code !== 0) {
|
||||
process.stderr.write(
|
||||
`prettier batch exited with code ${code} (continuing)\n`
|
||||
);
|
||||
}
|
||||
batch = [];
|
||||
len = 0;
|
||||
};
|
||||
|
||||
return (async () => {
|
||||
for (const f of files) {
|
||||
batch.push(f);
|
||||
len += f.length + 1;
|
||||
if (len >= maxArgLen) await flush();
|
||||
}
|
||||
await flush();
|
||||
})();
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const started = Date.now();
|
||||
let opts: Options;
|
||||
try {
|
||||
opts = parseArgs(process.argv.slice(2));
|
||||
} catch (e: any) {
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let resolved: ResolvedApp;
|
||||
try {
|
||||
if (opts.asar) {
|
||||
const r = resolveByPath(opts.asar);
|
||||
if (!r) throw new Error(`--asar path not found: ${opts.asar}`);
|
||||
const appName =
|
||||
opts.app && !isAbsolute(opts.app)
|
||||
? opts.app
|
||||
: appNameFromPath(opts.asar);
|
||||
resolved = { ...r, appName };
|
||||
} else {
|
||||
resolved = resolveApp(opts.app);
|
||||
}
|
||||
} catch (e: any) {
|
||||
fail(e.message, opts.json);
|
||||
}
|
||||
|
||||
const outputDir = resolve(
|
||||
opts.output ??
|
||||
join(
|
||||
downloadsDir(),
|
||||
`${sanitizeAppName(resolved.appName)}-electron-extract`
|
||||
)
|
||||
);
|
||||
try {
|
||||
assertSafeOutputDir(outputDir, resolved.appName);
|
||||
} catch (e: any) {
|
||||
fail(e.message, opts.json);
|
||||
}
|
||||
|
||||
const extractedDir = join(outputDir, "extracted");
|
||||
const unpackedOut = join(outputDir, "extracted.unpacked");
|
||||
const restoredDir = join(outputDir, "restored");
|
||||
|
||||
if (opts.dryRun) {
|
||||
const summary = {
|
||||
status: "dry-run",
|
||||
appName: resolved.appName,
|
||||
asar: resolved.asarPath,
|
||||
unpacked: resolved.unpackedDir,
|
||||
output: outputDir,
|
||||
extracted: extractedDir,
|
||||
extractedUnpacked: opts.noUnpacked
|
||||
? null
|
||||
: resolved.unpackedDir
|
||||
? unpackedOut
|
||||
: null,
|
||||
restored: opts.skipRestore ? null : restoredDir,
|
||||
};
|
||||
if (opts.json) {
|
||||
process.stdout.write(JSON.stringify(summary) + "\n");
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`[dry-run] App: ${resolved.appName}\n` +
|
||||
`[dry-run] Asar: ${resolved.asarPath}\n` +
|
||||
`[dry-run] Unpacked: ${resolved.unpackedDir ?? "(none)"}\n` +
|
||||
`[dry-run] Output: ${outputDir}\n` +
|
||||
`[dry-run] extracted: ${extractedDir}\n` +
|
||||
`[dry-run] unpacked: ${
|
||||
opts.noUnpacked
|
||||
? "(skipped)"
|
||||
: resolved.unpackedDir
|
||||
? unpackedOut
|
||||
: "(none)"
|
||||
}\n` +
|
||||
`[dry-run] restored: ${
|
||||
opts.skipRestore ? "(skipped)" : restoredDir
|
||||
}\n`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (existsSync(outputDir) && dirIsNonEmpty(outputDir) && !opts.force) {
|
||||
fail(
|
||||
`output directory exists and is not empty: ${outputDir}\n Pass --force to allow writing into it, or choose another --output.`,
|
||||
opts.json
|
||||
);
|
||||
}
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
if (!opts.json) {
|
||||
process.stdout.write(`App: ${resolved.appName}\n`);
|
||||
process.stdout.write(`Asar: ${resolved.asarPath}\n`);
|
||||
process.stdout.write(`Output: ${outputDir}\n`);
|
||||
process.stdout.write(`Extracting asar...\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
await extractAsar(resolved.asarPath, extractedDir, opts.json);
|
||||
} catch (e: any) {
|
||||
fail(e.message, opts.json);
|
||||
}
|
||||
|
||||
const counts: Counts = {
|
||||
extractedFiles: 0,
|
||||
restoredFiles: 0,
|
||||
formattedFiles: 0,
|
||||
skippedNodeModules: 0,
|
||||
unpackedFiles: 0,
|
||||
};
|
||||
const warnings: string[] = [];
|
||||
|
||||
let unpackedFinal: string | null = null;
|
||||
if (
|
||||
!opts.noUnpacked &&
|
||||
resolved.unpackedDir &&
|
||||
existsAsDir(resolved.unpackedDir)
|
||||
) {
|
||||
if (!opts.json) process.stdout.write(`Copying app.asar.unpacked...\n`);
|
||||
try {
|
||||
cpSync(resolved.unpackedDir, unpackedOut, { recursive: true });
|
||||
counts.unpackedFiles = countFiles(unpackedOut);
|
||||
unpackedFinal = unpackedOut;
|
||||
} catch (e: any) {
|
||||
warnings.push(`copy unpacked: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const walked = { jsFiles: [] as string[], cssFiles: [] as string[] };
|
||||
walk(extractedDir, walked, counts);
|
||||
|
||||
const toFormat: string[] = [];
|
||||
const restoredMapFiles = new Set<string>();
|
||||
const skipFormatSet = new Set<string>();
|
||||
|
||||
if (!opts.skipRestore) {
|
||||
for (const js of walked.jsFiles) {
|
||||
const mapPath = js + ".map";
|
||||
if (!existsAsFile(mapPath)) continue;
|
||||
const w = restoreFromMap(mapPath, extractedDir, restoredDir, warnings);
|
||||
if (w > 0) {
|
||||
counts.restoredFiles += w;
|
||||
restoredMapFiles.add(mapPath);
|
||||
skipFormatSet.add(js);
|
||||
const license = js + ".LICENSE.txt";
|
||||
if (existsAsFile(license)) skipFormatSet.add(license);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.skipFormat) {
|
||||
for (const js of walked.jsFiles)
|
||||
if (!skipFormatSet.has(js)) toFormat.push(js);
|
||||
for (const css of walked.cssFiles) toFormat.push(css);
|
||||
if (toFormat.length > 0) {
|
||||
if (!opts.json)
|
||||
process.stdout.write(
|
||||
`Formatting ${toFormat.length} file(s) with prettier...\n`
|
||||
);
|
||||
await formatBatches(toFormat, opts.json);
|
||||
counts.formattedFiles = toFormat.length;
|
||||
}
|
||||
}
|
||||
|
||||
const report: Report = {
|
||||
appName: resolved.appName,
|
||||
source: {
|
||||
input: opts.app,
|
||||
asar: resolved.asarPath,
|
||||
platform: process.platform,
|
||||
installRoot: resolved.installRoot,
|
||||
},
|
||||
output: {
|
||||
root: outputDir,
|
||||
extracted: extractedDir,
|
||||
unpacked: unpackedFinal,
|
||||
restored: counts.restoredFiles > 0 ? restoredDir : null,
|
||||
},
|
||||
counts,
|
||||
warnings,
|
||||
durationMs: Date.now() - started,
|
||||
};
|
||||
|
||||
try {
|
||||
writeFileSync(
|
||||
join(outputDir, "extract-report.json"),
|
||||
JSON.stringify(report, null, 2)
|
||||
);
|
||||
} catch (e: any) {
|
||||
warnings.push(`write extract-report.json: ${e.message}`);
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(JSON.stringify({ status: "ok", ...report }) + "\n");
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`\nDone in ${(report.durationMs / 1000).toFixed(1)}s\n` +
|
||||
` Extracted: ${counts.extractedFiles} file(s) into ${extractedDir}\n` +
|
||||
(counts.unpackedFiles > 0
|
||||
? ` Unpacked copied: ${counts.unpackedFiles} file(s) into ${unpackedOut}\n`
|
||||
: "") +
|
||||
` Restored: ${counts.restoredFiles} file(s)` +
|
||||
(counts.restoredFiles > 0 ? ` into ${restoredDir}\n` : `\n`) +
|
||||
` Formatted: ${counts.formattedFiles} file(s)\n` +
|
||||
` node_modules dirs skipped: ${counts.skippedNodeModules}\n` +
|
||||
(warnings.length > 0
|
||||
? ` Warnings: ${warnings.length} (see extract-report.json)\n`
|
||||
: "")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectRun(): boolean {
|
||||
if (import.meta.main) return true;
|
||||
const scriptPath = process.argv[1];
|
||||
return Boolean(
|
||||
scriptPath && pathToFileURL(resolve(scriptPath)).href === import.meta.url
|
||||
);
|
||||
}
|
||||
|
||||
if (isDirectRun()) {
|
||||
main().catch((e) => {
|
||||
process.stderr.write(`Unexpected error: ${e?.stack ?? e}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
---
|
||||
name: baoyu-image-cards
|
||||
description: Generates infographic image card series with 12 visual styles, 8 layouts, and 3 color palettes. Breaks content into 1-10 cartoon-style image cards optimized for social media engagement. Use when user mentions "小红书图片", "小红书种草", "小绿书", "微信图文", "微信贴图", "image cards", "图片卡片", or wants social media infographic series.
|
||||
version: 1.56.2
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-cards
|
||||
---
|
||||
|
||||
# Image Card Series Generator
|
||||
|
||||
Break down complex content into eye-catching image card series with multiple style options.
|
||||
|
||||
## 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
|
||||
|
||||
When this 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 `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):
|
||||
- If the current runtime exposes a native image tool (e.g., Codex `imagegen`, Hermes `image_generate`), use it. Runtime-native tools are preferred by default — agents that know their own tool inventory should surface the native one here.
|
||||
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-imagine`), 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.
|
||||
|
||||
Setting `preferred_image_backend: ask` forces the step-3 prompt every run regardless of available backends. Users change the pinned backend via the `## Changing Preferences` section below.
|
||||
|
||||
**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 file is the reproducibility record and lets you switch backends without regenerating prompts.
|
||||
|
||||
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) above are examples — substitute the local equivalents under the same rule.
|
||||
|
||||
## Confirmation Policy
|
||||
|
||||
Default behavior: **confirm before generation**.
|
||||
|
||||
- Treat explicit skill invocation, a file path, matched signals/presets, and `EXTEND.md` defaults as **recommendation inputs only**. None of them authorizes skipping confirmation.
|
||||
- Do **not** start Step 3 until the user completes Step 2.
|
||||
- Skip confirmation only when the current request explicitly says to do so, for example: `--yes`, "直接生成", "不用确认", "跳过确认", "按默认出图", or equivalent wording.
|
||||
- If confirmation is skipped explicitly, state the assumed strategy / style / layout / palette / count / backend in the next user-facing update before generating.
|
||||
|
||||
## Language
|
||||
|
||||
Respond in the user's language across questions, progress, errors, and completion summary. Keep technical tokens (style names, file paths, code) in English.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--style <name>` | Visual style (see Styles below) |
|
||||
| `--layout <name>` | Information layout (see Layouts below) |
|
||||
| `--palette <name>` | Color override: macaron / warm / neon |
|
||||
| `--preset <name>` | Style + layout + optional palette shorthand (see Presets below; per-preset prompt fragments in `references/style-presets.md`) |
|
||||
| `--ref <files...>` | Reference images applied to image 1 as the series anchor |
|
||||
| `--yes` | Non-interactive: skip all confirmations, use EXTEND.md or built-in defaults, auto-confirm recommended plan (Path A) |
|
||||
|
||||
## Dimensions
|
||||
|
||||
Three independent knobs combine freely:
|
||||
|
||||
| Dimension | Controls | Options |
|
||||
|-----------|----------|---------|
|
||||
| **Style** | Visual aesthetics (lines, decorations, rendering) | 12 styles (see Styles below) |
|
||||
| **Layout** | Information structure (density, arrangement) | 8 layouts (see Layouts below) |
|
||||
| **Palette** (optional) | Color override, replaces the style's default colors | macaron / warm / neon (see Palettes below) |
|
||||
|
||||
Example: `--style notion --layout dense` makes an intellectual knowledge card; add `--palette macaron` to soften the colors without changing notion's rendering rules. A `--preset` is a shorthand for style + layout (+ optional palette).
|
||||
|
||||
**Palette behavior**: no `--palette` → style's built-in colors; `--palette <name>` → overrides colors only, rendering rules unchanged. Some styles declare a `default_palette` (e.g., sketch-notes defaults to macaron).
|
||||
|
||||
## Styles (12)
|
||||
|
||||
| Style | Description |
|
||||
|-------|-------------|
|
||||
| `cute` (Default) | Sweet, adorable, girly aesthetic |
|
||||
| `fresh` | Clean, refreshing, natural |
|
||||
| `warm` | Cozy, friendly, approachable |
|
||||
| `bold` | High impact, attention-grabbing |
|
||||
| `minimal` | Ultra-clean, sophisticated |
|
||||
| `retro` | Vintage, nostalgic, trendy |
|
||||
| `pop` | Vibrant, energetic, eye-catching |
|
||||
| `notion` | Minimalist hand-drawn line art, intellectual |
|
||||
| `chalkboard` | Colorful chalk on black board, educational |
|
||||
| `study-notes` | Realistic handwritten photo style, blue pen + red annotations + yellow highlighter |
|
||||
| `screen-print` | Bold poster art, halftone textures, limited colors, symbolic storytelling |
|
||||
| `sketch-notes` | Hand-drawn educational infographic, macaron pastels on warm cream, wobble lines |
|
||||
|
||||
Per-style specifications: `references/presets/<style>.md`.
|
||||
|
||||
## Layouts (8)
|
||||
|
||||
| Layout | Description |
|
||||
|--------|-------------|
|
||||
| `sparse` (Default) | 1-2 points, maximum impact |
|
||||
| `balanced` | 3-4 points, standard |
|
||||
| `dense` | 5-8 points, knowledge-card style |
|
||||
| `list` | Enumeration / ranking (4-7 items) |
|
||||
| `comparison` | Side-by-side contrast |
|
||||
| `flow` | Process / timeline (3-6 steps) |
|
||||
| `mindmap` | Center-radial (4-8 branches) |
|
||||
| `quadrant` | Four-quadrant / circular sections |
|
||||
|
||||
Layout specs: `references/elements/canvas.md`.
|
||||
|
||||
## Palettes (optional override)
|
||||
|
||||
Replaces the style's colors while keeping rendering rules (line treatment, textures) intact.
|
||||
|
||||
| Palette | Background | Zone Colors | Accent | Feel |
|
||||
|---------|------------|-------------|--------|------|
|
||||
| `macaron` | Warm cream #F5F0E8 | Blue #A8D8EA, Lavender #D5C6E0, Mint #B5E5CF, Peach #F8D5C4 | Coral #E8655A | Soft, educational |
|
||||
| `warm` | Soft peach #FFECD2 | Orange #ED8936, Terracotta #C05621, Golden #F6AD55, Rose #D4A09A | Sienna #A0522D | Earth tones, cozy |
|
||||
| `neon` | Dark purple #1A1025 | Cyan #00F5FF, Magenta #FF00FF, Green #39FF14, Pink #FF6EC7 | Yellow #FFFF00 | High-energy, futuristic |
|
||||
|
||||
Palette specs: `references/palettes/<palette>.md`.
|
||||
|
||||
## Presets (style + layout shortcuts)
|
||||
|
||||
Quick-start combos, grouped by scenario. Use `--preset <name>` or recommend during Step 2.
|
||||
|
||||
**Knowledge & Learning**:
|
||||
|
||||
| Preset | Style | Layout | Best For |
|
||||
|--------|-------|--------|----------|
|
||||
| `knowledge-card` | notion | dense | 干货知识卡、概念科普 |
|
||||
| `checklist` | notion | list | 清单、排行榜 |
|
||||
| `concept-map` | notion | mindmap | 概念图、知识脉络 |
|
||||
| `swot` | notion | quadrant | SWOT 分析、四象限 |
|
||||
| `tutorial` | chalkboard | flow | 教程步骤、操作流程 |
|
||||
| `classroom` | chalkboard | balanced | 课堂笔记、知识讲解 |
|
||||
| `study-guide` | study-notes | dense | 学习笔记、考试重点 |
|
||||
| `hand-drawn-edu` | sketch-notes | flow | 手绘教程、流程图解 |
|
||||
| `sketch-card` | sketch-notes | dense | 手绘知识卡 |
|
||||
| `sketch-summary` | sketch-notes | balanced | 手绘总结、图文笔记 |
|
||||
|
||||
**Lifestyle & Sharing**:
|
||||
|
||||
| Preset | Style | Layout | Best For |
|
||||
|--------|-------|--------|----------|
|
||||
| `cute-share` | cute | balanced | 少女风分享、日常种草 |
|
||||
| `girly` | cute | sparse | 甜美封面、氛围感 |
|
||||
| `cozy-story` | warm | balanced | 生活故事、情感分享 |
|
||||
| `product-review` | fresh | comparison | 产品对比、测评 |
|
||||
| `nature-flow` | fresh | flow | 健康流程、自然主题 |
|
||||
|
||||
**Impact & Opinion**:
|
||||
|
||||
| Preset | Style | Layout | Best For |
|
||||
|--------|-------|--------|----------|
|
||||
| `warning` | bold | list | 避坑指南、重要提醒 |
|
||||
| `versus` | bold | comparison | 正反对比 |
|
||||
| `clean-quote` | minimal | sparse | 金句、极简封面 |
|
||||
| `pro-summary` | minimal | balanced | 专业总结、商务内容 |
|
||||
|
||||
**Trend & Entertainment**:
|
||||
|
||||
| Preset | Style | Layout | Best For |
|
||||
|--------|-------|--------|----------|
|
||||
| `retro-ranking` | retro | list | 复古排行、经典盘点 |
|
||||
| `throwback` | retro | balanced | 怀旧分享 |
|
||||
| `pop-facts` | pop | list | 趣味冷知识 |
|
||||
| `hype` | pop | sparse | 炸裂封面、惊叹分享 |
|
||||
|
||||
**Poster & Editorial**:
|
||||
|
||||
| Preset | Style | Layout | Best For |
|
||||
|--------|-------|--------|----------|
|
||||
| `poster` | screen-print | sparse | 海报风封面、影评书评 |
|
||||
| `editorial` | screen-print | balanced | 观点文章、文化评论 |
|
||||
| `cinematic` | screen-print | comparison | 电影对比、戏剧张力 |
|
||||
|
||||
Full prompt-fragment definitions: `references/style-presets.md`.
|
||||
|
||||
## Auto-Selection
|
||||
|
||||
Match content signals to the best combo. First row whose keywords appear wins; fall back to `cute-share` if nothing matches.
|
||||
|
||||
| Signals in source | Style | Layout | Recommended preset |
|
||||
|-------------------|-------|--------|--------------------|
|
||||
| beauty, fashion, cute, girl, pink | `cute` | sparse/balanced | `cute-share`, `girly` |
|
||||
| health, nature, fresh, organic | `fresh` | balanced/flow | `product-review`, `nature-flow` |
|
||||
| life, story, emotion, warm | `warm` | balanced | `cozy-story` |
|
||||
| warning, important, must, critical | `bold` | list/comparison | `warning`, `versus` |
|
||||
| professional, business, elegant | `minimal` | sparse/balanced | `clean-quote`, `pro-summary` |
|
||||
| classic, vintage, traditional | `retro` | balanced | `throwback`, `retro-ranking` |
|
||||
| fun, exciting, wow, amazing | `pop` | sparse/list | `hype`, `pop-facts` |
|
||||
| knowledge, concept, productivity, SaaS | `notion` | dense/list | `knowledge-card`, `checklist` |
|
||||
| education, tutorial, learning, classroom | `chalkboard` | balanced/dense | `tutorial`, `classroom` |
|
||||
| notes, handwritten, study guide, realistic | `study-notes` | dense/list/mindmap | `study-guide` |
|
||||
| movie, poster, opinion, editorial, cinematic | `screen-print` | sparse/comparison | `poster`, `editorial`, `cinematic` |
|
||||
| hand-drawn, infographic, workflow, 手绘, 图解 | `sketch-notes` | flow/balanced/dense | `hand-drawn-edu`, `sketch-card`, `sketch-summary` |
|
||||
|
||||
## Style × Layout Matrix
|
||||
|
||||
Compatibility scores (✓✓ highly recommended, ✓ works well, ✗ avoid). Use when the user picks a non-default combo and you want to flag a poor match.
|
||||
|
||||
| | sparse | balanced | dense | list | comparison | flow | mindmap | quadrant |
|
||||
|--------------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| cute | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| fresh | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓✓ | ✓ | ✓ |
|
||||
| warm | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ |
|
||||
| bold | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ |
|
||||
| minimal | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| retro | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| pop | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ |
|
||||
| notion | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
|
||||
| chalkboard | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| study-notes | ✗ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ |
|
||||
| screen-print | ✓✓ | ✓✓ | ✗ | ✓ | ✓✓ | ✓ | ✗ | ✓✓ |
|
||||
| sketch-notes | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
|
||||
## Outline Strategies
|
||||
|
||||
Three differentiated approaches — each produces a structurally different outline. The workflow recommends one; Path C generates all three and lets the user choose.
|
||||
|
||||
| Strategy | Concept | Best for | Structure |
|
||||
|----------|---------|----------|-----------|
|
||||
| **A — Story-Driven** | Personal experience as the thread, emotional resonance first | Reviews, personal shares, transformation | Hook → Problem → Discovery → Experience → Conclusion |
|
||||
| **B — Information-Dense** | Value-first, efficient information delivery | Tutorials, comparisons, checklists | Core conclusion → Info card → Pros/Cons → Recommendation |
|
||||
| **C — Visual-First** | Visual impact as core, minimal text | High-aesthetic products, lifestyle, mood content | Hero image → Detail shots → Lifestyle scene → CTA |
|
||||
|
||||
## Reference Images
|
||||
|
||||
User-supplied refs are **separate from** the internal "image-1 as anchor" chain (Step 3) — they layer on top of it.
|
||||
|
||||
**Intake**: via `--ref <files...>` or paths pasted in conversation.
|
||||
- File path → copy to `refs/NN-ref-{slug}.{ext}`
|
||||
- Pasted with no path → ask for the path, or extract style traits as a text fallback
|
||||
|
||||
**Usage modes** (per reference):
|
||||
|
||||
| Usage | Effect |
|
||||
|-------|--------|
|
||||
| `direct` | Pass the file to the backend (typically on image 1 only, so the anchor propagates through the chain) |
|
||||
| `style` | Extract style traits and append to every card's prompt body |
|
||||
| `palette` | Extract hex colors and append to every card's prompt body |
|
||||
|
||||
Record refs in each affected card's prompt frontmatter:
|
||||
|
||||
```yaml
|
||||
references:
|
||||
- ref_id: 01
|
||||
filename: 01-ref-brand.png
|
||||
usage: direct
|
||||
```
|
||||
|
||||
At generation time: verify files exist. Image 1 with `usage: direct` + backend that accepts refs → pass via the backend's ref parameter (becomes the chain anchor). Images 2+ keep using image-1 as `--ref` per Step 3 — do NOT re-stack user refs on top (avoids conflicting signals). For `style`/`palette`, embed extracted traits in every prompt.
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
image-cards/{topic-slug}/
|
||||
├── source-{slug}.{ext}
|
||||
├── analysis.md
|
||||
├── outline-strategy-{a,b,c}.md # Path C only
|
||||
├── outline.md
|
||||
├── prompts/NN-{type}-{slug}.md
|
||||
├── NN-{type}-{slug}.png
|
||||
└── refs/ # only if --ref used
|
||||
```
|
||||
|
||||
**Slug**: 2-4 words, kebab-case. "AI 工具推荐" → `ai-tools-recommend`. On collision, append `-YYYYMMDD-HHMMSS`.
|
||||
|
||||
**Backup rule** (applies throughout): before overwriting any file — source, outline, prompt, image — rename the existing one to `<name>-backup-YYYYMMDD-HHMMSS.<ext>`. This protects user edits.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
- [ ] Step 0: Load EXTEND.md ⛔ BLOCKING (interactive only)
|
||||
- [ ] Step 1: Analyze content → analysis.md
|
||||
- [ ] Step 2: Smart Confirm ⚠️ REQUIRED (Path A / B / C)
|
||||
- [ ] Step 3: Generate images
|
||||
- [ ] Step 4: Completion report
|
||||
```
|
||||
|
||||
### Step 0: Load EXTEND.md ⛔ BLOCKING
|
||||
|
||||
Check these paths in order; first hit wins:
|
||||
|
||||
| Path | Scope |
|
||||
|------|-------|
|
||||
| `.baoyu-skills/baoyu-image-cards/EXTEND.md` | Project |
|
||||
| `${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-image-cards/EXTEND.md` | XDG |
|
||||
| `$HOME/.baoyu-skills/baoyu-image-cards/EXTEND.md` | User home |
|
||||
|
||||
- **Found** → read, parse, print a summary (style / layout / watermark / language), continue.
|
||||
- **Not found + interactive** → run first-time setup (see `references/config/first-time-setup.md`) and save before anything else. Do NOT analyze content or ask style questions until preferences exist — this keeps first-run behavior predictable.
|
||||
- **Not found + `--yes`** → skip setup, use built-in defaults (no watermark, style/layout auto-selected, language from content). Do not prompt, do not create EXTEND.md.
|
||||
|
||||
**EXTEND.md keys**: watermark, preferred style/layout, custom style definitions, language preference. Schema: `references/config/preferences-schema.md`.
|
||||
|
||||
### Step 1: Analyze Content → `analysis.md`
|
||||
|
||||
1. Save the source (backup rule applies if `source.md` exists).
|
||||
2. Run the deep analysis in `references/workflows/analysis-framework.md`: content type, hook potential, audience, engagement signals, visual opportunity map, swipe flow.
|
||||
3. Detect source language, pick recommended image count (2-10).
|
||||
4. Auto-recommend strategy + style + layout + palette using the **Auto-Selection** table above.
|
||||
5. Write everything to `analysis.md`.
|
||||
|
||||
### Step 2: Smart Confirm ⚠️ REQUIRED
|
||||
|
||||
**Hard gate**: this step is mandatory per the [Confirmation Policy](#confirmation-policy) — Step 3 cannot start until the user confirms here (or explicitly opts out with `--yes` / equivalent wording in the current request).
|
||||
|
||||
Goal: present the auto-recommended plan and let the user confirm or adjust. Skip this step entirely under `--yes` — proceed with Path A using the analysis and any CLI overrides.
|
||||
|
||||
**Display summary** before asking:
|
||||
|
||||
```
|
||||
📋 内容分析
|
||||
主题:[topic] | 类型:[content_type]
|
||||
要点:[key points]
|
||||
受众:[audience]
|
||||
|
||||
🎨 推荐方案(自动匹配)
|
||||
策略:[A/B/C] [name]([reason])
|
||||
风格:[style] · 布局:[layout] · 配色:[palette or 默认] · 预设:[preset]
|
||||
图片:[N]张(封面+[N-2]内容+结尾)
|
||||
元素:[background] / [decorations] / [emphasis]
|
||||
```
|
||||
|
||||
Then ask one question — three paths. Verbatim option copy: `references/confirmation.md`.
|
||||
|
||||
**Path A — Quick confirm** (trust auto-recommendation): generate a single outline using the recommended strategy + style → save to `outline.md` → Step 3.
|
||||
|
||||
**Path B — Customize**: ask five questions (strategy/style, layout, palette, count, optional notes) with the recommendation pre-filled — blanks keep the recommendation. Generate one outline with the user's choices → `outline.md` → Step 3. See `references/confirmation.md`.
|
||||
|
||||
**Path C — Detailed mode**: two sub-confirmations.
|
||||
|
||||
- *Step 2a — Content understanding*: ask selling points (multi-select), audience, style preference (authentic / professional / aesthetic / auto), optional context. Update `analysis.md`.
|
||||
- *Step 2b — Three outline variants*: generate `outline-strategy-a.md`, `outline-strategy-b.md`, `outline-strategy-c.md`. Each MUST have a different structure AND a different recommended style — include `style_reason` in the frontmatter. Page-count heuristic: A ~4-6, B ~3-5, C ~3-4. Template: `references/workflows/outline-template.md`; frontmatter example in `references/confirmation.md`.
|
||||
- *Step 2c — Selection*: ask three questions (outline A/B/C/Combined, style, visual elements). Save selected/merged outline to `outline.md` → Step 3.
|
||||
|
||||
### Step 3: Generate Images
|
||||
|
||||
With confirmed outline + style + layout + palette:
|
||||
|
||||
**Visual consistency — image-1 anchor chain**: character / mascot / color rendering drifts between calls unless you anchor them. Generate image 1 (cover) first WITHOUT `--ref`, then pass image 1 as `--ref` to every subsequent image. This is the single most important consistency trick for this skill — don't skip it even if the backend also supports a session ID.
|
||||
|
||||
For each image (cover, content, ending):
|
||||
|
||||
1. Write the full prompt to `prompts/NN-{type}-{slug}.md` in the user's preferred language (backup rule applies).
|
||||
2. Generate:
|
||||
- **Image 1**: no `--ref` (establishes the anchor).
|
||||
- **Images 2+**: add `--ref <path-to-image-01.png>`.
|
||||
- Backup rule applies to the PNG files.
|
||||
3. Report progress after each image.
|
||||
|
||||
**Watermark** (if enabled in EXTEND.md): append to the generation prompt:
|
||||
|
||||
```
|
||||
Include a subtle watermark "[content]" positioned at [position].
|
||||
The watermark should be legible but not distracting.
|
||||
```
|
||||
|
||||
See `references/config/watermark-guide.md`.
|
||||
|
||||
**Backend selection**: per the Image Generation Tools rule at the top — use whatever is available, ask once if multiple, before any generation. Under `--yes`, use the EXTEND.md preference and fall back to the first available backend. Prompt files MUST exist before invoking any backend.
|
||||
|
||||
**Session ID** (if the backend supports `--sessionId`): use `cards-{topic-slug}-{timestamp}` for every image; combined with the ref chain this gives maximum consistency.
|
||||
|
||||
### Step 4: Completion Report
|
||||
|
||||
```
|
||||
Image Card Series Complete!
|
||||
|
||||
Topic: [topic]
|
||||
Mode: [Quick / Custom / Detailed]
|
||||
Strategy: [A/B/C/Combined]
|
||||
Style: [name]
|
||||
Palette: [name or "default"]
|
||||
Layout: [name or "varies"]
|
||||
Location: [directory]
|
||||
Images: N total
|
||||
|
||||
✓ analysis.md
|
||||
✓ outline.md
|
||||
✓ outline-strategy-a/b/c.md (detailed mode only)
|
||||
|
||||
- 01-cover-[slug].png ✓ Cover (sparse)
|
||||
- 02-content-[slug].png ✓ Content (balanced)
|
||||
- ...
|
||||
- NN-ending-[slug].png ✓ Ending (sparse)
|
||||
```
|
||||
|
||||
## Content Breakdown Principles
|
||||
|
||||
| Position | Purpose | Typical layout |
|
||||
|----------|---------|----------------|
|
||||
| Cover (image 1) | Hook + visual impact | `sparse` |
|
||||
| Content (middle) | Core value per image | `balanced` / `dense` / `list` / `comparison` / `flow` |
|
||||
| Ending (last) | CTA / summary | `sparse` or `balanced` |
|
||||
|
||||
For the style × layout compatibility matrix, see the **Style × Layout Matrix** above.
|
||||
|
||||
## Image Modification
|
||||
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Edit | Update `prompts/NN-{type}-{slug}.md` **first**, then regenerate with the same session ID |
|
||||
| Add | Specify position, create prompt, generate, renumber subsequent files `NN+1`, update outline |
|
||||
| Delete | Remove files, renumber subsequent `NN-1`, update outline |
|
||||
|
||||
Always update the prompt file before regenerating — it's the source of truth and makes changes reproducible.
|
||||
|
||||
## References
|
||||
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| `references/confirmation.md` | Verbatim AskUserQuestion copy for every confirmation path |
|
||||
| `references/style-presets.md` | Full preset shortcut definitions |
|
||||
| `references/presets/<style>.md` | Per-style element definitions |
|
||||
| `references/palettes/<name>.md` | Per-palette color definitions |
|
||||
| `references/elements/canvas.md` | Aspect ratios, safe zones, grid layouts |
|
||||
| `references/elements/image-effects.md` | Cutout, stroke, filters |
|
||||
| `references/elements/typography.md` | Decorated text, tags, text direction |
|
||||
| `references/elements/decorations.md` | Emphasis marks, backgrounds, doodles, frames |
|
||||
| `references/workflows/analysis-framework.md` | Content analysis framework |
|
||||
| `references/workflows/outline-template.md` | Outline template with layout guide |
|
||||
| `references/workflows/prompt-assembly.md` | Prompt assembly guide |
|
||||
| `references/config/preferences-schema.md` | EXTEND.md schema |
|
||||
| `references/config/first-time-setup.md` | First-time setup flow |
|
||||
| `references/config/watermark-guide.md` | Watermark configuration |
|
||||
|
||||
## Notes
|
||||
|
||||
- Auto-retry once on generation failure before reporting an error.
|
||||
- For sensitive public figures, use stylized cartoon alternatives.
|
||||
- Smart Confirm (Step 2) is required; Detailed mode adds a second confirmation (2a + 2c).
|
||||
|
||||
## Changing Preferences
|
||||
|
||||
EXTEND.md lives at the first matching path listed in Step 0. Three ways to change it:
|
||||
|
||||
- **Edit directly** — open EXTEND.md and change fields. Full schema: `references/config/preferences-schema.md`.
|
||||
- **Reconfigure interactively** — delete EXTEND.md (or ask "reconfigure baoyu-image-cards preferences" / "重新配置"). The next run re-triggers first-time setup.
|
||||
- **Common one-line edits**:
|
||||
- `preferred_image_backend: auto` — default; runtime-native tool wins, falls back to the only installed backend, asks only if multiple non-native are present.
|
||||
- `preferred_image_backend: codex-imagegen` — pin to Codex's built-in.
|
||||
- `preferred_image_backend: baoyu-imagine` — pin to the baoyu-imagine skill.
|
||||
- `preferred_image_backend: ask` — confirm backend every run.
|
||||
- `preferred_style: notion`, `preferred_layout: dense`, `preferred_palette: macaron`, `language: zh`.
|
||||
- `watermark.enabled: true` + `watermark.content: "@handle"` — add a watermark.
|
||||
@@ -1,122 +0,0 @@
|
||||
---
|
||||
name: first-time-setup
|
||||
description: First-time setup flow for baoyu-xhs-images preferences
|
||||
---
|
||||
|
||||
# First-Time Setup
|
||||
|
||||
## Overview
|
||||
|
||||
When no EXTEND.md is found, guide user through preference setup.
|
||||
|
||||
**⛔ BLOCKING OPERATION**: This setup MUST complete before ANY other workflow steps. Do NOT:
|
||||
- Ask about content/article
|
||||
- Ask about style or layout
|
||||
- Ask about target audience
|
||||
- Proceed to content analysis
|
||||
|
||||
ONLY ask the questions in this setup flow, save EXTEND.md, then continue.
|
||||
|
||||
## Setup Flow
|
||||
|
||||
```
|
||||
No EXTEND.md found
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ AskUserQuestion │
|
||||
│ (all questions) │
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Create EXTEND.md │
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
Continue to Step 1
|
||||
```
|
||||
|
||||
## Questions
|
||||
|
||||
**Language**: Use user's input language or saved language preference.
|
||||
|
||||
Use single AskUserQuestion with multiple questions (AskUserQuestion auto-adds "Other" option):
|
||||
|
||||
### Question 1: Watermark
|
||||
|
||||
```
|
||||
header: "Watermark"
|
||||
question: "Watermark text for generated images? Type your watermark content (e.g., name, @handle)"
|
||||
options:
|
||||
- label: "No watermark (Recommended)"
|
||||
description: "No watermark, can enable later in EXTEND.md"
|
||||
```
|
||||
|
||||
Position defaults to bottom-right.
|
||||
|
||||
### Question 2: Preferred Style
|
||||
|
||||
```
|
||||
header: "Style"
|
||||
question: "Default visual style preference? Or type another style name or your custom style"
|
||||
options:
|
||||
- label: "None (Recommended)"
|
||||
description: "Auto-select based on content analysis"
|
||||
- label: "cute"
|
||||
description: "Sweet, adorable - classic XHS aesthetic"
|
||||
- label: "notion"
|
||||
description: "Minimalist hand-drawn, intellectual"
|
||||
```
|
||||
|
||||
### Question 3: Save Location
|
||||
|
||||
```
|
||||
header: "Save"
|
||||
question: "Where to save preferences?"
|
||||
options:
|
||||
- label: "Project"
|
||||
description: ".baoyu-skills/ (this project only)"
|
||||
- label: "User"
|
||||
description: "~/.baoyu-skills/ (all projects)"
|
||||
```
|
||||
|
||||
## Save Locations
|
||||
|
||||
| Choice | Path | Scope |
|
||||
|--------|------|-------|
|
||||
| Project | `.baoyu-skills/baoyu-xhs-images/EXTEND.md` | Current project |
|
||||
| User | `~/.baoyu-skills/baoyu-xhs-images/EXTEND.md` | All projects |
|
||||
|
||||
## After Setup
|
||||
|
||||
1. Create directory if needed
|
||||
2. Write EXTEND.md with frontmatter
|
||||
3. Confirm: "Preferences saved to [path]"
|
||||
4. Continue to Step 1
|
||||
|
||||
## EXTEND.md Template
|
||||
|
||||
```yaml
|
||||
---
|
||||
version: 1
|
||||
watermark:
|
||||
enabled: [true/false]
|
||||
content: "[user input or empty]"
|
||||
position: bottom-right
|
||||
opacity: 0.7
|
||||
preferred_style:
|
||||
name: [selected style or null]
|
||||
description: ""
|
||||
preferred_layout: null
|
||||
language: null
|
||||
preferred_image_backend: auto
|
||||
custom_styles: []
|
||||
---
|
||||
```
|
||||
|
||||
`preferred_image_backend: auto` is the baked-in default — first-time setup does not ask about it. The `## Image Generation Tools` rule in SKILL.md then picks the runtime-native tool (Codex `imagegen`, Hermes `image_generate`, etc.) when available, and falls back to installed backends.
|
||||
|
||||
## Modifying Preferences Later
|
||||
|
||||
See the `## Changing Preferences` section in `SKILL.md` for the canonical list of common edits (pin backend, change defaults, retrigger setup). Full schema: `preferences-schema.md`.
|
||||
@@ -1,123 +0,0 @@
|
||||
---
|
||||
name: preferences-schema
|
||||
description: EXTEND.md YAML schema for baoyu-xhs-images user preferences
|
||||
---
|
||||
|
||||
# Preferences Schema
|
||||
|
||||
## Full Schema
|
||||
|
||||
```yaml
|
||||
---
|
||||
version: 1
|
||||
|
||||
watermark:
|
||||
enabled: false
|
||||
content: ""
|
||||
position: bottom-right # bottom-right|bottom-left|bottom-center|top-right
|
||||
|
||||
preferred_style:
|
||||
name: null # Built-in or custom style name
|
||||
description: "" # Override/notes
|
||||
|
||||
preferred_layout: null # sparse|balanced|dense|list|comparison|flow
|
||||
|
||||
language: null # zh|en|ja|ko|auto
|
||||
|
||||
preferred_image_backend: auto # auto|ask|<backend-id>
|
||||
|
||||
custom_styles:
|
||||
- name: my-style
|
||||
description: "Style description"
|
||||
color_palette:
|
||||
primary: ["#FED7E2", "#FEEBC8"]
|
||||
background: "#FFFAF0"
|
||||
accents: ["#FF69B4", "#FF6B6B"]
|
||||
visual_elements: "Hearts, stars, sparkles"
|
||||
typography: "Rounded, bubbly hand lettering"
|
||||
best_for: "Lifestyle, beauty"
|
||||
---
|
||||
```
|
||||
|
||||
## Field Reference
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `version` | int | 1 | Schema version |
|
||||
| `watermark.enabled` | bool | false | Enable watermark |
|
||||
| `watermark.content` | string | "" | Watermark text (@username or custom) |
|
||||
| `watermark.position` | enum | bottom-right | Position on image |
|
||||
| `preferred_style.name` | string | null | Style name or null |
|
||||
| `preferred_style.description` | string | "" | Custom notes/override |
|
||||
| `preferred_layout` | string | null | Layout preference or null |
|
||||
| `language` | string | null | Output language (null = auto-detect) |
|
||||
| `preferred_image_backend` | string | `auto` | Image backend selection. `auto` = prefer runtime-native tool, fall back to the only installed backend, ask if multiple non-native are present. `ask` = always confirm on every run. `<backend-id>` (e.g., `codex-imagegen`, `baoyu-imagine`, `image_generate`) = pin this backend when available; fall back to `auto` when it isn't. Absent = `auto`. Resolution logic is documented in `SKILL.md`'s `## Image Generation Tools` section. |
|
||||
| `custom_styles` | array | [] | User-defined styles |
|
||||
|
||||
## Position Options
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `bottom-right` | Lower right corner (default, most common) |
|
||||
| `bottom-left` | Lower left corner |
|
||||
| `bottom-center` | Bottom center |
|
||||
| `top-right` | Upper right corner |
|
||||
|
||||
## Custom Style Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `name` | Yes | Unique style identifier (kebab-case) |
|
||||
| `description` | Yes | What the style conveys |
|
||||
| `color_palette.primary` | No | Main colors (array) |
|
||||
| `color_palette.background` | No | Background color |
|
||||
| `color_palette.accents` | No | Accent colors (array) |
|
||||
| `visual_elements` | No | Decorative elements |
|
||||
| `typography` | No | Font/lettering style |
|
||||
| `best_for` | No | Recommended content types |
|
||||
|
||||
## Example: Minimal Preferences
|
||||
|
||||
```yaml
|
||||
---
|
||||
version: 1
|
||||
watermark:
|
||||
enabled: true
|
||||
content: "@myusername"
|
||||
preferred_style:
|
||||
name: notion
|
||||
---
|
||||
```
|
||||
|
||||
## Example: Full Preferences
|
||||
|
||||
```yaml
|
||||
---
|
||||
version: 1
|
||||
watermark:
|
||||
enabled: true
|
||||
content: "@myxhsaccount"
|
||||
position: bottom-right
|
||||
|
||||
preferred_style:
|
||||
name: notion
|
||||
description: "Clean knowledge cards for tech content"
|
||||
|
||||
preferred_layout: dense
|
||||
|
||||
language: zh
|
||||
|
||||
preferred_image_backend: codex-imagegen
|
||||
|
||||
custom_styles:
|
||||
- name: corporate
|
||||
description: "Professional B2B style"
|
||||
color_palette:
|
||||
primary: ["#1E3A5F", "#4A90D9"]
|
||||
background: "#F5F7FA"
|
||||
accents: ["#00B4D8", "#48CAE4"]
|
||||
visual_elements: "Clean lines, subtle gradients, geometric shapes"
|
||||
typography: "Modern sans-serif, professional"
|
||||
best_for: "Business, SaaS, enterprise"
|
||||
---
|
||||
```
|
||||
@@ -1,62 +0,0 @@
|
||||
---
|
||||
name: watermark-guide
|
||||
description: Watermark configuration guide for baoyu-xhs-images
|
||||
---
|
||||
|
||||
# Watermark Guide
|
||||
|
||||
## Position Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ [top-right]│
|
||||
│ │
|
||||
│ │
|
||||
│ IMAGE CONTENT │
|
||||
│ │
|
||||
│ │
|
||||
│[bottom-left][bottom-center][bottom-right]│
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
## Position Recommendations
|
||||
|
||||
| Position | Best For | Avoid When |
|
||||
|----------|----------|------------|
|
||||
| `bottom-right` | Default choice, most common | Key info in bottom-right |
|
||||
| `bottom-left` | Right-heavy layouts | Key info in bottom-left |
|
||||
| `bottom-center` | Centered designs | Text-heavy bottom area |
|
||||
| `top-right` | Bottom-heavy content | Title/header in top-right |
|
||||
|
||||
## Content Format
|
||||
|
||||
| Format | Example | Style |
|
||||
|--------|---------|-------|
|
||||
| Handle | `@username` | Most common for XHS |
|
||||
| Text | `MyBrand` | Simple branding |
|
||||
| Chinese | `小红书:用户名` | Platform specific |
|
||||
| URL | `myblog.com` | Cross-platform |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Consistency**: Use same watermark across all images in series
|
||||
2. **Legibility**: Ensure watermark readable on both light/dark areas
|
||||
3. **Size**: Keep subtle - should not distract from content
|
||||
|
||||
## Prompt Integration
|
||||
|
||||
When watermark is enabled, add to image generation prompt:
|
||||
|
||||
```
|
||||
Include a subtle watermark "[content]" positioned at [position].
|
||||
The watermark should be legible but not distracting from the main content.
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Watermark invisible | Adjust position or check contrast |
|
||||
| Watermark too prominent | Change position or reduce size |
|
||||
| Watermark overlaps content | Change position |
|
||||
| Inconsistent across images | Use session ID for consistency |
|
||||
@@ -1,156 +0,0 @@
|
||||
# Confirmation Questions
|
||||
|
||||
Concrete option copy for Step 2 Smart Confirm. SKILL.md states which question to ask and when — this file supplies the verbatim options used in Claude Code. Other runtimes should adapt the wording to their native user-input tool while preserving intent.
|
||||
|
||||
## Step 2 — Smart Confirm Entry
|
||||
|
||||
Single-question confirmation presented right after the auto-recommended plan.
|
||||
|
||||
```yaml
|
||||
header: Mode
|
||||
question: How to proceed with the recommended plan?
|
||||
options:
|
||||
- label: 1. ✅ 确认,直接生成(推荐)
|
||||
description: Trust auto-recommendation and proceed immediately
|
||||
- label: 2. 🎛️ 自定义调整
|
||||
description: Modify strategy/style/layout/count in one step
|
||||
- label: 3. 📋 详细模式
|
||||
description: Generate 3 outline variants, then choose (two confirmations)
|
||||
```
|
||||
|
||||
## Path B — Customize (Option 2)
|
||||
|
||||
Batch these five questions. Leaving a field blank keeps the recommended value.
|
||||
|
||||
```yaml
|
||||
header: Style/Strategy
|
||||
question: "Strategy + style. Current: {strategy} + {style}"
|
||||
hint: |
|
||||
Strategies: A Story-Driven (warm) | B Information-Dense (notion) | C Visual-First (screen-print)
|
||||
Styles: cute / fresh / warm / bold / minimal / retro / pop / notion / chalkboard / study-notes / screen-print / sketch-notes
|
||||
Presets: knowledge-card / checklist / tutorial / poster / hand-drawn-edu / ...
|
||||
```
|
||||
|
||||
```yaml
|
||||
header: Layout
|
||||
question: "Layout. Current: {layout}"
|
||||
options: [sparse, balanced, dense, list, comparison, flow, mindmap, quadrant]
|
||||
```
|
||||
|
||||
```yaml
|
||||
header: Palette
|
||||
question: "Palette. Current: {palette or 默认}"
|
||||
options: [默认, macaron, warm, neon]
|
||||
```
|
||||
|
||||
```yaml
|
||||
header: Count
|
||||
question: "Image count. Current: {N}"
|
||||
hint: Range 2-10
|
||||
```
|
||||
|
||||
```yaml
|
||||
header: Notes
|
||||
question: Optional notes (selling-point emphasis, audience adjustment, color preference)
|
||||
optional: true
|
||||
```
|
||||
|
||||
## Path C — Detailed Mode
|
||||
|
||||
### Step 2a: Content Understanding
|
||||
|
||||
Batch these questions.
|
||||
|
||||
```yaml
|
||||
header: SellingPoints
|
||||
question: Core selling points (pick all that apply)
|
||||
multiSelect: true
|
||||
```
|
||||
|
||||
```yaml
|
||||
header: Audience
|
||||
question: Target audience
|
||||
```
|
||||
|
||||
```yaml
|
||||
header: Tone
|
||||
question: Style preference
|
||||
options:
|
||||
- label: Authentic sharing
|
||||
- label: Professional review
|
||||
- label: Aesthetic mood
|
||||
- label: Auto
|
||||
```
|
||||
|
||||
```yaml
|
||||
header: Context
|
||||
question: Additional context (optional)
|
||||
optional: true
|
||||
```
|
||||
|
||||
### Step 2c: Outline & Style Selection
|
||||
|
||||
Batch these three questions.
|
||||
|
||||
```yaml
|
||||
header: Strategy
|
||||
question: Which outline strategy?
|
||||
options:
|
||||
- label: A — Story-Driven
|
||||
- label: B — Information-Dense
|
||||
- label: C — Visual-First
|
||||
- label: Combine (specify pages from each)
|
||||
```
|
||||
|
||||
```yaml
|
||||
header: Style
|
||||
question: Visual style?
|
||||
options:
|
||||
- label: Use recommended
|
||||
- label: Select preset
|
||||
- label: Select style directly
|
||||
- label: Custom description
|
||||
```
|
||||
|
||||
```yaml
|
||||
header: Elements
|
||||
question: Visual elements?
|
||||
options:
|
||||
- label: Use defaults (Recommended)
|
||||
- label: Adjust background
|
||||
- label: Adjust decorations
|
||||
- label: Custom
|
||||
```
|
||||
|
||||
## Outline Variant Frontmatter
|
||||
|
||||
Used by Path C when writing the three `outline-strategy-{a,b,c}.md` files. Each variant MUST have a different structure AND a different recommended style — include `style_reason` explaining why the style fits the strategy.
|
||||
|
||||
```yaml
|
||||
---
|
||||
strategy: a # a | b | c
|
||||
name: Story-Driven
|
||||
style: warm # recommended style for this strategy
|
||||
palette: ~ # optional: macaron | warm | neon | ~ (style default)
|
||||
style_reason: "Warm tones enhance emotional storytelling and personal connection"
|
||||
elements:
|
||||
background: solid-pastel
|
||||
decorations: [clouds, stars-sparkles]
|
||||
emphasis: star-burst
|
||||
typography: highlight
|
||||
layout: balanced
|
||||
image_count: 5
|
||||
---
|
||||
|
||||
## P1 Cover
|
||||
**Type**: cover
|
||||
**Hook**: "入冬后脸不干了🥹终于找到对的面霜"
|
||||
**Visual**: Product hero shot with cozy winter atmosphere
|
||||
**Layout**: sparse
|
||||
|
||||
## P2 Problem
|
||||
**Type**: pain-point
|
||||
...
|
||||
```
|
||||
|
||||
Page-count heuristic: strategy A typically 4-6 pages, B typically 3-5, C typically 3-4.
|
||||
@@ -1,122 +0,0 @@
|
||||
# Canvas & Layout
|
||||
|
||||
Core canvas specifications and layout grids for Xiaohongshu infographics.
|
||||
|
||||
## Aspect Ratios
|
||||
|
||||
| Name | Ratio | Pixels | Note |
|
||||
|------|-------|--------|------|
|
||||
| portrait-3-4 | 3:4 | 1242×1660 | Highest traffic on XHS (recommended) |
|
||||
| square | 1:1 | 1242×1242 | Second recommended |
|
||||
| portrait-2-3 | 2:3 | 1242×1863 | Taller format |
|
||||
|
||||
**Default**: portrait-3-4 for maximum engagement.
|
||||
|
||||
## Safe Zones
|
||||
|
||||
Avoid placing critical content in these areas:
|
||||
|
||||
| Zone | Position | Reason |
|
||||
|------|----------|--------|
|
||||
| bottom-overlay | Bottom 10% | Title bar overlay on mobile |
|
||||
| top-right | Top-right corner | Like/share button overlay |
|
||||
| bottom-right | Bottom-right corner | Watermark position |
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ [like/share]│ ← top-right: avoid
|
||||
│ │
|
||||
│ │
|
||||
│ ✓ SAFE CONTENT AREA │
|
||||
│ │
|
||||
│ │
|
||||
│ [title bar overlay area] │ ← bottom 10%: avoid key info
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
## Grid Layouts
|
||||
|
||||
### Density-Based Layouts
|
||||
|
||||
| Layout | Info Density | Whitespace | Points/Image | Best For |
|
||||
|--------|--------------|------------|--------------|----------|
|
||||
| sparse | Low | 60-70% | 1-2 | Covers, quotes, impactful statements |
|
||||
| balanced | Medium | 40-50% | 3-4 | Standard content, tutorials |
|
||||
| dense | High | 20-30% | 5-8 | Knowledge cards, cheat sheets |
|
||||
|
||||
### Structure-Based Layouts
|
||||
|
||||
| Layout | Structure | Items | Best For |
|
||||
|--------|-----------|-------|----------|
|
||||
| list | Vertical enumeration | 4-7 | Rankings, checklists, step guides |
|
||||
| comparison | Left vs Right | 2 sections | Before/after, pros/cons |
|
||||
| flow | Connected nodes | 3-6 steps | Processes, timelines, workflows |
|
||||
| mindmap | Center radial | 4-8 branches | Concept maps, brainstorming, topic overview |
|
||||
| quadrant | 4-section grid | 4 sections | SWOT analysis, priority matrix, classification |
|
||||
|
||||
## Layout by Position
|
||||
|
||||
| Position | Recommended Layout | Why |
|
||||
|----------|-------------------|-----|
|
||||
| Cover | sparse | Maximum visual impact, clear title |
|
||||
| Setup | balanced | Context without overwhelming |
|
||||
| Core | balanced/dense/list | Based on content density |
|
||||
| Payoff | balanced/list | Clear takeaways |
|
||||
| Ending | sparse | Clean CTA, memorable close |
|
||||
|
||||
## Grid Cells
|
||||
|
||||
For multi-element compositions:
|
||||
|
||||
| Name | Cells | Use Case |
|
||||
|------|-------|----------|
|
||||
| single | 1 | Hero image, maximum impact |
|
||||
| dual | 2 | Before/after, comparison |
|
||||
| triptych | 3 | Steps, process flow |
|
||||
| quad | 4 | Product showcase |
|
||||
| six-grid | 6 | Checklist, collection |
|
||||
| nine-grid | 9 | Multi-image gallery |
|
||||
|
||||
## Visual Balance
|
||||
|
||||
### Sparse Layout
|
||||
- Single focal point centered
|
||||
- Breathing room on all sides
|
||||
- Symmetrical composition
|
||||
|
||||
### Balanced Layout
|
||||
- Top-weighted title
|
||||
- Evenly distributed content below
|
||||
- Clear visual hierarchy
|
||||
|
||||
### Dense Layout
|
||||
- Organized grid structure
|
||||
- Clear section boundaries
|
||||
- Compact but readable spacing
|
||||
|
||||
### List Layout
|
||||
- Left-aligned items
|
||||
- Clear number/bullet hierarchy
|
||||
- Consistent item format
|
||||
|
||||
### Comparison Layout
|
||||
- Symmetrical left/right
|
||||
- Clear visual contrast
|
||||
- Divider between sections
|
||||
|
||||
### Flow Layout
|
||||
- Directional flow (top→bottom or left→right)
|
||||
- Connected nodes with arrows
|
||||
- Clear progression indicators
|
||||
|
||||
### Mindmap Layout
|
||||
- Central topic node
|
||||
- Radial branches outward
|
||||
- Hierarchical sub-branches
|
||||
- Organic curved connections
|
||||
|
||||
### Quadrant Layout
|
||||
- 4-section grid (2×2)
|
||||
- Clear axis labels
|
||||
- Each quadrant with distinct content
|
||||
- Optional circular variant for cycles
|
||||
@@ -1,152 +0,0 @@
|
||||
# Decorative Assets
|
||||
|
||||
Visual embellishments and decorative elements for Xiaohongshu infographics.
|
||||
|
||||
## Emphasis Marks (强调标记)
|
||||
|
||||
Elements to draw attention to specific content.
|
||||
|
||||
| Name | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| red-arrow | Red arrow pointing to target | Product features, key points |
|
||||
| circle-mark | Circle highlight annotation | Highlighting details |
|
||||
| underline | Straight or wavy underline | Text emphasis |
|
||||
| star-burst | Starburst explosion effect | Special offers, wow factor |
|
||||
| checkmark | Checkmark/tick symbol | Completed items, pros |
|
||||
| cross-mark | X mark symbol | Cons, things to avoid |
|
||||
| exclamation | Exclamation point decoration | Important warnings |
|
||||
| question | Question mark decoration | FAQ, curiosity |
|
||||
| numbering | Circled numbers | Steps, rankings |
|
||||
| bracket | Bracket highlighting | Grouping, emphasis |
|
||||
|
||||
## Backgrounds (背景)
|
||||
|
||||
Base layer treatments.
|
||||
|
||||
| Name | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| solid-saturated | High-saturation solid color | Bold, energetic |
|
||||
| solid-pastel | Soft pastel solid color | Cute, gentle |
|
||||
| gradient-linear | Linear color gradient | Modern, dynamic |
|
||||
| gradient-radial | Radial color gradient | Spotlight effect |
|
||||
| frosted-glass | Frosted glass blur effect | Layered compositions |
|
||||
| paper-texture | Paper or craft texture | Handmade aesthetic |
|
||||
| fabric-texture | Fabric/cloth texture | Cozy, tactile |
|
||||
| chalkboard | Blackboard texture | Educational content |
|
||||
| grid | Subtle grid pattern | Structured, organized |
|
||||
| dots | Polka dot pattern | Playful, retro |
|
||||
|
||||
## Doodles & Emoji (涂鸦)
|
||||
|
||||
Hand-drawn decorative elements.
|
||||
|
||||
| Name | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| hand-drawn-lines | Sketchy hand-drawn lines | Connections, borders |
|
||||
| stars-sparkles | Stars and sparkle effects | Magic, excellence |
|
||||
| flowers | Floral decorations | Beauty, feminine |
|
||||
| hearts | Heart symbols | Love, favorites |
|
||||
| clouds | Cloud shapes | Dreamy, thoughts |
|
||||
| arrows-curvy | Curved directional arrows | Flow, direction |
|
||||
| squiggles | Wavy squiggle lines | Energy, movement |
|
||||
| confetti | Scattered confetti | Celebration |
|
||||
| leaves | Leaf decorations | Nature, fresh |
|
||||
| bubbles | Circular bubble shapes | Playful, light |
|
||||
|
||||
## Emoji Integration
|
||||
|
||||
| Category | Examples | Use Case |
|
||||
|----------|----------|----------|
|
||||
| Reactions | 🥹 😍 🤯 | Emotional emphasis |
|
||||
| Objects | ✨ 💡 🎯 | Visual markers |
|
||||
| Actions | 👇 👆 ➡️ | Directional cues |
|
||||
| Nature | 🌸 🌿 ☀️ | Thematic decoration |
|
||||
|
||||
## Frames (边框)
|
||||
|
||||
Container and border treatments.
|
||||
|
||||
| Name | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| polaroid | Instant photo frame | Photo showcase |
|
||||
| film-strip | Film negative border | Cinematic, retro |
|
||||
| phone-screenshot | Mobile device mockup | App/screen content |
|
||||
| torn-paper | Torn paper edge effect | Scrapbook aesthetic |
|
||||
| rounded-rect | Rounded rectangle border | Clean containers |
|
||||
| decorative | Ornate decorative border | Premium, elegant |
|
||||
| tape-corners | Washi tape corners | Crafty, casual |
|
||||
| stamp-border | Stamp perforated edge | Vintage, postal |
|
||||
|
||||
## Dividers (分隔线)
|
||||
|
||||
Section separators.
|
||||
|
||||
| Name | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| line-simple | Simple horizontal line | Clean separation |
|
||||
| line-dashed | Dashed line | Subtle division |
|
||||
| line-wavy | Wavy line | Playful separation |
|
||||
| dots-row | Row of dots | Decorative division |
|
||||
| ornamental | Decorative flourish | Elegant separation |
|
||||
|
||||
## Stickers (贴纸)
|
||||
|
||||
Pre-composed decorative elements.
|
||||
|
||||
| Name | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| badge-new | "NEW" badge | New products |
|
||||
| badge-hot | "HOT" badge | Trending items |
|
||||
| badge-sale | Sale/discount badge | Promotions |
|
||||
| seal-quality | Quality seal | Recommendations |
|
||||
| ribbon-award | Award ribbon | Best picks |
|
||||
| tag-price | Price tag shape | Pricing info |
|
||||
|
||||
## Style-Specific Decorations
|
||||
|
||||
### Cute Style
|
||||
- Hearts, stars, sparkles
|
||||
- Ribbon decorations, sticker-style
|
||||
- Cute character elements
|
||||
|
||||
### Notion Style
|
||||
- Simple line doodles
|
||||
- Geometric shapes, stick figures
|
||||
- Maximum whitespace, minimal decoration
|
||||
|
||||
### Warm Style
|
||||
- Sun rays, coffee cups, cozy items
|
||||
- Warm lighting effects
|
||||
- Friendly, inviting decorations
|
||||
|
||||
### Fresh Style
|
||||
- Plant leaves, clouds, water drops
|
||||
- Simple geometric shapes
|
||||
- Open, breathing composition
|
||||
|
||||
### Bold Style
|
||||
- Exclamation marks, arrows
|
||||
- Warning icons, strong shapes
|
||||
- High contrast elements
|
||||
|
||||
### Pop Style
|
||||
- Bold shapes, speech bubbles
|
||||
- Comic-style effects, starburst
|
||||
- Dynamic, energetic decorations
|
||||
|
||||
### Retro Style
|
||||
- Halftone dots, vintage badges
|
||||
- Classic icons, tape effects
|
||||
- Aged texture overlays
|
||||
|
||||
### Chalkboard Style
|
||||
- Chalk dust effects
|
||||
- Hand-drawn doodles
|
||||
- Mathematical formulas, simple icons
|
||||
|
||||
### Screen-Print Style
|
||||
- Bold silhouettes, geometric shapes
|
||||
- Halftone dot patterns, print grain
|
||||
- No doodles — negative space does the work
|
||||
- Stencil-cut edges, color block boundaries
|
||||
- Vintage poster border treatments
|
||||
@@ -1,92 +0,0 @@
|
||||
# Image Processing Layer
|
||||
|
||||
Visual effects applied to image elements in Xiaohongshu infographics.
|
||||
|
||||
## AI Cutout (抠图)
|
||||
|
||||
Subject extraction styles for product/figure isolation.
|
||||
|
||||
| Name | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| clean | Sharp edges, precise boundaries | Product photography, tech items |
|
||||
| soft | Soft transition, feathered edges | Portrait cutout, organic subjects |
|
||||
| stylized | Hand-drawn edge treatment | Artistic compositions |
|
||||
|
||||
## Stroke Effects (描边)
|
||||
|
||||
Border treatments for cutout elements.
|
||||
|
||||
| Name | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| white-solid | White solid line border | Classic sticker feel, high contrast |
|
||||
| colored-solid | Colored solid line border | Playful vibe, brand colors |
|
||||
| dashed | Dashed/dotted border | Handmade aesthetic, casual |
|
||||
| double | Double-layer stroke | Emphasis effect, premium feel |
|
||||
| glow | Soft outer glow | Dreamy, soft aesthetic |
|
||||
| shadow | Drop shadow effect | Depth, floating element |
|
||||
|
||||
**Stroke Width Guidelines**:
|
||||
- Thin: 2-4px - Subtle, elegant
|
||||
- Medium: 5-8px - Standard visibility
|
||||
- Thick: 10-15px - Bold emphasis
|
||||
|
||||
## Filters (滤镜)
|
||||
|
||||
Color grading and mood presets popular on XHS.
|
||||
|
||||
| Name | Chinese | Description | Mood |
|
||||
|------|---------|-------------|------|
|
||||
| clear-glow | 清透感 | Transparent, radiant, luminous | Fresh, youthful |
|
||||
| film-grain | 胶片感 | Vintage film aesthetic, grain texture | Nostalgic, artistic |
|
||||
| cream-skin | 奶油肌 | Smooth, creamy complexion tones | Soft, flattering |
|
||||
| japanese-magazine | 日杂感 | Lifestyle magazine aesthetic | Curated, aspirational |
|
||||
| high-saturation | 高饱和 | Vibrant, punchy colors | Energetic, eye-catching |
|
||||
| muted-tones | 莫兰迪 | Morandi-style desaturated palette | Sophisticated, calm |
|
||||
| warm-tone | 暖色调 | Golden hour warmth | Cozy, inviting |
|
||||
| cool-tone | 冷色调 | Blue-shifted coolness | Modern, clean |
|
||||
|
||||
## Texture Overlays
|
||||
|
||||
Additional texture effects.
|
||||
|
||||
| Name | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| paper | Paper or fabric texture | Handmade feel |
|
||||
| noise | Fine grain noise | Analog aesthetic |
|
||||
| halftone | Dot pattern | Retro print style |
|
||||
| scratch | Light scratch marks | Vintage wear |
|
||||
|
||||
## Blending Modes
|
||||
|
||||
For layered compositions.
|
||||
|
||||
| Mode | Effect | Use Case |
|
||||
|------|--------|----------|
|
||||
| multiply | Darken, merge | Shadow effects |
|
||||
| screen | Lighten, glow | Light effects |
|
||||
| overlay | Contrast boost | Vibrant compositions |
|
||||
| soft-light | Subtle blending | Natural layering |
|
||||
|
||||
## Effect Combinations
|
||||
|
||||
Common effect stacks for different styles:
|
||||
|
||||
### Cute Style
|
||||
- Filter: clear-glow or cream-skin
|
||||
- Stroke: white-solid (medium)
|
||||
- Texture: none
|
||||
|
||||
### Notion Style
|
||||
- Filter: none or muted-tones
|
||||
- Stroke: white-solid (thin) or none
|
||||
- Texture: paper (subtle)
|
||||
|
||||
### Retro Style
|
||||
- Filter: film-grain
|
||||
- Stroke: double or dashed
|
||||
- Texture: halftone, scratch
|
||||
|
||||
### Bold Style
|
||||
- Filter: high-saturation
|
||||
- Stroke: colored-solid (thick)
|
||||
- Texture: none
|
||||
@@ -1,96 +0,0 @@
|
||||
# Typography System
|
||||
|
||||
Text styling elements for Xiaohongshu infographics.
|
||||
|
||||
## Decorated Text (花字)
|
||||
|
||||
Stylized text treatments for emphasis and visual appeal.
|
||||
|
||||
| Name | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| gradient | Gradient color fill | Title emphasis, modern feel |
|
||||
| stroke-text | Outlined text with stroke | Cover headlines, high visibility |
|
||||
| shadow-3d | 3D shadow/extrusion effect | Key terms, depth |
|
||||
| highlight | Highlighter marker effect | Critical information, key points |
|
||||
| neon | Neon glow effect | Tech content, night aesthetic |
|
||||
| handwritten | Authentic handwritten style | Personal touch, casual |
|
||||
| bubble | Rounded, inflated letterforms | Cute, playful content |
|
||||
| brush | Brush stroke texture | Artistic, dynamic |
|
||||
|
||||
## Tags & Labels (标签)
|
||||
|
||||
Structured text containers.
|
||||
|
||||
| Name | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| black-white | Black background, white text | Brand names, prices, categories |
|
||||
| white-black | White background, black text | Clean labels, minimal style |
|
||||
| bubble | Speech bubble style | Dialogue, annotations, callouts |
|
||||
| pointer | Arrow pointer with label | Product callouts, pointing to features |
|
||||
| ribbon | Ribbon/banner shape | Special offers, highlights |
|
||||
| stamp | Stamp/seal style | Authenticity, recommendations |
|
||||
| pill | Rounded pill shape | Tags, categories, keywords |
|
||||
|
||||
## Text Hierarchy
|
||||
|
||||
Recommended text sizing for visual hierarchy.
|
||||
|
||||
| Level | Role | Relative Size | Style |
|
||||
|-------|------|---------------|-------|
|
||||
| H1 | Main title | 100% | Bold, decorated |
|
||||
| H2 | Section header | 70-80% | Semi-bold |
|
||||
| H3 | Subsection | 50-60% | Medium weight |
|
||||
| Body | Content text | 40-50% | Regular |
|
||||
| Caption | Small notes | 30-35% | Light |
|
||||
|
||||
## Text Direction
|
||||
|
||||
| Direction | Description | Use Case |
|
||||
|-----------|-------------|----------|
|
||||
| horizontal | Standard left-to-right | Default for most content |
|
||||
| vertical | Top-to-bottom columns | Magazine style, traditional Chinese |
|
||||
| curved | Text following a curve | Decorative, around shapes |
|
||||
| diagonal | Angled text | Dynamic compositions |
|
||||
|
||||
## Text Effects
|
||||
|
||||
| Effect | Description | Use Case |
|
||||
|--------|-------------|----------|
|
||||
| shadow | Drop shadow behind text | Readability on busy backgrounds |
|
||||
| outline | Outline around letterforms | High contrast visibility |
|
||||
| glow | Soft glow around text | Dreamy, emphasis |
|
||||
| underline-wavy | Wavy underline decoration | Playful emphasis |
|
||||
| strikethrough | Crossed out text | Before/after, corrections |
|
||||
|
||||
## Language Considerations
|
||||
|
||||
### Chinese Text (中文)
|
||||
- Punctuation: 「」()、。!?
|
||||
- Spacing: No spaces between characters
|
||||
- Line height: 1.5-1.8x for readability
|
||||
|
||||
### Mixed Text
|
||||
- English in Chinese context: Maintain consistent baseline
|
||||
- Numbers: Use consistent number style (lining vs old-style)
|
||||
|
||||
## Style-Specific Typography
|
||||
|
||||
### Cute Style
|
||||
- Rounded, bubbly hand lettering
|
||||
- Soft shadows, playful decorations
|
||||
- Pink/pastel color accents
|
||||
|
||||
### Notion Style
|
||||
- Clean hand-drawn lettering
|
||||
- Simple sans-serif labels
|
||||
- Minimal decoration
|
||||
|
||||
### Bold Style
|
||||
- Impactful hand lettering with shadows
|
||||
- High contrast colors
|
||||
- Strong outlines
|
||||
|
||||
### Chalkboard Style
|
||||
- Chalk texture on all text
|
||||
- Visible imperfections
|
||||
- Multi-color chalk variety
|
||||
@@ -1,33 +0,0 @@
|
||||
# Macaron Palette
|
||||
|
||||
Soft pastel color blocks on warm cream background. Gentle, approachable, educational feel.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Warm cream (#F5F0E8)
|
||||
- Texture: Subtle paper grain, warm tone
|
||||
|
||||
## Colors
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Warm Cream | #F5F0E8 | Primary background |
|
||||
| Text | Deep Charcoal | #2C3E50 | Titles, main content |
|
||||
| Secondary Text | Warm Gray | #6B6B6B | Annotations, labels |
|
||||
| Block Color | Macaron Blue | #A8D8EA | Content block fill |
|
||||
| Block Color | Macaron Lavender | #D5C6E0 | Content block fill |
|
||||
| Block Color | Macaron Mint | #B5E5CF | Content block fill |
|
||||
| Block Color | Macaron Peach | #F8D5C4 | Content block fill |
|
||||
| Accent | Coral Red | #E8655A | Emphasis, highlights |
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Soft pastel macaron color palette. Use block colors as rounded card backgrounds for distinct information sections. Accent coral red sparingly for emphasis on key terms only. All colors should feel gentle and approachable — no saturated or neon tones. Do NOT render color names or role labels as visible text in the image.
|
||||
|
||||
## Best Paired With
|
||||
|
||||
- `sketch-notes` — natural pairing for hand-drawn educational content
|
||||
- `notion` — macaron accents soften the monochrome aesthetic
|
||||
- `chalkboard` — pastel chalk tones replace standard chalk colors
|
||||
- `warm` — reinforces the cozy, friendly feel
|
||||
- `fresh` — complements the clean, natural aesthetic
|
||||
@@ -1,32 +0,0 @@
|
||||
# Neon Palette
|
||||
|
||||
Vibrant neon colors on dark background. High-energy, futuristic, eye-catching.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Dark Purple (#1A1025)
|
||||
- Texture: Smooth, deep
|
||||
|
||||
## Colors
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Dark Purple | #1A1025 | Primary background |
|
||||
| Text | Bright White | #F0F0F0 | Titles, main content |
|
||||
| Secondary Text | Light Lavender | #B8B8D4 | Annotations, labels |
|
||||
| Block Color | Neon Cyan | #00F5FF | Content block fill |
|
||||
| Block Color | Neon Magenta | #FF00FF | Content block fill |
|
||||
| Block Color | Neon Green | #39FF14 | Content block fill |
|
||||
| Block Color | Neon Pink | #FF6EC7 | Content block fill |
|
||||
| Accent | Electric Yellow | #FFFF00 | Emphasis, highlights |
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Vibrant neon color palette on dark background. Colors should glow against the dark base. High contrast, futuristic feel. Use neon sparingly — too many glowing elements become chaotic. Let dark background breathe. Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best Paired With
|
||||
|
||||
- `bold` — amplifies high-impact energy
|
||||
- `pop` — neon takes the vibrancy further
|
||||
- `minimal` — neon accents on dark create striking contrast
|
||||
- `notion` — futuristic knowledge card aesthetic
|
||||
@@ -1,32 +0,0 @@
|
||||
# Warm Palette
|
||||
|
||||
Warm earth tones on soft peach background. Cozy, inviting, no cool colors.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Soft Peach (#FFECD2)
|
||||
- Texture: Warm, slightly textured
|
||||
|
||||
## Colors
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Soft Peach | #FFECD2 | Primary background |
|
||||
| Text | Deep Brown | #744210 | Titles, main content |
|
||||
| Secondary Text | Warm Brown | #9C6644 | Annotations, labels |
|
||||
| Block Color | Warm Orange | #ED8936 | Content block fill |
|
||||
| Block Color | Terracotta | #C05621 | Content block fill |
|
||||
| Block Color | Golden Yellow | #F6AD55 | Content block fill |
|
||||
| Block Color | Dusty Rose | #D4A09A | Content block fill |
|
||||
| Accent | Burnt Sienna | #A0522D | Emphasis, highlights |
|
||||
|
||||
## Semantic Constraint
|
||||
|
||||
Warm-only color palette, no cool colors (no blue, green, purple). Earth tones throughout. Evokes comfort, warmth, and trust. All colors should feel like autumn sunlight. Do NOT render color names, hex codes, or role labels as visible text in the image.
|
||||
|
||||
## Best Paired With
|
||||
|
||||
- `warm` — natural pairing, amplifies cozy feel
|
||||
- `cute` — warm pastels enhance the sweet aesthetic
|
||||
- `retro` — earth tones complement vintage style
|
||||
- `sketch-notes` — warm educational feel
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
name: bold
|
||||
category: impact
|
||||
---
|
||||
|
||||
# Bold Style
|
||||
|
||||
High impact, attention-grabbing aesthetic.
|
||||
|
||||
## Element Combination
|
||||
|
||||
```yaml
|
||||
canvas:
|
||||
ratio: portrait-3-4
|
||||
grid: single | dual
|
||||
|
||||
image_effects:
|
||||
cutout: clean
|
||||
stroke: colored-solid | double
|
||||
filter: high-saturation
|
||||
|
||||
typography:
|
||||
decorated: shadow-3d | stroke-text
|
||||
tags: black-white | ribbon
|
||||
direction: horizontal | diagonal
|
||||
|
||||
decorations:
|
||||
emphasis: exclamation | star-burst | red-arrow
|
||||
background: solid-saturated | gradient-linear
|
||||
doodles: arrows-curvy | squiggles
|
||||
frames: none
|
||||
```
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Colors | Hex |
|
||||
|------|--------|-----|
|
||||
| Primary | Vibrant red, orange, yellow | #E53E3E, #DD6B20, #F6E05E |
|
||||
| Background | Deep black, dark charcoal | #000000, #1A1A1A |
|
||||
| Accents | White, neon yellow | #FFFFFF, #F7FF00 |
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Exclamation marks, arrows, warning icons
|
||||
- Strong shapes, high contrast elements
|
||||
- Dramatic compositions
|
||||
- Bold geometric forms
|
||||
|
||||
## Typography
|
||||
|
||||
- Bold, impactful hand lettering with shadows
|
||||
- High contrast text treatments
|
||||
- Large, commanding headlines
|
||||
|
||||
## Best Layout Pairings
|
||||
|
||||
| Layout | Compatibility | Use Case |
|
||||
|--------|---------------|----------|
|
||||
| sparse | ✓✓ | Impactful statements |
|
||||
| balanced | ✓ | Warning content |
|
||||
| dense | ✓ | Critical information cards |
|
||||
| list | ✓✓ | Must-know lists, rankings |
|
||||
| comparison | ✓✓ | Dramatic contrasts |
|
||||
| flow | ✓ | Critical process steps |
|
||||
|
||||
## Best For
|
||||
|
||||
- Important tips and warnings
|
||||
- Must-know content
|
||||
- Critical announcements
|
||||
- Rankings and comparisons
|
||||
- Attention-grabbing hooks
|
||||
@@ -1,97 +0,0 @@
|
||||
---
|
||||
name: chalkboard
|
||||
category: educational
|
||||
---
|
||||
|
||||
# Chalkboard Style
|
||||
|
||||
Black chalkboard background with colorful chalk drawing aesthetic.
|
||||
|
||||
## Element Combination
|
||||
|
||||
```yaml
|
||||
canvas:
|
||||
ratio: portrait-3-4
|
||||
grid: single | dual | triptych
|
||||
|
||||
image_effects:
|
||||
cutout: stylized
|
||||
stroke: none
|
||||
filter: none
|
||||
|
||||
typography:
|
||||
decorated: handwritten
|
||||
tags: none
|
||||
direction: horizontal | vertical
|
||||
|
||||
decorations:
|
||||
emphasis: underline | circle-mark | arrows-curvy
|
||||
background: chalkboard
|
||||
doodles: hand-drawn-lines | stars-sparkles
|
||||
frames: none
|
||||
```
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Colors | Hex |
|
||||
|------|--------|-----|
|
||||
| Background | Chalkboard black, green-black | #1A1A1A, #1C2B1C |
|
||||
| Primary Text | Chalk white | #F5F5F5 |
|
||||
| Accent 1 | Chalk yellow | #FFE566 |
|
||||
| Accent 2 | Chalk pink | #FF9999 |
|
||||
| Accent 3 | Chalk blue | #66B3FF |
|
||||
| Accent 4 | Chalk green | #90EE90 |
|
||||
| Accent 5 | Chalk orange | #FFB366 |
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Hand-drawn chalk illustrations with sketchy, imperfect lines
|
||||
- Chalk dust effects around text and key elements
|
||||
- Doodles: stars, arrows, underlines, circles, checkmarks
|
||||
- Mathematical formulas and simple diagrams
|
||||
- Eraser smudges and chalk residue textures
|
||||
- Stick figures and simple icons
|
||||
- Connection lines with hand-drawn feel
|
||||
|
||||
## Typography
|
||||
|
||||
- Hand-drawn chalk lettering style
|
||||
- Visible chalk texture on all text
|
||||
- Imperfect baseline adds authenticity
|
||||
- White or bright colored chalk for emphasis
|
||||
|
||||
## Style Rules
|
||||
|
||||
### Do
|
||||
- Maintain authentic chalk texture on all elements
|
||||
- Use imperfect, hand-drawn quality throughout
|
||||
- Add subtle chalk dust and smudge effects
|
||||
- Create visual hierarchy with color variety
|
||||
- Include playful doodles and annotations
|
||||
|
||||
### Don't
|
||||
- Use perfect geometric shapes
|
||||
- Create clean digital-looking lines
|
||||
- Add photorealistic elements
|
||||
- Use gradients or glossy effects
|
||||
|
||||
## Best Layout Pairings
|
||||
|
||||
| Layout | Compatibility | Use Case |
|
||||
|--------|---------------|----------|
|
||||
| sparse | ✓✓ | Educational covers |
|
||||
| balanced | ✓✓ | Standard lessons |
|
||||
| dense | ✓✓ | Detailed tutorials |
|
||||
| list | ✓✓ | Learning checklists |
|
||||
| comparison | ✓ | Concept comparisons |
|
||||
| flow | ✓✓ | Process explanations |
|
||||
|
||||
## Best For
|
||||
|
||||
- Educational content
|
||||
- Tutorials and how-to's
|
||||
- Classroom themes
|
||||
- Teaching materials
|
||||
- Workshops
|
||||
- Informal learning sessions
|
||||
- Knowledge sharing
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
name: cute
|
||||
category: sweet
|
||||
---
|
||||
|
||||
# Cute Style
|
||||
|
||||
Sweet, adorable, girly - classic Xiaohongshu aesthetic.
|
||||
|
||||
## Element Combination
|
||||
|
||||
```yaml
|
||||
canvas:
|
||||
ratio: portrait-3-4
|
||||
grid: single | dual | quad
|
||||
|
||||
image_effects:
|
||||
cutout: soft
|
||||
stroke: white-solid | colored-solid
|
||||
filter: clear-glow | cream-skin
|
||||
|
||||
typography:
|
||||
decorated: bubble | highlight
|
||||
tags: pill | bubble
|
||||
direction: horizontal
|
||||
|
||||
decorations:
|
||||
emphasis: star-burst | hearts
|
||||
background: solid-pastel | gradient-linear
|
||||
doodles: hearts | stars-sparkles | flowers
|
||||
frames: polaroid | tape-corners
|
||||
```
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Colors | Hex |
|
||||
|------|--------|-----|
|
||||
| Primary | Pink, peach, mint, lavender | #FED7E2, #FEEBC8, #C6F6D5, #E9D8FD |
|
||||
| Background | Cream, soft pink | #FFFAF0, #FFF5F7 |
|
||||
| Accents | Hot pink, coral | #FF69B4, #FF6B6B |
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Hearts, stars, sparkles, cute faces
|
||||
- Ribbon decorations, sticker-style
|
||||
- Cute stickers, emoji icons
|
||||
- Soft, rounded shapes
|
||||
|
||||
## Typography
|
||||
|
||||
- Rounded, bubbly hand lettering
|
||||
- Soft shadows, playful decorations
|
||||
- Pink/pastel color accents on text
|
||||
|
||||
## Best Layout Pairings
|
||||
|
||||
| Layout | Compatibility | Use Case |
|
||||
|--------|---------------|----------|
|
||||
| sparse | ✓✓ | Covers, emotional impact |
|
||||
| balanced | ✓✓ | Standard cute content |
|
||||
| dense | ✓ | Cute knowledge cards |
|
||||
| list | ✓✓ | Checklists, cute rankings |
|
||||
| comparison | ✓ | Before/after transformations |
|
||||
| flow | ✓ | Cute step guides |
|
||||
|
||||
## Best For
|
||||
|
||||
- Lifestyle content
|
||||
- Beauty and skincare
|
||||
- Fashion and style
|
||||
- Daily tips and hacks
|
||||
- Personal shares
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
name: fresh
|
||||
category: natural
|
||||
---
|
||||
|
||||
# Fresh Style
|
||||
|
||||
Clean, refreshing, natural aesthetic.
|
||||
|
||||
## Element Combination
|
||||
|
||||
```yaml
|
||||
canvas:
|
||||
ratio: portrait-3-4
|
||||
grid: single | triptych
|
||||
|
||||
image_effects:
|
||||
cutout: soft
|
||||
stroke: white-solid | none
|
||||
filter: clear-glow | cool-tone
|
||||
|
||||
typography:
|
||||
decorated: none | highlight
|
||||
tags: pill | white-black
|
||||
direction: horizontal
|
||||
|
||||
decorations:
|
||||
emphasis: checkmark | circle-mark
|
||||
background: solid-white | solid-pastel
|
||||
doodles: leaves | clouds | bubbles
|
||||
frames: rounded-rect | none
|
||||
```
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Colors | Hex |
|
||||
|------|--------|-----|
|
||||
| Primary | Mint green, sky blue, light yellow | #9AE6B4, #90CDF4, #FAF089 |
|
||||
| Background | Pure white, soft mint | #FFFFFF, #F0FFF4 |
|
||||
| Accents | Leaf green, water blue | #48BB78, #4299E1 |
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Plant leaves, clouds, water drops
|
||||
- Simple geometric shapes
|
||||
- Breathing room, open composition
|
||||
- Natural, organic elements
|
||||
|
||||
## Typography
|
||||
|
||||
- Clean, light hand lettering with breathing room
|
||||
- Airy spacing
|
||||
- Fresh color accents
|
||||
|
||||
## Best Layout Pairings
|
||||
|
||||
| Layout | Compatibility | Use Case |
|
||||
|--------|---------------|----------|
|
||||
| sparse | ✓✓ | Clean covers |
|
||||
| balanced | ✓✓ | Standard fresh content |
|
||||
| dense | ✓ | Organized information |
|
||||
| list | ✓ | Wellness tips |
|
||||
| comparison | ✓ | Before/after health |
|
||||
| flow | ✓✓ | Organic processes |
|
||||
|
||||
## Best For
|
||||
|
||||
- Health and wellness
|
||||
- Minimalist lifestyle
|
||||
- Self-care content
|
||||
- Nature-related topics
|
||||
- Clean living tips
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user