mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-13 06:19:46 +08:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 0b3b7d13b5 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.115.2"
|
||||
"version": "1.119.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
@@ -22,6 +22,7 @@
|
||||
"./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-infographic",
|
||||
@@ -33,7 +34,8 @@
|
||||
"./skills/baoyu-translate",
|
||||
"./skills/baoyu-url-to-markdown",
|
||||
"./skills/baoyu-image-cards",
|
||||
"./skills/baoyu-youtube-transcript"
|
||||
"./skills/baoyu-youtube-transcript",
|
||||
"./skills/baoyu-wechat-summary"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: codex-imagegen tests
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'scripts/codex-imagegen/**'
|
||||
- 'scripts/codex-imagegen.sh'
|
||||
- '.github/workflows/codex-imagegen-tests.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'scripts/codex-imagegen/**'
|
||||
- 'scripts/codex-imagegen.sh'
|
||||
- '.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: scripts/codex-imagegen
|
||||
run: bun test
|
||||
|
||||
- name: Bundle smoke test (catches import/syntax errors)
|
||||
run: bun build --target=node scripts/codex-imagegen/main.ts --outfile /tmp/main-build.js
|
||||
|
||||
- name: Help output smoke test
|
||||
run: bun scripts/codex-imagegen/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/
|
||||
|
||||
+117
@@ -2,6 +2,123 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 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
|
||||
|
||||
+117
@@ -2,6 +2,123 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 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
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -66,6 +66,22 @@ Skills that prompt users for choices MUST declare the tool-selection convention
|
||||
|
||||
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.
|
||||
|
||||
### `codex-imagegen` Backend
|
||||
|
||||
A backend for non-Codex runtimes (e.g., Claude Code) that generates images by spawning `codex exec --json --sandbox danger-full-access` and delegating to Codex CLI's built-in `image_gen` tool. Uses the user's Codex subscription — no `OPENAI_API_KEY` required.
|
||||
|
||||
Invoke via:
|
||||
|
||||
```bash
|
||||
./scripts/codex-imagegen.sh \
|
||||
--image <output.png> \
|
||||
--prompt-file prompts/01-cover.md \
|
||||
--aspect 16:9 \
|
||||
--cache-dir ~/.cache/baoyu-codex-imagegen
|
||||
```
|
||||
|
||||
Stdout emits a single JSON line: `{"status":"ok","path":...,"bytes":N,...}`. On failure, `{"status":"error","error_kind":...}`. Skills route here by setting `preferred_image_backend: codex-imagegen` in EXTEND.md. Full reference: [docs/codex-imagegen-backend.md](docs/codex-imagegen-backend.md).
|
||||
|
||||
## Deprecated Skills
|
||||
|
||||
| Skill | Note |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,7 +40,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 +64,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 +80,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**
|
||||
@@ -579,7 +579,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 +612,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 +632,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
|
||||
@@ -1112,6 +1124,73 @@ 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 and per-user profiles across runs. Supports normal and roast (毒舌) versions.
|
||||
|
||||
```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
|
||||
- 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:
|
||||
|
||||
+76
-6
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](./README.md) | 中文
|
||||
|
||||
宝玉分享的 Claude Code 技能集,提升日常工作效率。
|
||||
宝玉分享的 AI Agent 技能集(适用于 Claude Code、Codex 等),提升日常工作效率。
|
||||
|
||||
## 前置要求
|
||||
|
||||
@@ -40,7 +40,7 @@ clawhub install baoyu-markdown-to-html
|
||||
|
||||
### 注册插件市场
|
||||
|
||||
在 Claude Code 中运行:
|
||||
在 Agent 中运行:
|
||||
|
||||
```bash
|
||||
/plugin marketplace add JimLiu/baoyu-skills
|
||||
@@ -64,7 +64,7 @@ clawhub install baoyu-markdown-to-html
|
||||
|
||||
**方式三:告诉 Agent**
|
||||
|
||||
直接告诉 Claude Code:
|
||||
直接告诉 Agent:
|
||||
|
||||
> 请帮我安装 github.com/JimLiu/baoyu-skills 中的 Skills
|
||||
|
||||
@@ -80,7 +80,7 @@ clawhub install baoyu-markdown-to-html
|
||||
|
||||
更新技能到最新版本:
|
||||
|
||||
1. 在 Claude Code 中运行 `/plugin`
|
||||
1. 在 Agent 中运行 `/plugin`
|
||||
2. 切换到 **Marketplaces** 标签页(使用方向键或 Tab)
|
||||
3. 选择 **baoyu-skills**
|
||||
4. 选择 **Update marketplace**
|
||||
@@ -579,7 +579,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 +612,9 @@ clawhub install baoyu-markdown-to-html
|
||||
|
||||
| 方式 | 速度 | 要求 |
|
||||
|------|------|------|
|
||||
| API(推荐) | 快 | API 凭证 |
|
||||
| API(推荐) | 快 | API 凭证(本机 IP 在公众号白名单内) |
|
||||
| 浏览器 | 慢 | Chrome,登录会话 |
|
||||
| 远程 API | 快 | API 凭证 + 一台 IP 在公众号白名单内、可 SSH 登录的服务器 |
|
||||
|
||||
**API 配置**(更快的发布方式):
|
||||
|
||||
@@ -631,6 +632,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
|
||||
@@ -1112,6 +1115,73 @@ AI 驱动的生成后端。
|
||||
- 为文化/专业术语添加译注
|
||||
- 输出目录保留所有中间文件
|
||||
|
||||
#### baoyu-wechat-summary
|
||||
|
||||
微信群聊精华提取。使用 [wx-cli](https://github.com/jackwener/wx-cli) 从群消息中提取话题、引言和统计数据,生成结构化简报。支持跨次运行的群聊历史和群友画像维护,可生成正常版和毒舌版。
|
||||
|
||||
```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` 文件中设置:
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
# `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-imagine` 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 preferred for running the wrapper. On macOS:
|
||||
|
||||
```bash
|
||||
brew install oven-sh/bun/bun
|
||||
```
|
||||
|
||||
If `bun` is missing, the shell entrypoint falls back to `npx -y bun`.
|
||||
|
||||
## Usage
|
||||
|
||||
### Direct CLI
|
||||
|
||||
```bash
|
||||
# Inline prompt
|
||||
./scripts/codex-imagegen.sh \
|
||||
--image /tmp/cat.png \
|
||||
--prompt "A friendly orange cat, watercolor"
|
||||
|
||||
# Prompt from file
|
||||
./scripts/codex-imagegen.sh \
|
||||
--image cover.png \
|
||||
--prompt-file prompts/01-cover.md \
|
||||
--aspect 16:9
|
||||
|
||||
# Verbose mode for debugging
|
||||
./scripts/codex-imagegen.sh -v --image dog.png --prompt "A corgi" --aspect 1:1
|
||||
```
|
||||
|
||||
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 `scripts/codex-imagegen.sh`.
|
||||
|
||||
> **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
|
||||
|
||||
```
|
||||
scripts/codex-imagegen.sh # thin bash entrypoint
|
||||
scripts/codex-imagegen/
|
||||
├── main.ts # parseArgs → cache → lock → retry loop → emit JSON
|
||||
├── types.ts # CliOptions, GenerateResult, GenError, ErrorKind
|
||||
├── spawn.ts # spawn codex exec --json --sandbox danger-full-access
|
||||
├── parser.ts # parse JSONL event stream → toolCalls, usage, thread_id
|
||||
├── validator.ts # verify image_gen invocation + PNG magic + file size
|
||||
├── cache.ts # cacheKey(sha256), FileLock, lookup/store
|
||||
├── logger.ts # JsonLogger (verbose stderr + JSONL file)
|
||||
├── parser.test.ts
|
||||
├── cache.test.ts
|
||||
└── validator.test.ts
|
||||
```
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
cd scripts/codex-imagegen && bun test
|
||||
```
|
||||
|
||||
## Internal Flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CC[Claude Code / any caller]
|
||||
WRAPPER[scripts/codex-imagegen.sh]
|
||||
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. **Bash entrypoint + TypeScript implementation** — the shell wrapper picks the runtime (`bun` preferred, falling back to `npx -y bun`); TypeScript handles the orchestration, parsing, retry, cache, and logging. This mirrors the project's existing `scripts/*.mjs` and `skills/<skill>/scripts/main.ts` pattern.
|
||||
2. **`--sandbox danger-full-access`** — necessary so the spawned agent can `cp`/`mv` the rendered PNG out of `$CODEX_HOME/generated_images/` to the user-specified path. Standard sandboxes block this.
|
||||
3. **Parse the JSONL event stream** — the final `agent_message` and intermediate `command_execution` events let the wrapper verify what actually happened (was `image_gen` called? did `cp` reach the right destination?), which is far more reliable than scraping freeform stdout.
|
||||
4. **Infrastructure, not a skill** — this backend is a CLI utility that skills route to via `preferred_image_backend`. It belongs in `scripts/`, not `skills/`, because it has no `SKILL.md` and is never loaded directly by an agent.
|
||||
5. **File lock instead of internal queue** — keeps the implementation small and works across multiple shell sessions or processes invoking the same wrapper concurrently.
|
||||
|
||||
## Related Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `scripts/codex-imagegen.sh` | CLI entrypoint |
|
||||
| `scripts/codex-imagegen/` | TypeScript implementation |
|
||||
| `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 |
|
||||
@@ -9,11 +9,14 @@ 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.
|
||||
- **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-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **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-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.
|
||||
|
||||
**⛔ 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@
|
||||
],
|
||||
"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",
|
||||
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# codex-imagegen: generate images via Codex CLI's built-in image_gen tool
|
||||
# Thin shell wrapper — implementation in codex-imagegen/main.ts (Bun TypeScript)
|
||||
#
|
||||
# Usage: ./codex-imagegen.sh --help
|
||||
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if command -v bun &>/dev/null; then
|
||||
BUN_X="bun"
|
||||
elif command -v npx &>/dev/null; then
|
||||
BUN_X="npx -y bun"
|
||||
else
|
||||
echo "Error: bun or npx required. Install: brew install oven-sh/bun/bun" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec $BUN_X "$SCRIPT_DIR/codex-imagegen/main.ts" "$@"
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { readFile, mkdir, copyFile, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { GenError, type CliOptions, type GenerateResult } from "./types.ts";
|
||||
import { runCodexExec } from "./spawn.ts";
|
||||
import { findCpToTarget, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts";
|
||||
import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts";
|
||||
import { JsonLogger } from "./logger.ts";
|
||||
|
||||
const HELP = `codex-imagegen — generate images via Codex CLI's image_gen tool
|
||||
|
||||
Usage:
|
||||
codex-imagegen --image <output.png> [--prompt <text> | --prompt-file <path>] [options]
|
||||
|
||||
Required:
|
||||
--image <path> Output PNG path
|
||||
--prompt <text> Prompt text (or use --prompt-file)
|
||||
--prompt-file <path> Read prompt from file
|
||||
|
||||
Options:
|
||||
--aspect <ratio> Aspect ratio (1:1, 16:9, 9:16, 4:3, 2.35:1). Default: 1:1
|
||||
--ref <file> Reference image (repeatable)
|
||||
--timeout <ms> Codex exec timeout in ms. Default: 300000
|
||||
--retries <n> Retry attempts on retryable errors. Default: 2
|
||||
--retry-delay <ms> Base retry delay (exponential). Default: 1500
|
||||
--cache-dir <path> Enable idempotency cache. Disabled by default.
|
||||
--log-file <path> Append JSONL log
|
||||
-v, --verbose Verbose stderr logging
|
||||
-h, --help Show this help
|
||||
|
||||
Stdout: single JSON line on success or failure.
|
||||
`;
|
||||
|
||||
const SHELL_METACHAR = /[;|&`$<>\n\r()'"]/;
|
||||
|
||||
function assertSafePath(label: string, value: string): void {
|
||||
if (SHELL_METACHAR.test(value)) {
|
||||
throw new GenError(
|
||||
"invalid_args",
|
||||
`${label} contains shell metacharacters and would be unsafe to interpolate into the codex instruction: ${value}`,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const opts: CliOptions = {
|
||||
prompt: "",
|
||||
promptFile: null,
|
||||
outputPath: "",
|
||||
aspect: "1:1",
|
||||
refImages: [],
|
||||
timeoutMs: 300_000,
|
||||
retries: 2,
|
||||
retryDelayMs: 1500,
|
||||
cacheDir: null,
|
||||
logFile: null,
|
||||
verbose: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const next = () => argv[++i];
|
||||
switch (a) {
|
||||
case "--prompt": opts.prompt = next(); break;
|
||||
case "--prompt-file": opts.promptFile = next(); break;
|
||||
case "--image": opts.outputPath = next(); break;
|
||||
case "--aspect": opts.aspect = next(); break;
|
||||
case "--ref": opts.refImages.push(next()); break;
|
||||
case "--timeout": opts.timeoutMs = Number(next()); break;
|
||||
case "--retries": opts.retries = Number(next()); break;
|
||||
case "--retry-delay": opts.retryDelayMs = Number(next()); break;
|
||||
case "--cache-dir": opts.cacheDir = next(); break;
|
||||
case "--log-file": opts.logFile = next(); break;
|
||||
case "-v":
|
||||
case "--verbose": opts.verbose = true; break;
|
||||
case "-h":
|
||||
case "--help": process.stdout.write(HELP); process.exit(0);
|
||||
default: throw new GenError("invalid_args", `Unknown argument: ${a}`, false);
|
||||
}
|
||||
}
|
||||
if (!opts.outputPath) throw new GenError("invalid_args", "--image is required", false);
|
||||
if (opts.prompt && opts.promptFile) {
|
||||
throw new GenError("invalid_args", "--prompt and --prompt-file are mutually exclusive", false);
|
||||
}
|
||||
if (!opts.prompt && !opts.promptFile) {
|
||||
throw new GenError("invalid_args", "--prompt or --prompt-file required", false);
|
||||
}
|
||||
|
||||
// Resolve every filesystem path to absolute up front, so behavior is
|
||||
// independent of the caller's cwd. This matters when the wrapper is
|
||||
// invoked from a skill running in an arbitrary working directory.
|
||||
const cwd = process.cwd();
|
||||
const toAbs = (p: string) => (path.isAbsolute(p) ? p : path.resolve(cwd, p));
|
||||
|
||||
opts.outputPath = toAbs(opts.outputPath);
|
||||
if (opts.promptFile) opts.promptFile = toAbs(opts.promptFile);
|
||||
opts.refImages = opts.refImages.map(toAbs);
|
||||
if (opts.cacheDir) opts.cacheDir = toAbs(opts.cacheDir);
|
||||
if (opts.logFile) opts.logFile = toAbs(opts.logFile);
|
||||
|
||||
// The output and ref paths are interpolated raw into the agent instruction
|
||||
// sent to `codex exec --sandbox danger-full-access`. A path containing shell
|
||||
// metacharacters could be misread by the agent's shell when it cp's the
|
||||
// result into place. Reject upfront rather than trusting the agent to quote.
|
||||
assertSafePath("--image path", opts.outputPath);
|
||||
for (const ref of opts.refImages) assertSafePath("--ref path", ref);
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function loadPrompt(opts: CliOptions): Promise<string> {
|
||||
if (opts.prompt) return opts.prompt;
|
||||
const file = opts.promptFile!;
|
||||
try {
|
||||
return await readFile(file, "utf-8");
|
||||
} catch {
|
||||
throw new GenError("prompt_file_missing", `Prompt file not found: ${file}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
function buildInstruction(prompt: string, opts: CliOptions): string {
|
||||
const refHint = opts.refImages.length > 0
|
||||
? `\nREFERENCE IMAGES (attached above): ${opts.refImages.length} image(s) provided for style/composition guidance.\n`
|
||||
: "";
|
||||
return `You have an internal tool called image_gen for image generation. Use it.
|
||||
|
||||
TASK: Generate an image with the spec below, then save to disk.
|
||||
|
||||
PROMPT:
|
||||
${prompt}
|
||||
|
||||
ASPECT RATIO: ${opts.aspect}
|
||||
OUTPUT PATH: ${opts.outputPath}
|
||||
${refHint}
|
||||
STEPS:
|
||||
1. Call image_gen with the prompt and aspect ratio above${opts.refImages.length > 0 ? " (using the attached reference images for guidance)" : ""}.
|
||||
2. Move or copy the resulting image from Codex default location ($CODEX_HOME/generated_images/...) to: ${opts.outputPath}
|
||||
3. Verify with: ls -la ${opts.outputPath}
|
||||
4. Reply with ONLY this JSON line (no markdown fences, no other text):
|
||||
{"status":"ok","path":"${opts.outputPath}","bytes":<file_size_in_bytes>}
|
||||
|
||||
HARD CONSTRAINTS:
|
||||
- Do NOT use curl, wget, Python, or any external API.
|
||||
- Do NOT use bash to fabricate an image; only image_gen produces real pixels.
|
||||
- Use ONLY the image_gen internal tool.`;
|
||||
}
|
||||
|
||||
async function attemptGenerate(
|
||||
opts: CliOptions,
|
||||
instruction: string,
|
||||
attempt: number,
|
||||
log: JsonLogger,
|
||||
): Promise<{ bytes: number; threadId: string | null; usage: any; toolCalls: any[] }> {
|
||||
await log.info("attempt.start", { attempt, output: opts.outputPath, aspect: opts.aspect });
|
||||
|
||||
const run = await runCodexExec({
|
||||
instruction,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
refImages: opts.refImages,
|
||||
});
|
||||
|
||||
await log.info("codex.completed", {
|
||||
duration_ms: run.durationMs,
|
||||
thread_id: run.threadId,
|
||||
tool_calls: run.toolCalls.length,
|
||||
usage: run.usage,
|
||||
raw_log: run.rawLogPath,
|
||||
});
|
||||
|
||||
// verify: thread id must be present
|
||||
if (!run.threadId) {
|
||||
throw new GenError("agent_refused", "No thread id in event stream");
|
||||
}
|
||||
|
||||
// verify: image_gen was actually invoked (check $CODEX_HOME/generated_images/{threadId}/)
|
||||
const ver = await verifyImageGenWasInvoked(run.threadId);
|
||||
if (!ver.ok) {
|
||||
// secondary verify: did tool_calls include cp/mv from generated_images to our target
|
||||
if (!findCpToTarget(run.toolCalls, opts.outputPath)) {
|
||||
throw new GenError("no_image_gen_tool_use", `image_gen was not invoked: ${ver.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// verify output
|
||||
const { bytes } = await verifyOutput(opts.outputPath);
|
||||
|
||||
return {
|
||||
bytes,
|
||||
threadId: run.threadId,
|
||||
usage: run.usage,
|
||||
toolCalls: run.toolCalls.map((tc) => ({ tool: tc.tool, status: tc.status })),
|
||||
};
|
||||
}
|
||||
|
||||
async function generate(opts: CliOptions, log: JsonLogger): Promise<GenerateResult> {
|
||||
const startEpoch = Date.now();
|
||||
const prompt = await loadPrompt(opts);
|
||||
|
||||
// Cache lookup
|
||||
if (opts.cacheDir) {
|
||||
const key = cacheKey(prompt, opts.aspect, opts.refImages);
|
||||
const cached = await lookupCache(opts.cacheDir, key);
|
||||
if (cached) {
|
||||
await mkdir(path.dirname(opts.outputPath), { recursive: true });
|
||||
await copyFile(cached, opts.outputPath);
|
||||
const s = await stat(opts.outputPath);
|
||||
await log.info("cache.hit", { key, source: cached });
|
||||
return {
|
||||
status: "ok",
|
||||
path: opts.outputPath,
|
||||
bytes: s.size,
|
||||
elapsed_seconds: 0,
|
||||
thread_id: null,
|
||||
attempts: 0,
|
||||
cached: true,
|
||||
usage: null,
|
||||
tool_calls: [],
|
||||
};
|
||||
}
|
||||
await log.info("cache.miss", { key });
|
||||
}
|
||||
|
||||
// lock to prevent concurrent codex exec
|
||||
const lockDir = opts.cacheDir ?? path.join(homedir(), ".cache", "baoyu-codex-imagegen");
|
||||
const lock = new FileLock(path.join(lockDir, "codex-exec.lock"));
|
||||
try {
|
||||
await lock.acquire(60_000);
|
||||
} catch (e) {
|
||||
throw new GenError("lock_busy", String(e), false);
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(opts.outputPath), { recursive: true });
|
||||
const instruction = buildInstruction(prompt, opts);
|
||||
|
||||
let lastErr: GenError | null = null;
|
||||
let lastAttempt = 0;
|
||||
try {
|
||||
for (let attempt = 1; attempt <= opts.retries + 1; attempt++) {
|
||||
lastAttempt = attempt;
|
||||
try {
|
||||
const result = await attemptGenerate(opts, instruction, attempt, log);
|
||||
|
||||
// write to cache
|
||||
if (opts.cacheDir) {
|
||||
const key = cacheKey(prompt, opts.aspect, opts.refImages);
|
||||
await storeCache(opts.cacheDir, key, opts.outputPath);
|
||||
await log.info("cache.stored", { key });
|
||||
}
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
path: opts.outputPath,
|
||||
bytes: result.bytes,
|
||||
elapsed_seconds: Math.round((Date.now() - startEpoch) / 1000),
|
||||
thread_id: result.threadId,
|
||||
attempts: attempt,
|
||||
cached: false,
|
||||
usage: result.usage,
|
||||
tool_calls: result.toolCalls,
|
||||
};
|
||||
} catch (e) {
|
||||
lastErr = e instanceof GenError ? e : new GenError("spawn_failed", String(e));
|
||||
await log.warn("attempt.failed", {
|
||||
attempt,
|
||||
kind: lastErr.kind,
|
||||
retryable: lastErr.retryable,
|
||||
error: lastErr.message,
|
||||
});
|
||||
if (!lastErr.retryable || attempt > opts.retries) break;
|
||||
const wait = opts.retryDelayMs * Math.pow(2, attempt - 1);
|
||||
await log.info("retry.wait", { wait_ms: wait, next_attempt: attempt + 1 });
|
||||
await delay(wait);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
|
||||
const err = lastErr ?? new GenError("spawn_failed", "Unknown failure");
|
||||
err.attempts = lastAttempt;
|
||||
throw err;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let opts: CliOptions;
|
||||
try {
|
||||
opts = parseArgs(process.argv.slice(2));
|
||||
} catch (e) {
|
||||
const err = e instanceof GenError ? e : new GenError("invalid_args", String(e), false);
|
||||
process.stderr.write(`Error: ${err.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const log = new JsonLogger(opts.logFile, opts.verbose);
|
||||
await log.info("start", { output: opts.outputPath, aspect: opts.aspect, refs: opts.refImages.length });
|
||||
|
||||
try {
|
||||
const result = await generate(opts, log);
|
||||
await log.info("done", { bytes: result.bytes, attempts: result.attempts, cached: result.cached });
|
||||
process.stdout.write(JSON.stringify(result) + "\n");
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
const err = e instanceof GenError ? e : new GenError("spawn_failed", String(e));
|
||||
await log.error("failed", { kind: err.kind, error: err.message, attempts: err.attempts ?? 0 });
|
||||
const out: GenerateResult = {
|
||||
status: "error",
|
||||
path: opts.outputPath,
|
||||
bytes: 0,
|
||||
elapsed_seconds: 0,
|
||||
thread_id: null,
|
||||
attempts: err.attempts ?? 0,
|
||||
cached: false,
|
||||
usage: null,
|
||||
tool_calls: [],
|
||||
error: err.message,
|
||||
error_kind: err.kind,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out) + "\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,49 @@
|
||||
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 finds shell calls touching generated_images", () => {
|
||||
const r = parseEventStream(REAL_PoC_STREAM);
|
||||
// image_gen itself is not an event; inferred via generated_images cp path
|
||||
// this test only verifies parser behavior; driver logic lives in validator
|
||||
const hasCp = r.toolCalls.some((tc) => tc.command?.includes("generated_images"));
|
||||
expect(hasCp).toBe(true);
|
||||
});
|
||||
|
||||
test("hasImageGenInvocation (proper) returns false when no image_gen tool", () => {
|
||||
expect(hasImageGenInvocation([{ id: "1", tool: "shell", status: "completed" }])).toBe(false);
|
||||
expect(
|
||||
hasImageGenInvocation([{ id: "1", tool: "image_gen", status: "completed" }]),
|
||||
).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,98 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { verifyOutput, verifyImageGenWasInvoked, findCpToTarget } from "./validator.ts";
|
||||
import { GenError } from "./types.ts";
|
||||
|
||||
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
test("verifyOutput passes for valid PNG", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cig-val-"));
|
||||
try {
|
||||
const p = path.join(dir, "good.png");
|
||||
await writeFile(p, Buffer.concat([PNG_HEADER, Buffer.alloc(5000)]));
|
||||
const r = await verifyOutput(p);
|
||||
expect(r.bytes).toBeGreaterThan(1000);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyOutput rejects missing file", async () => {
|
||||
await expect(verifyOutput("/no/such/file.png")).rejects.toBeInstanceOf(GenError);
|
||||
});
|
||||
|
||||
test("verifyOutput rejects tiny file", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cig-val-"));
|
||||
try {
|
||||
const p = path.join(dir, "tiny.png");
|
||||
await writeFile(p, "tiny");
|
||||
await expect(verifyOutput(p)).rejects.toThrow(/too small/);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyOutput rejects non-PNG magic", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cig-val-"));
|
||||
try {
|
||||
const p = path.join(dir, "fake.png");
|
||||
await writeFile(p, Buffer.concat([Buffer.from("GIF89a"), Buffer.alloc(5000)]));
|
||||
await expect(verifyOutput(p)).rejects.toThrow(/not a valid PNG/);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyImageGenWasInvoked false when no thread directory", async () => {
|
||||
const orig = process.env.CODEX_HOME;
|
||||
const tempHome = await mkdtemp(path.join(tmpdir(), "cig-home-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
try {
|
||||
const r = await verifyImageGenWasInvoked("no-such-thread");
|
||||
expect(r.ok).toBe(false);
|
||||
} finally {
|
||||
process.env.CODEX_HOME = orig;
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyImageGenWasInvoked true when PNG exists in thread dir", async () => {
|
||||
const orig = process.env.CODEX_HOME;
|
||||
const tempHome = await mkdtemp(path.join(tmpdir(), "cig-home-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
try {
|
||||
const threadDir = path.join(tempHome, "generated_images", "thread-xyz");
|
||||
await mkdir(threadDir, { recursive: true });
|
||||
await writeFile(path.join(threadDir, "ig_abc.png"), Buffer.alloc(100));
|
||||
const r = await verifyImageGenWasInvoked("thread-xyz");
|
||||
expect(r.ok).toBe(true);
|
||||
} finally {
|
||||
process.env.CODEX_HOME = orig;
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("findCpToTarget detects cp from generated_images", () => {
|
||||
expect(
|
||||
findCpToTarget(
|
||||
[
|
||||
{
|
||||
id: "1",
|
||||
tool: "shell",
|
||||
status: "completed",
|
||||
command: "cp ~/.codex/generated_images/thread/ig_x.png /tmp/out.png",
|
||||
},
|
||||
],
|
||||
"/tmp/out.png",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
findCpToTarget(
|
||||
[{ id: "1", tool: "shell", status: "completed", command: "ls /tmp" }],
|
||||
"/tmp/out.png",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { stat, readdir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { GenError } from "./types.ts";
|
||||
import type { ToolCall } from "./types.ts";
|
||||
|
||||
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
export function codexHome(): string {
|
||||
return process.env.CODEX_HOME ?? path.join(homedir(), ".codex");
|
||||
}
|
||||
|
||||
export async function verifyImageGenWasInvoked(threadId: string | null): Promise<{ ok: boolean; reason?: string }> {
|
||||
if (!threadId) return { ok: false, reason: "no thread id" };
|
||||
const dir = path.join(codexHome(), "generated_images", threadId);
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
const pngs = entries.filter((e) => e.toLowerCase().endsWith(".png"));
|
||||
if (pngs.length === 0) return { ok: false, reason: `no PNG in ${dir}` };
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: `cannot read ${dir}: ${e?.code ?? e?.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export function findCpToTarget(toolCalls: ToolCall[], target: string): boolean {
|
||||
return toolCalls.some(
|
||||
(tc) =>
|
||||
tc.tool === "shell" &&
|
||||
typeof tc.command === "string" &&
|
||||
(tc.command.includes(target) || tc.command.includes(path.basename(target))) &&
|
||||
/\b(cp|mv|cat)\b/.test(tc.command) &&
|
||||
tc.command.includes("generated_images"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function verifyOutput(outputPath: string): Promise<{ bytes: number }> {
|
||||
let s;
|
||||
try {
|
||||
s = await stat(outputPath);
|
||||
} catch {
|
||||
throw new GenError("output_missing", `Output file not created: ${outputPath}`);
|
||||
}
|
||||
if (s.size < 1000) {
|
||||
throw new GenError("invalid_png", `Output file too small (${s.size} bytes)`);
|
||||
}
|
||||
const file = Bun.file(outputPath);
|
||||
const head = new Uint8Array(await file.slice(0, 8).arrayBuffer());
|
||||
for (let i = 0; i < PNG_MAGIC.length; i++) {
|
||||
if (head[i] !== PNG_MAGIC[i]) {
|
||||
throw new GenError("invalid_png", `Output is not a valid PNG (magic mismatch)`);
|
||||
}
|
||||
}
|
||||
return { bytes: s.size };
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.59.0
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-article-illustrator
|
||||
@@ -28,17 +28,38 @@ 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.
|
||||
- **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-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **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-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.
|
||||
|
||||
**⛔ 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.
|
||||
|
||||
## 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
|
||||
|
||||
Default behavior: **confirm before generation**.
|
||||
@@ -164,7 +185,7 @@ Full template: [references/workflow.md](references/workflow.md#step-4-generate-o
|
||||
4. LABELS **MUST** include article-specific data: actual numbers, terms, metrics, quotes
|
||||
5. **DO NOT** pass ad-hoc inline prompts to `--prompt` without saving prompt files first
|
||||
6. Select the backend via the `## Image Generation Tools` rule at the top: use whatever is available; if multiple, ask the user once. Do this once per session before any generation.
|
||||
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.
|
||||
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 +235,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 |
|
||||
@@ -236,5 +263,6 @@ EXTEND.md lives at the first matching path listed in Step 1.5. Three ways to cha
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
@@ -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"
|
||||
@@ -55,6 +57,7 @@ custom_styles:
|
||||
| `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. |
|
||||
| `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
|
||||
|
||||
@@ -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.57.0
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-comic
|
||||
@@ -32,17 +32,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.
|
||||
- **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-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **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-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.
|
||||
|
||||
**⛔ 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.
|
||||
|
||||
## 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
|
||||
|
||||
Users may supply reference images to guide art style, palette, scene composition, or subject. This is **separate from** the auto-generated character sheet (Step 7.1) — both can coexist: user refs guide the look, the character sheet anchors recurring character identity.
|
||||
@@ -87,6 +109,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
|
||||
|
||||
@@ -234,6 +257,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 +279,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 +315,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
|
||||
@@ -313,4 +344,5 @@ EXTEND.md lives at `.baoyu-skills/baoyu-comic/EXTEND.md` (project) or `~/.baoyu-
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
@@ -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:
|
||||
@@ -49,6 +51,7 @@ character_presets:
|
||||
| `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. |
|
||||
| `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:
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -28,11 +28,29 @@ 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.
|
||||
- **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-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime does NOT expose a native `imagegen` skill but the `codex` CLI is on `PATH` and `codex login` is active (e.g., Claude Code with Codex CLI installed), invoke the `codex-imagegen` wrapper. **Path resolution**: `scripts/codex-imagegen.sh` lives at the plugin/repo root, NOT relative to your shell cwd. From this `SKILL.md`'s base directory, the wrapper is at `../../scripts/codex-imagegen.sh` — resolve to an absolute path before invoking. Command shape:
|
||||
```bash
|
||||
<ABSOLUTE_PLUGIN_ROOT>/scripts/codex-imagegen.sh \
|
||||
--image <absolute_output_path> \
|
||||
--prompt-file <absolute_path_to_prompts/NN-cover-[slug].md> \
|
||||
--aspect <ratio> \
|
||||
[--ref <absolute_file>]... \
|
||||
[--timeout <ms>] \
|
||||
[--cache-dir ~/.cache/baoyu-codex-imagegen] \
|
||||
[--log-file <absolute_jsonl_log_path>]
|
||||
```
|
||||
`--timeout` defaults to 300000 (5 min) per codex exec attempt; raise it (e.g. `--timeout 600000` for 10 min) on slow networks or large prompts.
|
||||
All input paths to the wrapper are auto-resolved against the wrapper's `process.cwd()` if you pass relative ones, but agents should pass absolute paths to be robust against cwd drift. Parse the single-line JSON on stdout. On `{"status":"ok",...}` proceed to Step 5. On `{"status":"error","error_kind":...}` report the `error_kind` to the user and (if retryable) ask whether to retry or fall back to another backend. The wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` needed.
|
||||
- **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-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.
|
||||
|
||||
**⛔ 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.
|
||||
@@ -209,7 +227,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`**: invoke `<ABSOLUTE_PLUGIN_ROOT>/scripts/codex-imagegen.sh` (NOT a cwd-relative `./scripts/...` — resolve the absolute path from this skill's base directory: `../../scripts/codex-imagegen.sh`) with `--image <ABSOLUTE_output>` `--prompt-file <ABSOLUTE_prompts/01-cover-[slug].md>` `--aspect <ratio>` (add `--ref <ABSOLUTE_file>` per reference, `--cache-dir ~/.cache/baoyu-codex-imagegen` to enable the idempotency cache, `--timeout <ms>` to override the default 300000 / 5-min per-attempt limit on slow networks). All input paths to the wrapper are auto-resolved against its `process.cwd()` if relative, but passing absolutes is more robust. Read the stdout JSON; act on `status` and `error_kind`.
|
||||
- **Codex `imagegen` (native)** or other runtime-native tools / `baoyu-imagine` skill: per the rule in `## Image Generation Tools` above.
|
||||
6. On failure: auto-retry once
|
||||
|
||||
### Step 5: Completion Report
|
||||
@@ -238,6 +258,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
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
---
|
||||
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
|
||||
version: 1.57.0
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-cards
|
||||
@@ -28,17 +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.
|
||||
- **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-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **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-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.
|
||||
|
||||
**⛔ 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 titles, body copy, tags, or any other text inside an already generated image card. If text is wrong or unclear, regenerate from a corrected prompt, switch to a layout with less on-card 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 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.
|
||||
|
||||
## 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 the image-1 anchor chain: generate image 1 first, then batch images 2+ using image 1 as the reference.
|
||||
- Never start a batch until every selected prompt file for that batch exists 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
|
||||
|
||||
Default behavior: **confirm before generation**.
|
||||
@@ -61,6 +83,7 @@ Respond in the user's language across questions, progress, errors, and completio
|
||||
| `--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 |
|
||||
| `--batch-size <n>` | Temporary generation batch size for this run. Default: `generation_batch_size` from EXTEND.md, otherwise 4. Clamp to 1-8. |
|
||||
| `--yes` | Non-interactive: skip all confirmations, use EXTEND.md or built-in defaults, auto-confirm recommended plan (Path A) |
|
||||
|
||||
## Dimensions
|
||||
@@ -296,7 +319,7 @@ Check these paths in order; first hit wins:
|
||||
- **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`.
|
||||
**EXTEND.md keys**: watermark, preferred style/layout, custom style definitions, language preference, preferred image backend, generation batch size. Schema: `references/config/preferences-schema.md`.
|
||||
|
||||
### Step 1: Analyze Content → `analysis.md`
|
||||
|
||||
@@ -345,14 +368,13 @@ 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):
|
||||
Generation flow:
|
||||
|
||||
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.
|
||||
1. Write the full prompt for every image to `prompts/NN-{type}-{slug}.md` in the user's preferred language (backup rule applies), then verify all selected prompt files exist.
|
||||
2. Generate **image 1** first without `--ref`; backup rule applies to the PNG file. This establishes the anchor.
|
||||
3. Build a task list for **images 2+** using image 1 as `--ref <path-to-image-01.png>`.
|
||||
4. Dispatch images 2+ in batches per the `## Batch Generation Policy`: backend native batch first, runtime parallel tool calls second, sequential only as fallback.
|
||||
5. Report progress after each completed image. On failure, retry only the failed item once from the same saved prompt file.
|
||||
|
||||
**Watermark** (if enabled in EXTEND.md): append to the generation prompt:
|
||||
|
||||
@@ -411,6 +433,12 @@ For the style × layout compatibility matrix, see the **Style × Layout Matrix**
|
||||
|
||||
Always update the prompt file before regenerating — it's the source of truth and makes changes reproducible.
|
||||
|
||||
Text correction policy:
|
||||
|
||||
- If a card's title, body copy, tags, 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.
|
||||
|
||||
## References
|
||||
|
||||
| File | Content |
|
||||
@@ -447,5 +475,6 @@ EXTEND.md lives at the first matching path listed in Step 0. Three ways to chang
|
||||
- `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.
|
||||
- `generation_batch_size: 4` — default number of images to render concurrently when the backend/runtime supports batch or parallel generation.
|
||||
- `preferred_style: notion`, `preferred_layout: dense`, `preferred_palette: macaron`, `language: zh`.
|
||||
- `watermark.enabled: true` + `watermark.content: "@handle"` — add a watermark.
|
||||
|
||||
@@ -111,12 +111,15 @@ preferred_style:
|
||||
preferred_layout: null
|
||||
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`.
|
||||
|
||||
@@ -26,6 +26,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 generation
|
||||
|
||||
custom_styles:
|
||||
- name: my-style
|
||||
description: "Style description"
|
||||
@@ -52,6 +54,7 @@ custom_styles:
|
||||
| `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. |
|
||||
| `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
|
||||
@@ -109,6 +112,8 @@ language: zh
|
||||
|
||||
preferred_image_backend: codex-imagegen
|
||||
|
||||
generation_batch_size: 4
|
||||
|
||||
custom_styles:
|
||||
- name: corporate
|
||||
description: "Professional B2B style"
|
||||
|
||||
@@ -51,6 +51,16 @@ Check these paths in order; first hit wins:
|
||||
|
||||
Minimum working examples — see `references/usage-examples.md` for the full set including per-provider invocations and batch mode.
|
||||
|
||||
### Identity-preserving reference prompts
|
||||
|
||||
When the user wants a real person/character/object preserved from reference images, do **not** replace the reference with a long generic description. Prefer short, hard identity-preservation language:
|
||||
|
||||
- "Use the person/object in the reference image(s) as the same identity. Do not redesign it or create a similar-looking new subject."
|
||||
- "Only change scene, clothing, pose, lighting, rendering style, and composition. Keep the face/proportions/hair/key accessories/overall identity from the references."
|
||||
- If using multiple references, state that they are the same subject and should jointly define identity.
|
||||
|
||||
Pitfall: long descriptions like "young East Asian woman, oval face, clear eyes..." can cause the model to synthesize a new person matching the description instead of preserving the referenced person.
|
||||
|
||||
```bash
|
||||
# Basic
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image cat.png
|
||||
@@ -71,6 +81,16 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider d
|
||||
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4
|
||||
```
|
||||
|
||||
## Reference-Image Identity Preservation
|
||||
|
||||
When the user wants a person/object preserved from reference images:
|
||||
|
||||
- Prefer a small curated set of existing source references (usually 2–4) over many images; large multi-megabyte refs can destabilize streaming providers.
|
||||
- Make the prompt say the references are the same subject and the output must use that identity. Avoid long generic facial-feature descriptions that can cause the model to synthesize a new similar-looking person.
|
||||
- Do not use newly generated outputs as references unless the user explicitly asks; generated refs compound drift.
|
||||
- If results become too polished or influencer-like, reduce stylized refs and add explicit anti-beautification constraints (no face slimming, eye enlargement, heavy makeup, commercial travel shoot, over-smoothing).
|
||||
- If the subject should look younger/older, preserve the face and express age through clothing, posture, scene, and styling; do not ask the model to change facial identity.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
@@ -118,6 +138,18 @@ ${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4
|
||||
|
||||
**Load priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||
|
||||
### Codex/ChatGPT OAuth is not an OpenAI API key
|
||||
|
||||
`--provider openai` uses the standard OpenAI Images API and requires `OPENAI_API_KEY`. A Codex or ChatGPT desktop login is a different entitlement and is not a drop-in replacement for `OPENAI_API_KEY`; do not paste a Codex OAuth token into `OPENAI_API_KEY` or only set `OPENAI_BASE_URL` to a Codex backend.
|
||||
|
||||
If the user wants to use their Codex subscription / GPT Image 2 entitlement without an OpenAI API key, route through a Codex-native backend instead of this skill's `openai` provider:
|
||||
|
||||
- In Codex runtime: use the native `imagegen` skill/tool.
|
||||
- In non-Codex runtimes with `codex` CLI installed and logged in: use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it. Resolve it from the plugin/repo root and pass absolute prompt/output/reference paths.
|
||||
- In Hermes runtimes with a native `image_generate` tool: use that tool as a fallback, and state whether reference images were passed directly or reconstructed from extracted traits.
|
||||
|
||||
Do not modify the existing `openai` provider to silently consume Codex OAuth. If first-class Codex OAuth support is added, implement it as a distinct provider (for example `openai-codex`) with its own auth, route, request shape, docs, and tests. See `references/codex-oauth-vs-openai-api-key.md`.
|
||||
|
||||
## Model Resolution
|
||||
|
||||
Priority (highest → lowest) applies to every provider:
|
||||
@@ -211,11 +243,17 @@ Rule of thumb: once prompt files are saved and the task is "generate all of thes
|
||||
- Invalid aspect ratio → warning, proceed with default
|
||||
- Reference images with unsupported provider/model → error with fix hint
|
||||
|
||||
### Codex image2 fallback
|
||||
|
||||
If `--provider openai` fails because `OPENAI_API_KEY` is missing but the current runtime has a native image-generation backend or the repo-level `codex-imagegen` wrapper is available, use that path rather than leaving the user waiting. Be explicit about whether the fallback is true reference-image generation or only a text-prompt reconstruction from extracted visual traits. See `references/codex-image2-fallback.md`.
|
||||
|
||||
## References
|
||||
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| `references/usage-examples.md` | Extended CLI examples across providers and batch mode |
|
||||
| `references/codex-oauth-vs-openai-api-key.md` | Why Codex/ChatGPT OAuth image2 entitlement is not usable through the standard OpenAI API-key provider |
|
||||
| `references/codex-image2-fallback.md` | Practical fallback behavior when OpenAI API credentials are absent but Codex/native image generation is available |
|
||||
| `references/providers/dashscope.md` | DashScope families, sizes, limits |
|
||||
| `references/providers/zai.md` | Z.AI GLM-image / cogview-4 |
|
||||
| `references/providers/minimax.md` | MiniMax image-01 + subject reference |
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: codex-image2-fallback
|
||||
description: Fallback behavior when OpenAI API credentials are absent but Codex/native image generation is available
|
||||
---
|
||||
|
||||
# Codex Image2 Fallback
|
||||
|
||||
The `openai` provider can fail with:
|
||||
|
||||
```text
|
||||
OPENAI_API_KEY is required. Codex/ChatGPT desktop login does not automatically grant OpenAI Images API access to this script.
|
||||
```
|
||||
|
||||
This is expected. The `openai` provider uses the public OpenAI Images API and needs `OPENAI_API_KEY`. Codex / ChatGPT image2 entitlement is a separate runtime-native path.
|
||||
|
||||
## Practical fallback pattern
|
||||
|
||||
1. Try this skill when provider credentials are available.
|
||||
2. If it fails only because `OPENAI_API_KEY` is missing, do not leave the user waiting.
|
||||
3. Prefer a Codex/native raster backend in this order:
|
||||
- Codex runtime native `imagegen` skill/tool, if available.
|
||||
- Repo-level `scripts/codex-imagegen.sh`, if `codex` CLI is installed/logged in and the calling skill supports the wrapper.
|
||||
- Hermes native `image_generate`, if available.
|
||||
4. Be transparent about reference-image behavior:
|
||||
- If the fallback backend accepts references, pass the reference images.
|
||||
- If it does not, derive a concise identity-preserving prompt from the references and state that it is a text-description fallback, not strict reference-image editing.
|
||||
5. Return the generated media path or structured backend error promptly.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Codex OAuth vs OpenAI API key
|
||||
|
||||
This deprecated skill keeps cross-cutting image-generation guidance in sync with `baoyu-imagine`.
|
||||
|
||||
The `openai` provider uses the standard OpenAI Images API and requires `OPENAI_API_KEY`. It calls OpenAI-compatible image endpoints such as `/images/generations` and `/images/edits`.
|
||||
|
||||
Codex / ChatGPT login is different. Codex image generation is driven by Codex OAuth and the Codex runtime's `image_gen` capability, not by the public OpenAI Images API key path. A Codex OAuth token is not a drop-in replacement for `OPENAI_API_KEY`, and setting `OPENAI_BASE_URL` to a Codex backend will not make the existing `openai` provider work because the auth, route, and payload shape differ.
|
||||
|
||||
## What to use instead
|
||||
|
||||
- If running inside Codex and the native `imagegen` skill/tool is available, use it directly.
|
||||
- If running outside Codex but the `codex` CLI is installed and logged in, use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it.
|
||||
- If running inside Hermes and a native `image_generate` tool is available, use that as a runtime-native fallback. Be explicit about whether reference images are passed directly or only reconstructed from extracted traits.
|
||||
- If first-class Codex OAuth support is added, add a distinct provider such as `openai-codex` rather than modifying the existing `openai` provider.
|
||||
|
||||
## Reference-image prompting note
|
||||
|
||||
When using actual reference images for identity preservation, avoid long generic descriptions of the subject. Prefer direct wording:
|
||||
|
||||
> Use the person/object in the reference image(s) as the same identity. Do not redesign it or create a similar-looking new subject. Only change scene, clothing, pose, lighting, rendering style, and composition.
|
||||
@@ -52,6 +52,16 @@ Legacy compatibility: if `.baoyu-skills/baoyu-image-gen/EXTEND.md` exists and th
|
||||
|
||||
Minimum working examples — see `references/usage-examples.md` for the full set including per-provider invocations and batch mode.
|
||||
|
||||
### Identity-preserving reference prompts
|
||||
|
||||
When the user wants a real person/character/object preserved from reference images, do **not** replace the reference with a long generic description. Prefer short, hard identity-preservation language:
|
||||
|
||||
- "Use the person/object in the reference image(s) as the same identity. Do not redesign it or create a similar-looking new subject."
|
||||
- "Only change scene, clothing, pose, lighting, rendering style, and composition. Keep the face/proportions/hair/key accessories/overall identity from the references."
|
||||
- If using multiple references, state that they are the same subject and should jointly define identity.
|
||||
|
||||
Pitfall: long descriptions like "young East Asian woman, oval face, clear eyes..." can cause the model to synthesize a new person matching the description instead of preserving the referenced person.
|
||||
|
||||
```bash
|
||||
# Basic
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image cat.png
|
||||
@@ -75,6 +85,16 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider o
|
||||
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4
|
||||
```
|
||||
|
||||
## Reference-Image Identity Preservation
|
||||
|
||||
When the user wants a person/object preserved from reference images:
|
||||
|
||||
- Prefer a small curated set of existing source references (usually 2–4) over many images; large multi-megabyte refs can destabilize streaming providers.
|
||||
- Make the prompt say the references are the same subject and the output must use that identity. Avoid long generic facial-feature descriptions that can cause the model to synthesize a new similar-looking person.
|
||||
- Do not use newly generated outputs as references unless the user explicitly asks; generated refs compound drift.
|
||||
- If results become too polished or influencer-like, reduce stylized refs and add explicit anti-beautification constraints (no face slimming, eye enlargement, heavy makeup, commercial travel shoot, over-smoothing).
|
||||
- If the subject should look younger/older, preserve the face and express age through clothing, posture, scene, and styling; do not ask the model to change facial identity.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
@@ -122,6 +142,18 @@ ${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4
|
||||
|
||||
**Load priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||
|
||||
### Codex/ChatGPT OAuth is not an OpenAI API key
|
||||
|
||||
`--provider openai --model gpt-image-2` uses the standard OpenAI Images API (`/v1/images/generations` or `/v1/images/edits`) and requires `OPENAI_API_KEY`. A Codex or ChatGPT desktop login is a different entitlement and is not a drop-in replacement for `OPENAI_API_KEY`; do not paste a Codex OAuth token into `OPENAI_API_KEY` or only set `OPENAI_BASE_URL` to a Codex backend.
|
||||
|
||||
If the user wants to use their Codex subscription / GPT Image 2 entitlement without an OpenAI API key, route through a Codex-native backend instead of this skill's `openai` provider:
|
||||
|
||||
- In Codex runtime: use the native `imagegen` skill/tool.
|
||||
- In non-Codex runtimes with `codex` CLI installed and logged in: use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it (for example `baoyu-cover-image`). Resolve it from the plugin/repo root and pass absolute prompt/output/reference paths.
|
||||
- In Hermes runtimes with a native `image_generate` tool: use that tool as a fallback, and state whether reference images were passed directly or reconstructed from extracted traits.
|
||||
|
||||
Do not modify the existing `openai` provider to silently consume Codex OAuth. If first-class Codex OAuth support is added to `baoyu-imagine`, implement it as a distinct provider (for example `openai-codex`) with its own auth, route, request shape, docs, and tests. See `references/codex-oauth-vs-openai-api-key.md`.
|
||||
|
||||
## Model Resolution
|
||||
|
||||
Priority (highest → lowest) applies to every provider:
|
||||
@@ -219,11 +251,17 @@ Rule of thumb: once prompt files are saved and the task is "generate all of thes
|
||||
- Invalid aspect ratio → warning, proceed with default
|
||||
- Reference images with unsupported provider/model → error with fix hint
|
||||
|
||||
### Codex image2 fallback
|
||||
|
||||
If `--provider openai --model gpt-image-2` fails because `OPENAI_API_KEY` is missing but the current runtime has a native image-generation backend or the repo-level `codex-imagegen` wrapper is available, use that path rather than leaving the user waiting. Be explicit about whether the fallback is true reference-image generation or only a text-prompt reconstruction from extracted visual traits. See `references/codex-image2-fallback.md`.
|
||||
|
||||
## References
|
||||
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| `references/usage-examples.md` | Extended CLI examples across providers and batch mode |
|
||||
| `references/codex-oauth-vs-openai-api-key.md` | Why Codex/ChatGPT OAuth image2 entitlement is not usable through baoyu-imagine's standard OpenAI API-key provider |
|
||||
| `references/codex-image2-fallback.md` | Practical fallback behavior when OpenAI API credentials are absent but Codex/native image generation is available |
|
||||
| `references/providers/dashscope.md` | DashScope families, sizes, limits |
|
||||
| `references/providers/zai.md` | Z.AI GLM-image / cogview-4 |
|
||||
| `references/providers/minimax.md` | MiniMax image-01 + subject reference |
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: codex-image2-fallback
|
||||
description: Fallback behavior when baoyu-imagine lacks OpenAI API credentials but Codex/native image generation is available
|
||||
---
|
||||
|
||||
# Codex Image2 Fallback
|
||||
|
||||
When using `baoyu-imagine` with `--provider openai --model gpt-image-2`, the CLI can fail with:
|
||||
|
||||
```text
|
||||
OPENAI_API_KEY is required. Codex/ChatGPT desktop login does not automatically grant OpenAI Images API access to this script.
|
||||
```
|
||||
|
||||
This is expected. The `openai` provider uses the public OpenAI Images API and needs `OPENAI_API_KEY`. Codex / ChatGPT image2 entitlement is a separate runtime-native path.
|
||||
|
||||
## Practical fallback pattern
|
||||
|
||||
1. Try `baoyu-imagine` when provider credentials are available.
|
||||
2. If it fails only because `OPENAI_API_KEY` is missing, do not leave the user waiting.
|
||||
3. Prefer a Codex/native raster backend in this order:
|
||||
- Codex runtime native `imagegen` skill/tool, if available.
|
||||
- Repo-level `scripts/codex-imagegen.sh`, if `codex` CLI is installed/logged in and the calling skill supports the wrapper.
|
||||
- Hermes native `image_generate`, if available.
|
||||
4. Be transparent about reference-image behavior:
|
||||
- If the fallback backend accepts references, pass the reference images.
|
||||
- If it does not, derive a concise identity-preserving prompt from the references and state that it is a text-description fallback, not strict reference-image editing.
|
||||
5. Return the generated media path or structured backend error promptly.
|
||||
|
||||
## User-facing wording
|
||||
|
||||
Use concise wording such as:
|
||||
|
||||
> The OpenAI API path needs `OPENAI_API_KEY`; Codex login is a separate image2 backend. I used the available Codex/native image backend instead. Reference images were [passed directly / reconstructed from visual traits].
|
||||
|
||||
Avoid implying that `baoyu-imagine --provider openai` can use Codex OAuth without a dedicated provider implementation.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Codex OAuth vs OpenAI API key for baoyu-imagine
|
||||
|
||||
`baoyu-imagine --provider openai` uses the standard OpenAI Images API and requires `OPENAI_API_KEY`. It calls OpenAI-compatible image endpoints such as `/images/generations` and `/images/edits`.
|
||||
|
||||
Codex / ChatGPT login is different. Codex image generation is driven by Codex OAuth and the Codex runtime's `image_gen` capability, not by the public OpenAI Images API key path. A Codex OAuth token is not a drop-in replacement for `OPENAI_API_KEY`, and setting `OPENAI_BASE_URL` to a Codex backend will not make baoyu-imagine's existing `openai` provider work because the auth, route, and payload shape differ.
|
||||
|
||||
## What to use instead
|
||||
|
||||
- If running inside Codex and the native `imagegen` skill/tool is available, use it directly.
|
||||
- If running outside Codex but the `codex` CLI is installed and logged in, use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it. The wrapper invokes `codex exec` and the Codex `image_gen` tool; no `OPENAI_API_KEY` is required.
|
||||
- If running inside Hermes and a native `image_generate` tool is available, use that as a runtime-native fallback. Be explicit about whether reference images are passed directly or only reconstructed from extracted traits.
|
||||
- If the user wants `baoyu-imagine` itself to support Codex OAuth, add a distinct provider such as `openai-codex` rather than modifying the existing `openai` provider.
|
||||
|
||||
## Reference-image prompting note
|
||||
|
||||
When using actual reference images for identity preservation, avoid long generic descriptions of the subject. Long descriptions can cause the model to synthesize a new similar-looking person/object. Prefer direct wording:
|
||||
|
||||
> Use the person/object in the reference image(s) as the same identity. Do not redesign it or create a similar-looking new subject. Only change scene, clothing, pose, lighting, rendering style, and composition.
|
||||
@@ -28,11 +28,16 @@ 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.
|
||||
- **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-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **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-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.
|
||||
|
||||
**⛔ 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, headings, callouts, data values, or any other text inside an already generated infographic. If text is wrong or unclear, regenerate from a corrected prompt, switch to a layout with less 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.
|
||||
@@ -294,6 +299,12 @@ Combine:
|
||||
4. Call the chosen backend with the prompt file and output path
|
||||
5. On failure, auto-retry once
|
||||
|
||||
Text correction policy:
|
||||
|
||||
- If labels, headings, callouts, data values, 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.
|
||||
|
||||
### Step 7: Output Summary
|
||||
|
||||
Report: topic, layout, style, aspect, language, image backend, output path, files created.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-post-to-wechat
|
||||
description: Posts content to WeChat Official Account (微信公众号) via API or Chrome CDP. Supports article posting (文章) with HTML, markdown, or plain text input, and image-text posting (贴图, formerly 图文) with multiple images. Markdown article workflows default to converting ordinary external links into bottom citations for WeChat-friendly output. Use when user mentions "发布公众号", "post to wechat", "微信公众号", or "贴图/图文/文章".
|
||||
version: 1.56.1
|
||||
version: 1.117.5
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-wechat
|
||||
@@ -64,13 +64,26 @@ Found → read, parse, apply. Not found → run first-time setup (`references/co
|
||||
```md
|
||||
default_theme: default
|
||||
default_color: blue
|
||||
default_publish_method: api
|
||||
default_publish_method: browser
|
||||
default_author: 宝玉
|
||||
need_open_comment: 1
|
||||
only_fans_can_comment: 0
|
||||
chrome_profile_path: /path/to/chrome/profile
|
||||
|
||||
# Remote API publishing (optional) — only set if WeChat's IP allowlist
|
||||
# excludes your local machine. See "Remote API Method" below.
|
||||
# remote_publish_host: server.example.com
|
||||
# remote_publish_user: deploy
|
||||
# remote_publish_port: 22
|
||||
# remote_publish_identity_file: ~/.ssh/id_ed25519
|
||||
# remote_publish_known_hosts_file: ~/.ssh/known_hosts
|
||||
# remote_publish_strict_host_key_checking: accept-new
|
||||
# remote_publish_connect_timeout: 10
|
||||
# remote_publish_proxy_jump: bastion.example.com
|
||||
```
|
||||
|
||||
Raw `ssh` / `scp` options are intentionally not supported; only the typed keys above are honored. Authentication is SSH key only (no passwords).
|
||||
|
||||
**Theme options**: default, grace, simple, modern. **Color presets**: blue, green, vermilion, yellow, purple, sky, rose, olive, black, gray, pink, red, orange (or hex).
|
||||
|
||||
**Value priority**: CLI args → frontmatter → EXTEND.md (account-level → global) → skill defaults.
|
||||
@@ -148,11 +161,14 @@ Ask method unless specified in EXTEND.md or CLI:
|
||||
|
||||
| Method | Speed | Requires |
|
||||
|--------|-------|----------|
|
||||
| `api` (Recommended) | Fast | API credentials |
|
||||
| `api` (Recommended) | Fast | API credentials (local IP allowlisted) |
|
||||
| `browser` | Slow | Chrome + logged-in session |
|
||||
| `remote-api` | Fast | API credentials + an SSH-reachable server whose IP is on the WeChat allowlist |
|
||||
|
||||
**API selected + missing credentials** → run guided setup per `references/api-setup.md` (writes to `.baoyu-skills/.env`).
|
||||
|
||||
**`remote-api` method**: WeChat's "公众号设置 → IP 白名单" often limits API access to one or two fixed IPs. If your local machine's IP is not on that list but a cloud server's is, use `remote-api`: all markdown rendering, image processing, draft assembly, and HTML rewriting still happen locally, and only the outbound HTTPS calls to `api.weixin.qq.com` (token, uploadimg, add_material, draft/add) are tunneled through an SSH SOCKS5 dynamic port forward (`ssh -N -D`) so that WeChat sees the remote server as the source IP. No files are written to the remote host; `AppSecret` never leaves the local process. Requires only `sshd` and outbound network on the remote host — no Python, no agent process. See "Remote API Method" below.
|
||||
|
||||
### Step 3: Resolve Theme/Color and Validate Metadata
|
||||
|
||||
1. **Theme**: CLI `--theme` → EXTEND.md `default_theme` → `default` (first match wins; do NOT ask if resolved).
|
||||
@@ -183,6 +199,14 @@ ${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme <theme> [--color <color>
|
||||
|
||||
Always pass `--theme` even if it's `default`. Only pass `--color` when explicitly set by the user or EXTEND.md.
|
||||
|
||||
**Remote API method** (same script, adds `--remote`):
|
||||
|
||||
```bash
|
||||
${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme <theme> --remote [--remote-host <host>] [--remote-user <user>] [--remote-port <port>] [--remote-identity-file <path>] [--remote-known-hosts-file <path>] [--remote-strict-host-key-checking yes|no|accept-new] [--remote-connect-timeout <s>] [--remote-proxy-jump <spec>]
|
||||
```
|
||||
|
||||
Any `--remote-*` flag implies `--remote`. CLI values override account-level then global `remote_publish_*` keys from EXTEND.md. Setting `default_publish_method: remote-api` also enables remote mode without `--remote`.
|
||||
|
||||
**`draft/add` payload rules**:
|
||||
- Endpoint: `POST https://api.weixin.qq.com/cgi-bin/draft/add?access_token=ACCESS_TOKEN`
|
||||
- `article_type`: `news` (default) or `newspic`
|
||||
@@ -225,19 +249,20 @@ Files created:
|
||||
|
||||
## Feature Comparison
|
||||
|
||||
| Feature | Image-Text | Article (API) | Article (Browser) |
|
||||
|---------|:---:|:---:|:---:|
|
||||
| Plain text input | ✗ | ✓ | ✓ |
|
||||
| HTML input | ✗ | ✓ | ✓ |
|
||||
| Markdown input | Title/content | ✓ | ✓ |
|
||||
| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) |
|
||||
| Themes | ✗ | ✓ | ✓ |
|
||||
| Auto-generate metadata | ✗ | ✓ | ✓ |
|
||||
| Default cover fallback (`imgs/cover.png`) | ✗ | ✓ | ✗ |
|
||||
| Comment control | ✗ | ✓ | ✗ |
|
||||
| Requires Chrome | ✓ | ✗ | ✓ |
|
||||
| Requires API credentials | ✗ | ✓ | ✗ |
|
||||
| Speed | Medium | Fast | Slow |
|
||||
| Feature | Image-Text | Article (API) | Article (Remote API) | Article (Browser) |
|
||||
|---------|:---:|:---:|:---:|:---:|
|
||||
| Plain text input | ✗ | ✓ | ✓ | ✓ |
|
||||
| HTML input | ✗ | ✓ | ✓ | ✓ |
|
||||
| Markdown input | Title/content | ✓ | ✓ | ✓ |
|
||||
| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) | ✓ (inline) |
|
||||
| Themes | ✗ | ✓ | ✓ | ✓ |
|
||||
| Auto-generate metadata | ✗ | ✓ | ✓ | ✓ |
|
||||
| Default cover fallback (`imgs/cover.png`) | ✗ | ✓ | ✓ | ✗ |
|
||||
| Comment control | ✗ | ✓ | ✓ | ✗ |
|
||||
| Requires Chrome | ✓ | ✗ | ✗ | ✓ |
|
||||
| Requires API credentials | ✗ | ✓ | ✓ | ✗ |
|
||||
| Requires SSH-reachable server with allowlisted IP | ✗ | ✗ | ✓ | ✗ |
|
||||
| Speed | Medium | Fast | Fast | Slow |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -245,12 +270,16 @@ Files created:
|
||||
|-------|-----|
|
||||
| Missing API credentials | Follow guided setup in Step 2 |
|
||||
| Access token error | Verify credentials valid and not expired |
|
||||
| Not logged in (browser) | First run opens browser — scan QR to log in |
|
||||
| Not logged in (browser) | First run opens browser — scan QR to log in. Set `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` to receive the QR image via Telegram |
|
||||
| Chrome not found | Set `WECHAT_BROWSER_CHROME_PATH` |
|
||||
| Title/summary missing | Use auto-generation or provide manually |
|
||||
| No cover image | Add frontmatter cover or place `imgs/cover.png` in article directory |
|
||||
| Wrong comment defaults | Check `need_open_comment` / `only_fans_can_comment` in EXTEND.md |
|
||||
| Paste fails | Check system clipboard permissions |
|
||||
| `Remote publish host is required` | Set `--remote-host` or `remote_publish_host` in EXTEND.md |
|
||||
| `SOCKS proxy on 127.0.0.1:… not ready` | SSH could not start the tunnel — check key, host, `StrictHostKeyChecking`, or use `--remote-connect-timeout` |
|
||||
| `ssh exited early` during remote publish | Verify the user can `ssh` non-interactively to the server; raise `--remote-connect-timeout` if the link is slow |
|
||||
| Remote API call returns `errcode 40164` (invalid IP) | The remote server's egress IP is not on WeChat's allowlist; add it in 公众号设置 → IP 白名单 |
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -86,8 +86,12 @@ options:
|
||||
description: "Fast, requires API credentials (AppID + AppSecret)"
|
||||
- label: "browser"
|
||||
description: "Slow, requires Chrome and login session"
|
||||
- label: "remote-api"
|
||||
description: "Fast, tunnels WeChat API calls through SSH to a server whose IP is on the WeChat allowlist"
|
||||
```
|
||||
|
||||
If the user selects `remote-api`, prompt for `remote_publish_host` and (optionally) `remote_publish_user`, `remote_publish_identity_file`. These can also be filled in later by editing EXTEND.md.
|
||||
|
||||
### Question 4: Default Author
|
||||
|
||||
```yaml
|
||||
@@ -157,13 +161,26 @@ options:
|
||||
```md
|
||||
default_theme: [default/grace/simple/modern]
|
||||
default_color: [preset name, hex, or empty for theme default]
|
||||
default_publish_method: [api/browser]
|
||||
default_publish_method: [api/browser/remote-api]
|
||||
default_author: [author name or empty]
|
||||
need_open_comment: [1/0]
|
||||
only_fans_can_comment: [1/0]
|
||||
chrome_profile_path:
|
||||
|
||||
# Remote API publishing — only fill in if default_publish_method is remote-api
|
||||
# or you plan to pass --remote on the CLI.
|
||||
remote_publish_host:
|
||||
remote_publish_user:
|
||||
remote_publish_port:
|
||||
remote_publish_identity_file:
|
||||
remote_publish_known_hosts_file:
|
||||
remote_publish_strict_host_key_checking:
|
||||
remote_publish_connect_timeout:
|
||||
remote_publish_proxy_jump:
|
||||
```
|
||||
|
||||
Raw `ssh` / `scp` options are intentionally not supported; only the typed keys above are honored. Authentication is SSH key only.
|
||||
|
||||
### Multi-Account
|
||||
|
||||
```md
|
||||
@@ -174,15 +191,19 @@ accounts:
|
||||
- name: [display name]
|
||||
alias: [short key, e.g. "baoyu"]
|
||||
default: true
|
||||
default_publish_method: [api/browser]
|
||||
default_publish_method: [api/browser/remote-api]
|
||||
default_author: [author name]
|
||||
need_open_comment: [1/0]
|
||||
only_fans_can_comment: [1/0]
|
||||
app_id: [WeChat App ID, optional]
|
||||
app_secret: [WeChat App Secret, optional]
|
||||
# Remote API publishing (optional, per-account override of globals)
|
||||
remote_publish_host:
|
||||
remote_publish_user:
|
||||
remote_publish_identity_file:
|
||||
- name: [second account name]
|
||||
alias: [short key, e.g. "ai-tools"]
|
||||
default_publish_method: [api/browser]
|
||||
default_publish_method: [api/browser/remote-api]
|
||||
default_author: [author name]
|
||||
need_open_comment: [1/0]
|
||||
only_fans_can_comment: [1/0]
|
||||
|
||||
@@ -37,7 +37,7 @@ accounts:
|
||||
|
||||
## Per-Account vs Global Keys
|
||||
|
||||
**Per-account** (also accepted globally as fallback): `default_publish_method`, `default_author`, `need_open_comment`, `only_fans_can_comment`, `app_id`, `app_secret`, `chrome_profile_path`.
|
||||
**Per-account** (also accepted globally as fallback): `default_publish_method`, `default_author`, `need_open_comment`, `only_fans_can_comment`, `app_id`, `app_secret`, `chrome_profile_path`, `remote_publish_host`, `remote_publish_user`, `remote_publish_port`, `remote_publish_identity_file`, `remote_publish_known_hosts_file`, `remote_publish_strict_host_key_checking`, `remote_publish_connect_timeout`, `remote_publish_proxy_jump`.
|
||||
|
||||
**Global-only** (always shared): `default_theme`, `default_color`.
|
||||
|
||||
@@ -99,3 +99,54 @@ ${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme default --account ai-too
|
||||
${BUN_X} {baseDir}/scripts/wechat-article.ts --markdown <file> --theme default --account baoyu
|
||||
${BUN_X} {baseDir}/scripts/wechat-browser.ts --markdown <file> --images ./photos/ --account baoyu
|
||||
```
|
||||
|
||||
## Remote API Publishing
|
||||
|
||||
`wechat-api.ts` supports a `remote-api` mode that tunnels WeChat API calls through an SSH SOCKS5 dynamic port forward to a server whose IP is on WeChat's allowlist. Markdown rendering, image processing, draft assembly, and HTML rewriting still happen locally; only outbound HTTPS calls to `api.weixin.qq.com` traverse the tunnel. No files are written to the remote host and `AppSecret` never leaves the local process. The remote host needs only `sshd` and outbound network access.
|
||||
|
||||
### Per-Account Configuration
|
||||
|
||||
```md
|
||||
default_theme: default
|
||||
default_color: blue
|
||||
default_publish_method: browser # browser remains the default
|
||||
|
||||
accounts:
|
||||
- name: 宝玉的技术分享
|
||||
alias: baoyu
|
||||
default: true
|
||||
default_publish_method: api
|
||||
default_author: 宝玉
|
||||
app_id: your_wechat_app_id
|
||||
app_secret: your_wechat_app_secret
|
||||
- name: AI工具集
|
||||
alias: ai-tools
|
||||
default_publish_method: remote-api
|
||||
default_author: AI工具集
|
||||
app_id: your_ai_tools_app_id
|
||||
app_secret: your_ai_tools_app_secret
|
||||
remote_publish_host: ai-tools-server.example.com
|
||||
remote_publish_user: deploy
|
||||
remote_publish_port: 22
|
||||
remote_publish_identity_file: /home/me/.ssh/id_ed25519
|
||||
remote_publish_known_hosts_file: /home/me/.ssh/known_hosts
|
||||
remote_publish_strict_host_key_checking: accept-new
|
||||
```
|
||||
|
||||
Account-level `remote_publish_*` values override top-level globals. CLI `--remote-*` flags override both.
|
||||
|
||||
### CLI Usage
|
||||
|
||||
```bash
|
||||
# Use the account's default_publish_method (remote-api here):
|
||||
${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme default --account ai-tools
|
||||
|
||||
# Force remote mode regardless of default_publish_method:
|
||||
${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme default --account baoyu --remote --remote-host other-server.example.com
|
||||
```
|
||||
|
||||
### Security Notes
|
||||
|
||||
- Authentication is SSH key only. Passwords and `ssh-askpass` are not used.
|
||||
- Only the typed `remote_publish_*` keys are read; raw `ssh` / `scp` options are intentionally not supported.
|
||||
- The tunnel forwards raw TCP; TLS verification for `api.weixin.qq.com` is still performed end-to-end by the local process.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"baoyu-chrome-cdp": "^0.1.0",
|
||||
"baoyu-md": "^0.1.0",
|
||||
"jimp": "^1.6.0",
|
||||
"socks": "^2.8.9",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -165,6 +166,8 @@
|
||||
|
||||
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
|
||||
|
||||
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"jimp": ["jimp@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/diff": "1.6.1", "@jimp/js-bmp": "1.6.1", "@jimp/js-gif": "1.6.1", "@jimp/js-jpeg": "1.6.1", "@jimp/js-png": "1.6.1", "@jimp/js-tiff": "1.6.1", "@jimp/plugin-blit": "1.6.1", "@jimp/plugin-blur": "1.6.1", "@jimp/plugin-circle": "1.6.1", "@jimp/plugin-color": "1.6.1", "@jimp/plugin-contain": "1.6.1", "@jimp/plugin-cover": "1.6.1", "@jimp/plugin-crop": "1.6.1", "@jimp/plugin-displace": "1.6.1", "@jimp/plugin-dither": "1.6.1", "@jimp/plugin-fisheye": "1.6.1", "@jimp/plugin-flip": "1.6.1", "@jimp/plugin-hash": "1.6.1", "@jimp/plugin-mask": "1.6.1", "@jimp/plugin-print": "1.6.1", "@jimp/plugin-quantize": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/plugin-rotate": "1.6.1", "@jimp/plugin-threshold": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1" } }, "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw=="],
|
||||
@@ -277,6 +280,10 @@
|
||||
|
||||
"slick": ["slick@1.12.2", "", {}, "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A=="],
|
||||
|
||||
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
||||
|
||||
"socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const clipboardScript = fs.readFileSync(path.join(__dirname, "copy-to-clipboard.ts"), "utf8");
|
||||
|
||||
test("macOS image clipboard copy avoids Swift AppKit JIT", () => {
|
||||
assert.match(clipboardScript, /copyImageMacWithOsascript/);
|
||||
assert.doesNotMatch(clipboardScript, /await runCommand\('swift', \[swiftPath, 'image', imagePath\]\)/);
|
||||
});
|
||||
|
||||
test("macOS image clipboard copy converts WebP to PNG before AppleScript", () => {
|
||||
assert.match(clipboardScript, /convertWebpMacToPng/);
|
||||
assert.match(clipboardScript, /path\.extname\(imagePath\)\.toLowerCase\(\) === '\.webp'/);
|
||||
assert.match(clipboardScript, /await copyImageMacWithOsascript\(pngPath\)/);
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import decodeWebp, { init as initWebpDecode } from '@jsquash/webp/decode.js';
|
||||
import { Jimp, JimpMime } from 'jimp';
|
||||
|
||||
const SUPPORTED_IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
||||
|
||||
@@ -186,12 +189,73 @@ default:
|
||||
`;
|
||||
}
|
||||
|
||||
async function copyImageMac(imagePath: string): Promise<void> {
|
||||
await withTempDir('copy-to-clipboard-', async (tempDir) => {
|
||||
const swiftPath = path.join(tempDir, 'clipboard.swift');
|
||||
await writeFile(swiftPath, getMacSwiftClipboardSource(), 'utf8');
|
||||
await runCommand('swift', [swiftPath, 'image', imagePath]);
|
||||
function escapeAppleScriptString(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function getAppleScriptImageType(imagePath: string): string {
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.png':
|
||||
return '«class PNGf»';
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'JPEG picture';
|
||||
case '.gif':
|
||||
return 'GIF picture';
|
||||
default:
|
||||
throw new Error(`macOS clipboard image copy supports PNG, JPEG, and GIF via AppleScript; convert ${ext || 'this file'} to PNG first.`);
|
||||
}
|
||||
}
|
||||
|
||||
let webpDecoderReady: Promise<void> | undefined;
|
||||
|
||||
async function ensureWebpDecoder(): Promise<void> {
|
||||
if (!webpDecoderReady) {
|
||||
webpDecoderReady = (async () => {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const wasmPath = path.resolve(__dirname, 'node_modules/@jsquash/webp/codec/dec/webp_dec.wasm');
|
||||
const wasmModule = await WebAssembly.compile(await readFile(wasmPath));
|
||||
await initWebpDecode(wasmModule, {});
|
||||
})();
|
||||
}
|
||||
|
||||
await webpDecoderReady;
|
||||
}
|
||||
|
||||
async function convertWebpMacToPng(webpPath: string, tempDir: string): Promise<string> {
|
||||
await ensureWebpDecoder();
|
||||
const decoded = await decodeWebp(await readFile(webpPath));
|
||||
const image = new Jimp({
|
||||
data: Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength),
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
});
|
||||
const pngPath = path.join(tempDir, `${path.basename(webpPath, path.extname(webpPath))}.png`);
|
||||
await writeFile(pngPath, await image.getBuffer(JimpMime.png));
|
||||
return pngPath;
|
||||
}
|
||||
|
||||
async function copyImageMacWithOsascript(imagePath: string): Promise<void> {
|
||||
const imageType = getAppleScriptImageType(imagePath);
|
||||
const escapedPath = escapeAppleScriptString(imagePath);
|
||||
await runCommand('osascript', [
|
||||
'-e',
|
||||
`set the clipboard to (read (POSIX file "${escapedPath}") as ${imageType})`,
|
||||
]);
|
||||
}
|
||||
|
||||
async function copyImageMac(imagePath: string): Promise<void> {
|
||||
if (path.extname(imagePath).toLowerCase() === '.webp') {
|
||||
await withTempDir('copy-to-clipboard-', async (tempDir) => {
|
||||
const pngPath = await convertWebpMacToPng(imagePath, tempDir);
|
||||
await copyImageMacWithOsascript(pngPath);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await copyImageMacWithOsascript(imagePath);
|
||||
}
|
||||
|
||||
async function copyHtmlMac(htmlFilePath: string): Promise<void> {
|
||||
@@ -377,4 +441,3 @@ await main().catch((err) => {
|
||||
console.error(`Error: ${message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"@jsquash/webp": "^1.5.0",
|
||||
"baoyu-chrome-cdp": "^0.1.0",
|
||||
"baoyu-md": "^0.1.0",
|
||||
"jimp": "^1.6.0"
|
||||
"jimp": "^1.6.0",
|
||||
"socks": "^2.8.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,25 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadWechatExtendConfig, resolveAccount, loadCredentials } from "./wechat-extend-config.ts";
|
||||
import {
|
||||
loadWechatExtendConfig,
|
||||
resolveAccount,
|
||||
loadCredentials,
|
||||
type ResolvedAccount,
|
||||
type StrictHostKeyChecking,
|
||||
} from "./wechat-extend-config.ts";
|
||||
import {
|
||||
type WechatUploadAsset,
|
||||
prepareWechatBodyImageUpload,
|
||||
needsWechatBodyImageProcessing,
|
||||
detectImageFormatFromBuffer,
|
||||
} from "./wechat-image-processor.ts";
|
||||
import { loadUploadAsset } from "./wechat-image-loader.ts";
|
||||
import { wechatHttp, buildMultipart, type WechatClient } from "./wechat-http.ts";
|
||||
import {
|
||||
type RemotePublishConfig,
|
||||
normalizeRemoteConfig,
|
||||
withSshTunnel,
|
||||
} from "./wechat-remote-publish.ts";
|
||||
|
||||
interface AccessTokenResponse {
|
||||
access_token?: string;
|
||||
@@ -62,13 +74,17 @@ const UPLOAD_BODY_IMG_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg";
|
||||
const UPLOAD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
|
||||
const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
|
||||
|
||||
async function fetchAccessToken(appId: string, appSecret: string): Promise<string> {
|
||||
async function fetchAccessToken(
|
||||
appId: string,
|
||||
appSecret: string,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<string> {
|
||||
const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const res = await client(url);
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`Failed to fetch access token: ${res.status}`);
|
||||
}
|
||||
const data = await res.json() as AccessTokenResponse;
|
||||
const data = await res.json<AccessTokenResponse>();
|
||||
if (data.errcode) {
|
||||
throw new Error(`Access token error ${data.errcode}: ${data.errmsg}`);
|
||||
}
|
||||
@@ -83,86 +99,12 @@ function toHttpsUrl(url: string | undefined): string {
|
||||
return url.startsWith("http://") ? url.replace(/^http:\/\//i, "https://") : url;
|
||||
}
|
||||
|
||||
async function loadUploadAsset(
|
||||
imagePath: string,
|
||||
baseDir?: string,
|
||||
): Promise<WechatUploadAsset> {
|
||||
let fileBuffer: Buffer;
|
||||
let filename: string;
|
||||
let contentType: string;
|
||||
let fileSize = 0;
|
||||
let fileExt = "";
|
||||
|
||||
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
||||
const response = await fetch(imagePath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${imagePath}`);
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength === 0) {
|
||||
throw new Error(`Remote image is empty: ${imagePath}`);
|
||||
}
|
||||
fileBuffer = Buffer.from(buffer);
|
||||
fileSize = buffer.byteLength;
|
||||
const urlPath = imagePath.split("?")[0];
|
||||
filename = path.basename(urlPath) || "image.jpg";
|
||||
fileExt = path.extname(filename).toLowerCase();
|
||||
contentType = response.headers.get("content-type") || "image/jpeg";
|
||||
} else {
|
||||
const resolvedPath = path.isAbsolute(imagePath)
|
||||
? imagePath
|
||||
: path.resolve(baseDir || process.cwd(), imagePath);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(`Image not found: ${resolvedPath}`);
|
||||
}
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (stats.size === 0) {
|
||||
throw new Error(`Local image is empty: ${resolvedPath}`);
|
||||
}
|
||||
fileSize = stats.size;
|
||||
fileBuffer = fs.readFileSync(resolvedPath);
|
||||
filename = path.basename(resolvedPath);
|
||||
fileExt = path.extname(filename).toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".tiff": "image/tiff",
|
||||
".tif": "image/tiff",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
contentType = mimeTypes[fileExt] || "image/jpeg";
|
||||
}
|
||||
|
||||
// Detect actual format from magic bytes to fix extension/content-type mismatches
|
||||
// (e.g. CDNs serving WebP for URLs with .png extension)
|
||||
const detected = detectImageFormatFromBuffer(fileBuffer);
|
||||
if (detected && detected.contentType !== contentType) {
|
||||
console.error(`[wechat-api] Format mismatch: ${filename} declared as ${contentType}, actual ${detected.contentType}`);
|
||||
contentType = detected.contentType;
|
||||
fileExt = detected.fileExt;
|
||||
filename = `${path.basename(filename, path.extname(filename))}${detected.fileExt}`;
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: fileBuffer,
|
||||
filename,
|
||||
contentType,
|
||||
fileExt,
|
||||
fileSize,
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
imagePath: string,
|
||||
accessToken: string,
|
||||
baseDir?: string,
|
||||
uploadType: "body" | "material" = "body"
|
||||
uploadType: "body" | "material" = "body",
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<UploadResponse> {
|
||||
const asset = await loadUploadAsset(imagePath, baseDir);
|
||||
let uploadAsset = asset;
|
||||
@@ -187,6 +129,7 @@ async function uploadImage(
|
||||
uploadAsset.contentType,
|
||||
accessToken,
|
||||
uploadType,
|
||||
client,
|
||||
);
|
||||
|
||||
// media/uploadimg 接口只返回 URL,material/add_material 返回 media_id
|
||||
@@ -201,39 +144,27 @@ async function uploadImage(
|
||||
}
|
||||
}
|
||||
|
||||
// 实际的微信上传函数
|
||||
async function uploadToWechat(
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
contentType: string,
|
||||
accessToken: string,
|
||||
uploadType: "body" | "material"
|
||||
uploadType: "body" | "material",
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<UploadResponse> {
|
||||
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`;
|
||||
const header = [
|
||||
`--${boundary}`,
|
||||
`Content-Disposition: form-data; name="media"; filename="${filename}"`,
|
||||
`Content-Type: ${contentType}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n");
|
||||
const footer = `\r\n--${boundary}--\r\n`;
|
||||
|
||||
const headerBuffer = Buffer.from(header, "utf-8");
|
||||
const footerBuffer = Buffer.from(footer, "utf-8");
|
||||
const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]);
|
||||
const multipart = buildMultipart([
|
||||
{ name: "media", filename, contentType, data: fileBuffer },
|
||||
]);
|
||||
|
||||
const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL;
|
||||
const url = `${uploadUrl}?type=image&access_token=${accessToken}`;
|
||||
const res = await fetch(url, {
|
||||
const res = await client(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
headers: { "Content-Type": multipart.contentType },
|
||||
body: multipart.body,
|
||||
});
|
||||
|
||||
const data = await res.json() as UploadResponse;
|
||||
const data = await res.json<UploadResponse>();
|
||||
if (data.errcode && data.errcode !== 0) {
|
||||
throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`);
|
||||
}
|
||||
@@ -248,6 +179,7 @@ async function uploadImagesInHtml(
|
||||
contentImages: ImageInfo[] = [],
|
||||
articleType: ArticleType = "news",
|
||||
collectNewsCoverFallback: boolean = false,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<{ html: string; firstCoverMediaId: string; imageMediaIds: string[] }> {
|
||||
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
|
||||
const matches = [...html.matchAll(imgRegex)];
|
||||
@@ -268,7 +200,7 @@ async function uploadImagesInHtml(
|
||||
if (src.startsWith("https://mmbiz.qpic.cn")) {
|
||||
if (collectNewsCoverFallback && !firstCoverMediaId) {
|
||||
try {
|
||||
const coverResp = await uploadImage(src, accessToken, baseDir, "material");
|
||||
const coverResp = await uploadImage(src, accessToken, baseDir, "material", client);
|
||||
firstCoverMediaId = coverResp.media_id;
|
||||
} catch (err) {
|
||||
console.error(`[wechat-api] Failed to reuse existing WeChat image as cover: ${src}`, err);
|
||||
@@ -284,8 +216,7 @@ async function uploadImagesInHtml(
|
||||
try {
|
||||
let resp = uploadedBySource.get(imagePath);
|
||||
if (!resp) {
|
||||
// 正文图片使用 media/uploadimg 接口获取 URL
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body", client);
|
||||
uploadedBySource.set(imagePath, resp);
|
||||
}
|
||||
const newTag = fullTag
|
||||
@@ -296,7 +227,7 @@ async function uploadImagesInHtml(
|
||||
if (shouldUploadMaterial) {
|
||||
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||
if (!materialResp) {
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client);
|
||||
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||
}
|
||||
if (articleType === "newspic" && materialResp.media_id) {
|
||||
@@ -320,8 +251,7 @@ async function uploadImagesInHtml(
|
||||
try {
|
||||
let resp = uploadedBySource.get(imagePath);
|
||||
if (!resp) {
|
||||
// 正文图片使用 media/uploadimg 接口获取 URL
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body", client);
|
||||
uploadedBySource.set(imagePath, resp);
|
||||
}
|
||||
|
||||
@@ -331,7 +261,7 @@ async function uploadImagesInHtml(
|
||||
if (shouldUploadMaterial) {
|
||||
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||
if (!materialResp) {
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client);
|
||||
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||
}
|
||||
if (articleType === "newspic" && materialResp.media_id) {
|
||||
@@ -351,7 +281,8 @@ async function uploadImagesInHtml(
|
||||
|
||||
async function publishToDraft(
|
||||
options: ArticleOptions,
|
||||
accessToken: string
|
||||
accessToken: string,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<PublishResponse> {
|
||||
const url = `${DRAFT_URL}?access_token=${accessToken}`;
|
||||
|
||||
@@ -388,15 +319,13 @@ async function publishToDraft(
|
||||
if (options.digest) article.digest = options.digest;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
const res = await client(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ articles: [article] }),
|
||||
});
|
||||
|
||||
const data = await res.json() as PublishResponse;
|
||||
const data = await res.json<PublishResponse>();
|
||||
if (data.errcode && data.errcode !== 0) {
|
||||
throw new Error(`Publish failed ${data.errcode}: ${data.errmsg}`);
|
||||
}
|
||||
@@ -494,6 +423,15 @@ Options:
|
||||
--account <alias> Select account by alias (for multi-account setups)
|
||||
--no-cite Disable bottom citations for ordinary external links in markdown mode
|
||||
--dry-run Parse and render only, don't publish
|
||||
--remote Route WeChat API calls via SSH SOCKS5 tunnel to a whitelisted server
|
||||
--remote-host <h> Remote server host (implies --remote)
|
||||
--remote-user <u> SSH user (default: root, implies --remote)
|
||||
--remote-port <n> SSH port (default: 22, implies --remote)
|
||||
--remote-identity-file <p> SSH private key path (implies --remote)
|
||||
--remote-known-hosts-file <p> SSH known_hosts file path (implies --remote)
|
||||
--remote-strict-host-key-checking <yes|no|accept-new> (implies --remote)
|
||||
--remote-connect-timeout <seconds> SSH ConnectTimeout (implies --remote)
|
||||
--remote-proxy-jump <spec> SSH ProxyJump value (implies --remote)
|
||||
--help Show this help
|
||||
|
||||
Frontmatter Fields (markdown):
|
||||
@@ -539,6 +477,15 @@ interface CliArgs {
|
||||
account?: string;
|
||||
citeStatus: boolean;
|
||||
dryRun: boolean;
|
||||
remote: boolean;
|
||||
remoteHost?: string;
|
||||
remoteUser?: string;
|
||||
remotePort?: number;
|
||||
remoteIdentityFile?: string;
|
||||
remoteKnownHostsFile?: string;
|
||||
remoteStrictHostKeyChecking?: StrictHostKeyChecking;
|
||||
remoteConnectTimeout?: number;
|
||||
remoteProxyJump?: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
@@ -553,6 +500,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
theme: "default",
|
||||
citeStatus: true,
|
||||
dryRun: false,
|
||||
remote: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
@@ -582,6 +530,47 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
args.citeStatus = false;
|
||||
} else if (arg === "--dry-run") {
|
||||
args.dryRun = true;
|
||||
} else if (arg === "--remote") {
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-host" && argv[i + 1]) {
|
||||
args.remoteHost = argv[++i];
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-user" && argv[i + 1]) {
|
||||
args.remoteUser = argv[++i];
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-port" && argv[i + 1]) {
|
||||
const n = Number.parseInt(argv[++i]!, 10);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
||||
console.error(`Error: --remote-port must be 1-65535, got ${argv[i]}`);
|
||||
process.exit(1);
|
||||
}
|
||||
args.remotePort = n;
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-identity-file" && argv[i + 1]) {
|
||||
args.remoteIdentityFile = argv[++i];
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-known-hosts-file" && argv[i + 1]) {
|
||||
args.remoteKnownHostsFile = argv[++i];
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-strict-host-key-checking" && argv[i + 1]) {
|
||||
const v = argv[++i]!.toLowerCase();
|
||||
if (v !== "yes" && v !== "no" && v !== "accept-new") {
|
||||
console.error(`Error: --remote-strict-host-key-checking must be yes|no|accept-new, got ${argv[i]}`);
|
||||
process.exit(1);
|
||||
}
|
||||
args.remoteStrictHostKeyChecking = v as StrictHostKeyChecking;
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-connect-timeout" && argv[i + 1]) {
|
||||
const n = Number.parseInt(argv[++i]!, 10);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
console.error(`Error: --remote-connect-timeout must be a positive integer, got ${argv[i]}`);
|
||||
process.exit(1);
|
||||
}
|
||||
args.remoteConnectTimeout = n;
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-proxy-jump" && argv[i + 1]) {
|
||||
args.remoteProxyJump = argv[++i];
|
||||
args.remote = true;
|
||||
} else if (arg.startsWith("--") && argv[i + 1] && !argv[i + 1]!.startsWith("-")) {
|
||||
i++;
|
||||
} else if (!arg.startsWith("-")) {
|
||||
@@ -607,6 +596,27 @@ function extractHtmlTitle(html: string): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildRemoteConfig(args: CliArgs, resolved: ResolvedAccount): RemotePublishConfig {
|
||||
const host = args.remoteHost ?? resolved.remote_publish_host;
|
||||
if (!host) {
|
||||
throw new Error(
|
||||
"Remote publishing requires a host. Set --remote-host, EXTEND.md remote_publish_host, " +
|
||||
"or an account-level remote_publish_host.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
host,
|
||||
user: args.remoteUser ?? resolved.remote_publish_user,
|
||||
port: args.remotePort ?? resolved.remote_publish_port,
|
||||
identityFile: args.remoteIdentityFile ?? resolved.remote_publish_identity_file,
|
||||
knownHostsFile: args.remoteKnownHostsFile ?? resolved.remote_publish_known_hosts_file,
|
||||
strictHostKeyChecking:
|
||||
args.remoteStrictHostKeyChecking ?? resolved.remote_publish_strict_host_key_checking,
|
||||
connectTimeout: args.remoteConnectTimeout ?? resolved.remote_publish_connect_timeout,
|
||||
proxyJump: args.remoteProxyJump ?? resolved.remote_publish_proxy_jump,
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
@@ -709,8 +719,6 @@ async function main(): Promise<void> {
|
||||
console.error(`[wechat-api] Skipped incomplete credential source: ${skippedSource}`);
|
||||
}
|
||||
console.error(`[wechat-api] Credentials source: ${creds.source}`);
|
||||
console.error("[wechat-api] Fetching access token...");
|
||||
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret);
|
||||
|
||||
const rawCoverPath = args.cover ||
|
||||
frontmatter.coverImage ||
|
||||
@@ -722,62 +730,80 @@ async function main(): Promise<void> {
|
||||
: rawCoverPath;
|
||||
const needNewsCoverFallback = args.articleType === "news" && !coverPath;
|
||||
|
||||
console.error("[wechat-api] Uploading body images...");
|
||||
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
|
||||
htmlContent,
|
||||
accessToken,
|
||||
baseDir,
|
||||
contentImages,
|
||||
args.articleType,
|
||||
needNewsCoverFallback,
|
||||
);
|
||||
htmlContent = processedHtml;
|
||||
const useRemote = args.remote || resolved.default_publish_method === "remote-api";
|
||||
const method = useRemote ? "remote-api" : "api";
|
||||
|
||||
let thumbMediaId = "";
|
||||
const publishWith = async (client: WechatClient): Promise<void> => {
|
||||
console.error("[wechat-api] Fetching access token...");
|
||||
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret, client);
|
||||
|
||||
if (coverPath) {
|
||||
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
||||
// 封面图片使用 material/add_material 接口
|
||||
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material");
|
||||
thumbMediaId = coverResp.media_id;
|
||||
console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
|
||||
} else if (firstCoverMediaId && args.articleType === "news") {
|
||||
// news 类型没有封面时,使用第一张正文图的 media_id 作为封面(兜底逻辑)
|
||||
thumbMediaId = firstCoverMediaId;
|
||||
console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`);
|
||||
console.error("[wechat-api] Uploading body images...");
|
||||
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
|
||||
htmlContent,
|
||||
accessToken,
|
||||
baseDir,
|
||||
contentImages,
|
||||
args.articleType,
|
||||
needNewsCoverFallback,
|
||||
client,
|
||||
);
|
||||
htmlContent = processedHtml;
|
||||
|
||||
let thumbMediaId = "";
|
||||
|
||||
if (coverPath) {
|
||||
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
||||
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material", client);
|
||||
thumbMediaId = coverResp.media_id;
|
||||
console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
|
||||
} else if (firstCoverMediaId && args.articleType === "news") {
|
||||
thumbMediaId = firstCoverMediaId;
|
||||
console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`);
|
||||
}
|
||||
|
||||
if (args.articleType === "news" && !thumbMediaId) {
|
||||
throw new Error("No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content.");
|
||||
}
|
||||
|
||||
if (args.articleType === "newspic" && imageMediaIds.length === 0) {
|
||||
throw new Error("newspic requires at least one image in content.");
|
||||
}
|
||||
|
||||
console.error("[wechat-api] Publishing to draft...");
|
||||
const result = await publishToDraft({
|
||||
title,
|
||||
author: author || undefined,
|
||||
digest: digest || undefined,
|
||||
content: htmlContent,
|
||||
thumbMediaId,
|
||||
articleType: args.articleType,
|
||||
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
|
||||
needOpenComment: resolved.need_open_comment,
|
||||
onlyFansCanComment: resolved.only_fans_can_comment,
|
||||
}, accessToken, client);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
media_id: result.media_id,
|
||||
title,
|
||||
articleType: args.articleType,
|
||||
method,
|
||||
}, null, 2));
|
||||
|
||||
console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`);
|
||||
};
|
||||
|
||||
if (useRemote) {
|
||||
const remoteConfig = normalizeRemoteConfig(buildRemoteConfig(args, resolved));
|
||||
console.error(
|
||||
`[wechat-api] Remote publishing via ${remoteConfig.user}@${remoteConfig.host}:${remoteConfig.port}`,
|
||||
);
|
||||
await withSshTunnel(remoteConfig, async (client) => {
|
||||
await publishWith(client);
|
||||
});
|
||||
} else {
|
||||
await publishWith(wechatHttp);
|
||||
}
|
||||
|
||||
if (args.articleType === "news" && !thumbMediaId) {
|
||||
console.error("Error: No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.articleType === "newspic" && imageMediaIds.length === 0) {
|
||||
console.error("Error: newspic requires at least one image in content.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error("[wechat-api] Publishing to draft...");
|
||||
const result = await publishToDraft({
|
||||
title,
|
||||
author: author || undefined,
|
||||
digest: digest || undefined,
|
||||
content: htmlContent,
|
||||
thumbMediaId,
|
||||
articleType: args.articleType,
|
||||
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
|
||||
needOpenComment: resolved.need_open_comment,
|
||||
onlyFansCanComment: resolved.only_fans_can_comment,
|
||||
}, accessToken);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
media_id: result.media_id,
|
||||
title,
|
||||
articleType: args.articleType,
|
||||
}, null, 2));
|
||||
|
||||
console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`);
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const articleScript = fs.readFileSync(path.join(__dirname, "wechat-article.ts"), "utf8");
|
||||
|
||||
test("browser article paste uses CDP-targeted paste instead of global macOS keystrokes", () => {
|
||||
assert.equal(
|
||||
articleScript.includes('tell application "System Events" to keystroke "v" using command down'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("browser article publishing verifies the title before saving drafts", () => {
|
||||
assert.match(articleScript, /verifyTitleUnchangedBeforeSave/);
|
||||
assert.match(articleScript, /Title was modified during paste/);
|
||||
});
|
||||
|
||||
test("browser article body insertion does not paste HTML through the active form field", () => {
|
||||
assert.match(articleScript, /insertHtmlIntoEditorFromFile/);
|
||||
assert.doesNotMatch(articleScript, /await copyHtmlFromBrowser\(cdp, effectiveHtmlFile, contentImages\)/);
|
||||
});
|
||||
|
||||
test("browser article body operations target the body ProseMirror, not the title ProseMirror", () => {
|
||||
assert.match(articleScript, /BODY_EDITOR_SELECTOR = '\.rich_media_content \.ProseMirror'/);
|
||||
assert.doesNotMatch(articleScript, /clickElement\(session, '\\.ProseMirror'\)/);
|
||||
});
|
||||
|
||||
test("browser article reuses the selected account Chrome profile before launching", () => {
|
||||
assert.match(articleScript, /await findExistingChromeDebugPort\(profileDir\)/);
|
||||
});
|
||||
|
||||
test("browser article inserts inline images through WeChat local upload instead of clipboard paste", () => {
|
||||
assert.match(articleScript, /uploadImageThroughFileInput/);
|
||||
assert.match(articleScript, /DOM\.setFileInputFiles/);
|
||||
assert.doesNotMatch(articleScript, /await copyImageToClipboard\(img\.localPath\)/);
|
||||
});
|
||||
|
||||
test("browser article uploads original images before fallback processing", () => {
|
||||
const rawUploadIndex = articleScript.indexOf("await uploadImagePathThroughFileInput(session, absolutePath, beforeCount)");
|
||||
const fallbackIndex = articleScript.indexOf("const fallback = await prepareFallbackWechatBodyImageUpload(absolutePath)");
|
||||
|
||||
assert.notEqual(rawUploadIndex, -1);
|
||||
assert.notEqual(fallbackIndex, -1);
|
||||
assert.ok(rawUploadIndex < fallbackIndex);
|
||||
assert.match(articleScript, /prepareWechatBodyImageUpload/);
|
||||
assert.match(articleScript, /Raw image upload failed, retrying with fallback processing/);
|
||||
});
|
||||
|
||||
test("browser article waits for a saved draft appmsgid before reporting success", () => {
|
||||
assert.match(articleScript, /waitForDraftSaved/);
|
||||
assert.match(articleScript, /appmsgid/);
|
||||
assert.doesNotMatch(articleScript, /Waiting for save confirmation/);
|
||||
});
|
||||
@@ -1,12 +1,15 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { launchChrome, tryConnectExisting, findExistingChromeDebugPort, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, getAccountProfileDir, type ChromeSession, type CdpConnection } from './cdp.ts';
|
||||
import { loadWechatExtendConfig, resolveAccount } from './wechat-extend-config.ts';
|
||||
import { prepareWechatBodyImageUpload } from './wechat-image-processor.ts';
|
||||
|
||||
const WECHAT_URL = 'https://mp.weixin.qq.com/';
|
||||
const BODY_EDITOR_SELECTOR = '.rich_media_content .ProseMirror';
|
||||
|
||||
interface ImageInfo {
|
||||
placeholder: string;
|
||||
@@ -31,7 +34,98 @@ interface ArticleOptions {
|
||||
cdpPort?: number;
|
||||
}
|
||||
|
||||
async function sendQrToTelegram(session: ChromeSession): Promise<void> {
|
||||
const botToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
const chatId = process.env.TELEGRAM_CHAT_ID;
|
||||
if (!botToken || !chatId) return;
|
||||
|
||||
// Wait for QR to render before extracting
|
||||
await sleep(2000);
|
||||
|
||||
try {
|
||||
// Try to extract QR image from DOM first (avoids full-page screenshot noise)
|
||||
const domResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const selectors = [
|
||||
'.login__type__container__scan img',
|
||||
'.login_img img',
|
||||
'#login_container img',
|
||||
'.qrcode img',
|
||||
'img[src*="qrcode"]',
|
||||
'img[src*="login"]',
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el?.src && !el.src.startsWith('data:,')) return el.src.startsWith('data:') ? el.src : 'url:' + el.src;
|
||||
}
|
||||
const canvas = document.querySelector('canvas');
|
||||
if (canvas) try { return canvas.toDataURL('image/png'); } catch {}
|
||||
return '';
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId: session.sessionId });
|
||||
|
||||
const raw = (domResult.result.value as string) ?? '';
|
||||
let imgBuffer: Buffer;
|
||||
|
||||
if (raw.startsWith('data:image')) {
|
||||
imgBuffer = Buffer.from(raw.split(',')[1] ?? '', 'base64');
|
||||
} else if (raw.startsWith('url:')) {
|
||||
// Fetch inside Chrome to carry WeChat session cookies
|
||||
const imgUrl = raw.slice(4);
|
||||
const inBrowserFetch = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(async () => {
|
||||
const resp = await fetch(${JSON.stringify(imgUrl)}, { credentials: 'include' });
|
||||
const buf = await resp.arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
let b = '';
|
||||
for (let i = 0; i < bytes.length; i++) b += String.fromCharCode(bytes[i]);
|
||||
return btoa(b);
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
}, { sessionId: session.sessionId });
|
||||
imgBuffer = Buffer.from((inBrowserFetch.result.value as string) ?? '', 'base64');
|
||||
} else {
|
||||
// Fallback: viewport screenshot (smaller than full-page; QR is usually in viewport)
|
||||
const screenshotResp = await session.cdp.send<{ data: string }>(
|
||||
'Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }, { sessionId: session.sessionId }
|
||||
);
|
||||
imgBuffer = Buffer.from(screenshotResp.data ?? '', 'base64');
|
||||
}
|
||||
|
||||
const boundary = `tgboundary${Date.now()}`;
|
||||
const parts: Buffer[] = [
|
||||
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`),
|
||||
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\nWeChat QR code — please scan to log in\r\n`),
|
||||
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="photo"; filename="qr.png"\r\nContent-Type: image/png\r\n\r\n`),
|
||||
imgBuffer,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||
];
|
||||
const tgResp = await fetch(`https://api.telegram.org/bot${botToken}/sendPhoto`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
|
||||
body: Buffer.concat(parts),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const tgJson = await tgResp.json() as { ok: boolean; description?: string };
|
||||
if (tgJson.ok) {
|
||||
console.log('[wechat] QR code sent to Telegram.');
|
||||
} else {
|
||||
console.error('[wechat] Telegram send failed:', tgJson.description);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[wechat] Failed to send QR to Telegram:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForLogin(session: ChromeSession, timeoutMs = 120_000): Promise<boolean> {
|
||||
// Notify via Telegram if configured (no-op when env vars absent)
|
||||
await sendQrToTelegram(session);
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const url = await evaluate<string>(session, 'window.location.href');
|
||||
@@ -105,27 +199,27 @@ async function pasteInEditor(session: ChromeSession): Promise<void> {
|
||||
}
|
||||
|
||||
async function sendCopy(cdp?: CdpConnection, sessionId?: string): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
if (cdp && sessionId) {
|
||||
const modifiers = process.platform === 'darwin' ? 4 : 2;
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'c', code: 'KeyC', modifiers, windowsVirtualKeyCode: 67 }, { sessionId });
|
||||
await sleep(50);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'c', code: 'KeyC', modifiers, windowsVirtualKeyCode: 67 }, { sessionId });
|
||||
} else if (process.platform === 'darwin') {
|
||||
spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "c" using command down']);
|
||||
} else if (process.platform === 'linux') {
|
||||
spawnSync('xdotool', ['key', 'ctrl+c']);
|
||||
} else if (cdp && sessionId) {
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'c', code: 'KeyC', modifiers: 2, windowsVirtualKeyCode: 67 }, { sessionId });
|
||||
await sleep(50);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'c', code: 'KeyC', modifiers: 2, windowsVirtualKeyCode: 67 }, { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPaste(cdp?: CdpConnection, sessionId?: string): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
spawnSync('osascript', ['-e', 'tell application "System Events" to keystroke "v" using command down']);
|
||||
} else if (process.platform === 'linux') {
|
||||
spawnSync('xdotool', ['key', 'ctrl+v']);
|
||||
} else if (cdp && sessionId) {
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers: 2, windowsVirtualKeyCode: 86 }, { sessionId });
|
||||
await sleep(50);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers: 2, windowsVirtualKeyCode: 86 }, { sessionId });
|
||||
if (!cdp || !sessionId) {
|
||||
throw new Error('Targeted paste requires a Chrome DevTools session');
|
||||
}
|
||||
|
||||
const modifiers = process.platform === 'darwin' ? 4 : 2;
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId });
|
||||
await sleep(50);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId });
|
||||
}
|
||||
|
||||
async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string, contentImages: ImageInfo[] = []): Promise<void> {
|
||||
@@ -180,6 +274,10 @@ async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string, con
|
||||
}, { sessionId });
|
||||
await sleep(300);
|
||||
|
||||
console.log('[wechat] Activating HTML tab for copy...');
|
||||
await cdp.send('Target.activateTarget', { targetId });
|
||||
await sleep(300);
|
||||
|
||||
console.log('[wechat] Copying content...');
|
||||
await sendCopy(cdp, sessionId);
|
||||
await sleep(1000);
|
||||
@@ -189,11 +287,92 @@ async function copyHtmlFromBrowser(cdp: CdpConnection, htmlFilePath: string, con
|
||||
}
|
||||
|
||||
async function pasteFromClipboardInEditor(session: ChromeSession): Promise<void> {
|
||||
console.log('[wechat] Activating editor tab for paste...');
|
||||
if (session.targetId) {
|
||||
await session.cdp.send('Target.activateTarget', { targetId: session.targetId });
|
||||
await sleep(300);
|
||||
}
|
||||
console.log('[wechat] Pasting content...');
|
||||
await sendPaste(session.cdp, session.sessionId);
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
async function insertHtmlIntoEditorFromFile(
|
||||
session: ChromeSession,
|
||||
htmlFilePath: string,
|
||||
contentImages: ImageInfo[] = [],
|
||||
): Promise<void> {
|
||||
const absolutePath = path.isAbsolute(htmlFilePath) ? htmlFilePath : path.resolve(process.cwd(), htmlFilePath);
|
||||
const html = fs.readFileSync(absolutePath, 'utf8');
|
||||
const replacements = contentImages.map(img => ({ placeholder: img.placeholder, localPath: img.localPath }));
|
||||
|
||||
const result = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||
if (!editor) return JSON.stringify({ ok: false, reason: 'editor-missing' });
|
||||
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = ${JSON.stringify(html)};
|
||||
const replacements = ${JSON.stringify(replacements)};
|
||||
|
||||
for (const img of Array.from(template.content.querySelectorAll('img'))) {
|
||||
const src = img.getAttribute('src') || '';
|
||||
const localPath = img.getAttribute('data-local-path') || '';
|
||||
const replacement = replacements.find((item) => item.placeholder === src || item.localPath === localPath);
|
||||
if (replacement) {
|
||||
img.replaceWith(document.createTextNode(replacement.placeholder));
|
||||
}
|
||||
}
|
||||
|
||||
const output = template.content.querySelector('#output');
|
||||
const wrapper = document.createElement('div');
|
||||
if (output) {
|
||||
wrapper.innerHTML = output.innerHTML;
|
||||
} else {
|
||||
wrapper.appendChild(template.content.cloneNode(true));
|
||||
}
|
||||
|
||||
editor.focus();
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
range.deleteContents();
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
const inserted = document.execCommand('insertHTML', false, wrapper.innerHTML);
|
||||
editor.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true,
|
||||
inputType: 'insertHTML',
|
||||
data: wrapper.innerText || ''
|
||||
}));
|
||||
|
||||
return JSON.stringify({
|
||||
ok: inserted || (editor.innerText || '').trim().length > 0,
|
||||
textLength: (editor.innerText || '').trim().length
|
||||
});
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId: session.sessionId });
|
||||
|
||||
const parsed = JSON.parse(result.result.value || '{}') as { ok?: boolean; reason?: string; textLength?: number };
|
||||
if (!parsed.ok) {
|
||||
throw new Error(`Failed to insert HTML into body editor${parsed.reason ? `: ${parsed.reason}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyTitleUnchangedBeforeSave(session: ChromeSession, expectedTitle: string): Promise<void> {
|
||||
if (!expectedTitle) return;
|
||||
|
||||
const actualTitle = await evaluate<string>(session, `document.querySelector('#title')?.value || ''`);
|
||||
if (actualTitle !== expectedTitle) {
|
||||
throw new Error(`Title was modified during paste. Expected: "${expectedTitle}", got: "${actualTitle}"`);
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareEditorPasteTarget(
|
||||
session: ChromeSession,
|
||||
context: string,
|
||||
@@ -203,19 +382,24 @@ async function prepareEditorPasteTarget(
|
||||
await sleep(100);
|
||||
|
||||
if (options.clickEditor) {
|
||||
await clickElement(session, '.ProseMirror');
|
||||
await clickElement(session, BODY_EDITOR_SELECTOR);
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
const ready = await evaluate<boolean>(session, `
|
||||
(function() {
|
||||
const editor = document.querySelector('.ProseMirror');
|
||||
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||
if (!editor) return false;
|
||||
|
||||
const active = document.activeElement;
|
||||
const selection = window.getSelection();
|
||||
const selectionInEditor = !!selection && selection.rangeCount > 0 && !!selection.anchorNode && editor.contains(selection.anchorNode);
|
||||
const focusInEditor = !!active && (active === editor || editor.contains(active));
|
||||
const activeIsUnsafeInput = !!active && (
|
||||
active.matches?.('#title, #author, #js_description') ||
|
||||
((active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') && !editor.contains(active))
|
||||
);
|
||||
if (activeIsUnsafeInput) return false;
|
||||
if (selectionInEditor || focusInEditor) return true;
|
||||
|
||||
if (${JSON.stringify(Boolean(options.clickEditor))}) {
|
||||
@@ -338,7 +522,7 @@ async function selectAndReplacePlaceholder(session: ChromeSession, placeholder:
|
||||
const result = await session.cdp.send<{ result: { value: boolean } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const editor = document.querySelector('.ProseMirror');
|
||||
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||
if (!editor) return false;
|
||||
|
||||
const placeholder = ${JSON.stringify(placeholder)};
|
||||
@@ -356,6 +540,7 @@ async function selectAndReplacePlaceholder(session: ChromeSession, placeholder:
|
||||
// Exact match if next char is not a digit
|
||||
if (charAfter === undefined || !/\\d/.test(charAfter)) {
|
||||
node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
editor.focus();
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
@@ -386,7 +571,7 @@ async function pressDeleteKey(session: ChromeSession): Promise<void> {
|
||||
async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<boolean> {
|
||||
const removed = await evaluate<boolean>(session, `
|
||||
(function() {
|
||||
const editor = document.querySelector('.ProseMirror');
|
||||
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||
if (!editor) return false;
|
||||
|
||||
const sel = window.getSelection();
|
||||
@@ -448,6 +633,188 @@ async function removeExtraEmptyLineAfterImage(session: ChromeSession): Promise<b
|
||||
return removed;
|
||||
}
|
||||
|
||||
async function getBodyImageCount(session: ChromeSession): Promise<number> {
|
||||
return await evaluate<number>(session, `
|
||||
(function() {
|
||||
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||
if (!editor) return 0;
|
||||
return Array.from(editor.querySelectorAll('img')).filter((img) => !img.classList.contains('ProseMirror-separator')).length;
|
||||
})()
|
||||
`);
|
||||
}
|
||||
|
||||
async function waitForBodyImageCount(session: ChromeSession, minimumCount: number, timeoutMs = 45_000): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const count = await getBodyImageCount(session);
|
||||
if (count >= minimumCount) return true;
|
||||
await sleep(500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function inferImageContentType(imagePath: string): string {
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.gif':
|
||||
return 'image/gif';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
case '.bmp':
|
||||
return 'image/bmp';
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadImagePathThroughFileInput(
|
||||
session: ChromeSession,
|
||||
absolutePath: string,
|
||||
beforeCount: number,
|
||||
): Promise<void> {
|
||||
const documentNode = await session.cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {
|
||||
depth: -1,
|
||||
pierce: true,
|
||||
}, { sessionId: session.sessionId });
|
||||
const inputNode = await session.cdp.send<{ nodeId: number }>('DOM.querySelector', {
|
||||
nodeId: documentNode.root.nodeId,
|
||||
selector: 'input[type="file"][accept*="image"]',
|
||||
}, { sessionId: session.sessionId });
|
||||
|
||||
if (!inputNode.nodeId) throw new Error('WeChat local image upload input not found');
|
||||
|
||||
await session.cdp.send('DOM.setFileInputFiles', {
|
||||
nodeId: inputNode.nodeId,
|
||||
files: [absolutePath],
|
||||
}, { sessionId: session.sessionId });
|
||||
|
||||
const inserted = await waitForBodyImageCount(session, beforeCount + 1);
|
||||
if (!inserted) {
|
||||
const afterCount = await getBodyImageCount(session);
|
||||
throw new Error(`Image upload did not insert into editor: ${path.basename(absolutePath)} (${beforeCount} -> ${afterCount})`);
|
||||
}
|
||||
}
|
||||
|
||||
interface FallbackUploadImage {
|
||||
uploadPath: string;
|
||||
wasProcessed: boolean;
|
||||
processingNotes: string[];
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
async function prepareFallbackWechatBodyImageUpload(absolutePath: string): Promise<FallbackUploadImage> {
|
||||
const buffer = fs.readFileSync(absolutePath);
|
||||
const prepared = await prepareWechatBodyImageUpload({
|
||||
buffer,
|
||||
filename: path.basename(absolutePath),
|
||||
contentType: inferImageContentType(absolutePath),
|
||||
fileExt: path.extname(absolutePath).toLowerCase(),
|
||||
fileSize: buffer.length,
|
||||
});
|
||||
|
||||
if (!prepared.wasProcessed) {
|
||||
return {
|
||||
uploadPath: absolutePath,
|
||||
wasProcessed: false,
|
||||
processingNotes: [],
|
||||
cleanup: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-body-image-'));
|
||||
const uploadPath = path.join(tempDir, prepared.filename);
|
||||
fs.writeFileSync(uploadPath, prepared.buffer);
|
||||
|
||||
return {
|
||||
uploadPath,
|
||||
wasProcessed: true,
|
||||
processingNotes: prepared.processingNotes,
|
||||
cleanup: () => fs.rmSync(tempDir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadImageThroughFileInput(session: ChromeSession, imagePath: string): Promise<void> {
|
||||
const absolutePath = path.isAbsolute(imagePath) ? imagePath : path.resolve(process.cwd(), imagePath);
|
||||
if (!fs.existsSync(absolutePath)) throw new Error(`Image file not found: ${absolutePath}`);
|
||||
|
||||
const beforeCount = await getBodyImageCount(session);
|
||||
try {
|
||||
await uploadImagePathThroughFileInput(session, absolutePath, beforeCount);
|
||||
return;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('local image upload input not found')) throw err;
|
||||
|
||||
const currentCount = await getBodyImageCount(session);
|
||||
if (currentCount > beforeCount) return;
|
||||
|
||||
console.warn(`[wechat] Raw image upload failed, retrying with fallback processing: ${message}`);
|
||||
const fallback = await prepareFallbackWechatBodyImageUpload(absolutePath);
|
||||
const notes = fallback.processingNotes.length > 0 ? ` (${fallback.processingNotes.join('; ')})` : '';
|
||||
console.log(`[wechat] Retrying image upload with ${fallback.wasProcessed ? 'processed' : 'original'} file: ${path.basename(fallback.uploadPath)}${notes}`);
|
||||
|
||||
try {
|
||||
await uploadImagePathThroughFileInput(session, fallback.uploadPath, currentCount);
|
||||
} finally {
|
||||
fallback.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface DraftSaveStatus {
|
||||
appmsgid: string;
|
||||
isLoading: boolean;
|
||||
submitText: string;
|
||||
url: string;
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
async function getDraftSaveStatus(session: ChromeSession): Promise<DraftSaveStatus> {
|
||||
const raw = await evaluate<string>(session, `
|
||||
(function() {
|
||||
const submit = document.querySelector('#js_submit');
|
||||
const button = submit?.querySelector('button');
|
||||
const url = location.href;
|
||||
const appmsgid = new URL(url).searchParams.get('appmsgid') || '';
|
||||
const messages = Array.from(document.querySelectorAll('.weui-desktop-toast, .weui-desktop-toptips, .js_tips'))
|
||||
.map((el) => (el.innerText || el.textContent || '').trim())
|
||||
.filter(Boolean);
|
||||
return JSON.stringify({
|
||||
appmsgid,
|
||||
isLoading: !!submit?.classList.contains('btn_loading') || !!button?.disabled,
|
||||
submitText: (submit?.innerText || '').trim(),
|
||||
url,
|
||||
messages
|
||||
});
|
||||
})()
|
||||
`);
|
||||
return JSON.parse(raw || '{}') as DraftSaveStatus;
|
||||
}
|
||||
|
||||
async function waitForDraftSaved(session: ChromeSession, timeoutMs = 60_000): Promise<string> {
|
||||
const start = Date.now();
|
||||
let lastStatus: DraftSaveStatus | null = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
lastStatus = await getDraftSaveStatus(session);
|
||||
if (lastStatus.appmsgid && !lastStatus.isLoading) return lastStatus.appmsgid;
|
||||
|
||||
const relevantFailure = lastStatus.messages.find((message) => /保存.*失败|草稿.*失败|save.*fail/i.test(message));
|
||||
if (relevantFailure) throw new Error(`Draft save failed: ${relevantFailure}`);
|
||||
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
throw new Error(`Draft save did not complete${lastStatus ? `: ${JSON.stringify(lastStatus)}` : ''}`);
|
||||
}
|
||||
|
||||
export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
const { title, content, htmlFile, markdownFile, theme, color, citeStatus = true, author, summary, images = [], submit = false, profileDir, cdpPort } = options;
|
||||
let { contentImages = [] } = options;
|
||||
@@ -491,7 +858,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
let chrome: ReturnType<typeof import('node:child_process').spawn> | null = null;
|
||||
|
||||
// Try connecting to existing Chrome: explicit port > auto-detect > launch new
|
||||
const portToTry = cdpPort ?? await findExistingChromeDebugPort();
|
||||
const portToTry = cdpPort ?? await findExistingChromeDebugPort(profileDir);
|
||||
if (portToTry) {
|
||||
const existing = await tryConnectExisting(portToTry);
|
||||
if (existing) {
|
||||
@@ -548,7 +915,8 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
|
||||
const url = await evaluate<string>(session, 'window.location.href');
|
||||
if (!url.includes('/cgi-bin/')) {
|
||||
console.log('[wechat] Not logged in. Please scan QR code...');
|
||||
const hasTelegram = !!(process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID);
|
||||
console.log(`[wechat] Not logged in. Please scan QR code...${hasTelegram ? ' (sending to Telegram)' : ''}`);
|
||||
const loggedIn = await waitForLogin(session);
|
||||
if (!loggedIn) throw new Error('Login timeout');
|
||||
}
|
||||
@@ -579,7 +947,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
console.log('[wechat] Waiting for editor to load...');
|
||||
const editorLoaded = await waitForElement(session, '#title', 30_000);
|
||||
if (!editorLoaded) throw new Error('Editor did not load (#title not found)');
|
||||
await waitForElement(session, '.ProseMirror', 15_000);
|
||||
await waitForElement(session, BODY_EDITOR_SELECTOR, 15_000);
|
||||
await sleep(2000);
|
||||
|
||||
if (effectiveTitle) {
|
||||
@@ -604,25 +972,23 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
}
|
||||
|
||||
console.log('[wechat] Clicking on editor...');
|
||||
await clickElement(session, '.ProseMirror');
|
||||
await clickElement(session, BODY_EDITOR_SELECTOR);
|
||||
await sleep(1000);
|
||||
|
||||
console.log('[wechat] Ensuring editor focus...');
|
||||
await clickElement(session, '.ProseMirror');
|
||||
await clickElement(session, BODY_EDITOR_SELECTOR);
|
||||
await sleep(500);
|
||||
|
||||
if (effectiveHtmlFile && fs.existsSync(effectiveHtmlFile)) {
|
||||
console.log(`[wechat] Copying HTML content from: ${effectiveHtmlFile}`);
|
||||
await copyHtmlFromBrowser(cdp, effectiveHtmlFile, contentImages);
|
||||
await sleep(500);
|
||||
console.log(`[wechat] Inserting HTML content from: ${effectiveHtmlFile}`);
|
||||
await prepareEditorPasteTarget(session, 'body content paste', { clickEditor: true });
|
||||
console.log('[wechat] Pasting into editor...');
|
||||
await pasteFromClipboardInEditor(session);
|
||||
await insertHtmlIntoEditorFromFile(session, effectiveHtmlFile, contentImages);
|
||||
await sleep(3000);
|
||||
await verifyTitleUnchangedBeforeSave(session, effectiveTitle);
|
||||
|
||||
const editorHasContent = await evaluate<boolean>(session, `
|
||||
(function() {
|
||||
const editor = document.querySelector('.ProseMirror');
|
||||
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||
if (!editor) return false;
|
||||
const text = editor.innerText?.trim() || '';
|
||||
return text.length > 0;
|
||||
@@ -648,18 +1014,15 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
|
||||
await sleep(500);
|
||||
|
||||
console.log(`[wechat] Copying image: ${path.basename(img.localPath)}`);
|
||||
await copyImageToClipboard(img.localPath);
|
||||
await sleep(300);
|
||||
|
||||
console.log('[wechat] Deleting placeholder with Backspace...');
|
||||
await pressDeleteKey(session);
|
||||
await sleep(200);
|
||||
|
||||
console.log('[wechat] Pasting image...');
|
||||
await prepareEditorPasteTarget(session, 'inline image paste');
|
||||
await pasteFromClipboardInEditor(session);
|
||||
await sleep(3000);
|
||||
console.log(`[wechat] Uploading image: ${path.basename(img.localPath)}`);
|
||||
await prepareEditorPasteTarget(session, 'inline image upload');
|
||||
await uploadImageThroughFileInput(session, img.localPath);
|
||||
await sleep(1000);
|
||||
await verifyTitleUnchangedBeforeSave(session, effectiveTitle);
|
||||
await removeExtraEmptyLineAfterImage(session);
|
||||
}
|
||||
console.log('[wechat] All images inserted.');
|
||||
@@ -667,12 +1030,10 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
} else if (content) {
|
||||
for (const img of images) {
|
||||
if (fs.existsSync(img)) {
|
||||
console.log(`[wechat] Pasting image: ${img}`);
|
||||
await copyImageToClipboard(img);
|
||||
await sleep(500);
|
||||
await prepareEditorPasteTarget(session, 'leading image paste');
|
||||
await pasteInEditor(session);
|
||||
await sleep(2000);
|
||||
console.log(`[wechat] Uploading image: ${img}`);
|
||||
await prepareEditorPasteTarget(session, 'leading image upload');
|
||||
await uploadImageThroughFileInput(session, img);
|
||||
await sleep(1000);
|
||||
await removeExtraEmptyLineAfterImage(session);
|
||||
}
|
||||
}
|
||||
@@ -684,7 +1045,7 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
|
||||
const editorHasContent = await evaluate<boolean>(session, `
|
||||
(function() {
|
||||
const editor = document.querySelector('.ProseMirror');
|
||||
const editor = document.querySelector(${JSON.stringify(BODY_EDITOR_SELECTOR)});
|
||||
if (!editor) return false;
|
||||
const text = editor.innerText?.trim() || '';
|
||||
return text.length > 0;
|
||||
@@ -721,17 +1082,12 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
await verifyTitleUnchangedBeforeSave(session, effectiveTitle);
|
||||
|
||||
console.log('[wechat] Saving as draft...');
|
||||
await evaluate(session, `document.querySelector('#js_submit button').click()`);
|
||||
await sleep(3000);
|
||||
|
||||
const saved = await evaluate<boolean>(session, `!!document.querySelector('.weui-desktop-toast')`);
|
||||
if (saved) {
|
||||
console.log('[wechat] Draft saved successfully!');
|
||||
} else {
|
||||
console.log('[wechat] Waiting for save confirmation...');
|
||||
await sleep(5000);
|
||||
}
|
||||
const appmsgid = await waitForDraftSaved(session);
|
||||
console.log(`[wechat] Draft saved successfully! appmsgid: ${appmsgid}`);
|
||||
|
||||
console.log('[wechat] Done. Browser window left open.');
|
||||
} finally {
|
||||
|
||||
@@ -5,7 +5,11 @@ import path from "node:path";
|
||||
import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import { loadCredentials } from "./wechat-extend-config.ts";
|
||||
import {
|
||||
loadCredentials,
|
||||
loadWechatExtendConfig,
|
||||
resolveAccount,
|
||||
} from "./wechat-extend-config.ts";
|
||||
|
||||
function useCwd(t: TestContext, cwd: string): void {
|
||||
const previous = process.cwd();
|
||||
@@ -73,6 +77,28 @@ async function writeEnvFile(root: string, content: string): Promise<void> {
|
||||
await fs.writeFile(envPath, content);
|
||||
}
|
||||
|
||||
async function writeExtendFile(root: string, content: string): Promise<void> {
|
||||
const extendPath = path.join(root, ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md");
|
||||
await fs.mkdir(path.dirname(extendPath), { recursive: true });
|
||||
await fs.writeFile(extendPath, content);
|
||||
}
|
||||
|
||||
function useXdgConfigHome(t: TestContext, value: string | undefined): void {
|
||||
const previous = process.env.XDG_CONFIG_HOME;
|
||||
if (value === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
} else {
|
||||
process.env.XDG_CONFIG_HOME = value;
|
||||
}
|
||||
t.after(() => {
|
||||
if (previous === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
return;
|
||||
}
|
||||
process.env.XDG_CONFIG_HOME = previous;
|
||||
});
|
||||
}
|
||||
|
||||
test("loadCredentials selects the first complete source without mixing values across sources", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-creds-home-");
|
||||
@@ -119,6 +145,149 @@ test("loadCredentials prefers a complete process.env pair over lower-priority fi
|
||||
assert.deepEqual(credentials.skippedSources, []);
|
||||
});
|
||||
|
||||
test("resolveAccount returns global remote_publish_* values when no account is configured", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||
|
||||
useCwd(t, cwdRoot);
|
||||
useHome(t, homeRoot);
|
||||
useXdgConfigHome(t, undefined);
|
||||
|
||||
await writeExtendFile(
|
||||
cwdRoot,
|
||||
[
|
||||
"default_publish_method: remote-api",
|
||||
"remote_publish_host: bastion.example.com",
|
||||
"remote_publish_user: deploy",
|
||||
"remote_publish_port: 2222",
|
||||
"remote_publish_identity_file: /home/me/.ssh/id_ed25519",
|
||||
"remote_publish_known_hosts_file: /home/me/.ssh/known_hosts",
|
||||
"remote_publish_strict_host_key_checking: accept-new",
|
||||
"remote_publish_connect_timeout: 12",
|
||||
"remote_publish_proxy_jump: jump.example.com",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const config = loadWechatExtendConfig();
|
||||
const resolved = resolveAccount(config);
|
||||
|
||||
assert.equal(resolved.default_publish_method, "remote-api");
|
||||
assert.equal(resolved.remote_publish_host, "bastion.example.com");
|
||||
assert.equal(resolved.remote_publish_user, "deploy");
|
||||
assert.equal(resolved.remote_publish_port, 2222);
|
||||
assert.equal(resolved.remote_publish_identity_file, "/home/me/.ssh/id_ed25519");
|
||||
assert.equal(resolved.remote_publish_known_hosts_file, "/home/me/.ssh/known_hosts");
|
||||
assert.equal(resolved.remote_publish_strict_host_key_checking, "accept-new");
|
||||
assert.equal(resolved.remote_publish_connect_timeout, 12);
|
||||
assert.equal(resolved.remote_publish_proxy_jump, "jump.example.com");
|
||||
});
|
||||
|
||||
test("resolveAccount lets account-level remote_publish_* override globals", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||
|
||||
useCwd(t, cwdRoot);
|
||||
useHome(t, homeRoot);
|
||||
useXdgConfigHome(t, undefined);
|
||||
|
||||
await writeExtendFile(
|
||||
cwdRoot,
|
||||
[
|
||||
"default_publish_method: browser",
|
||||
"remote_publish_host: global.example.com",
|
||||
"remote_publish_user: deploy",
|
||||
"remote_publish_port: 22",
|
||||
"accounts:",
|
||||
" - name: Primary",
|
||||
" alias: primary",
|
||||
" default: true",
|
||||
" remote_publish_host: primary.example.com",
|
||||
" remote_publish_user: primary-user",
|
||||
" remote_publish_port: 2200",
|
||||
" remote_publish_identity_file: /p/id_primary",
|
||||
" - name: Secondary",
|
||||
" alias: secondary",
|
||||
" remote_publish_proxy_jump: jump.example.com",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const config = loadWechatExtendConfig();
|
||||
const primary = resolveAccount(config, "primary");
|
||||
assert.equal(primary.alias, "primary");
|
||||
assert.equal(primary.remote_publish_host, "primary.example.com");
|
||||
assert.equal(primary.remote_publish_user, "primary-user");
|
||||
assert.equal(primary.remote_publish_port, 2200);
|
||||
assert.equal(primary.remote_publish_identity_file, "/p/id_primary");
|
||||
assert.equal(primary.remote_publish_proxy_jump, undefined);
|
||||
|
||||
const secondary = resolveAccount(config, "secondary");
|
||||
assert.equal(secondary.alias, "secondary");
|
||||
assert.equal(secondary.remote_publish_host, "global.example.com");
|
||||
assert.equal(secondary.remote_publish_user, "deploy");
|
||||
assert.equal(secondary.remote_publish_port, 22);
|
||||
assert.equal(secondary.remote_publish_proxy_jump, "jump.example.com");
|
||||
});
|
||||
|
||||
test("loadWechatExtendConfig throws on invalid remote_publish_port", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||
|
||||
useCwd(t, cwdRoot);
|
||||
useHome(t, homeRoot);
|
||||
useXdgConfigHome(t, undefined);
|
||||
|
||||
await writeExtendFile(
|
||||
cwdRoot,
|
||||
[
|
||||
"remote_publish_host: example.com",
|
||||
"remote_publish_port: 99999",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
assert.throws(() => loadWechatExtendConfig(), /Invalid remote_publish_port: 99999/);
|
||||
});
|
||||
|
||||
test("loadWechatExtendConfig throws on invalid remote_publish_connect_timeout", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||
|
||||
useCwd(t, cwdRoot);
|
||||
useHome(t, homeRoot);
|
||||
useXdgConfigHome(t, undefined);
|
||||
|
||||
await writeExtendFile(
|
||||
cwdRoot,
|
||||
[
|
||||
"remote_publish_host: example.com",
|
||||
"remote_publish_connect_timeout: 0",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
assert.throws(() => loadWechatExtendConfig(), /Invalid remote_publish_connect_timeout: 0/);
|
||||
});
|
||||
|
||||
test("loadWechatExtendConfig throws on invalid remote_publish_strict_host_key_checking", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||
|
||||
useCwd(t, cwdRoot);
|
||||
useHome(t, homeRoot);
|
||||
useXdgConfigHome(t, undefined);
|
||||
|
||||
await writeExtendFile(
|
||||
cwdRoot,
|
||||
[
|
||||
"remote_publish_host: example.com",
|
||||
"remote_publish_strict_host_key_checking: maybe",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => loadWechatExtendConfig(),
|
||||
/Invalid remote_publish_strict_host_key_checking: maybe/,
|
||||
);
|
||||
});
|
||||
|
||||
test("loadCredentials reports skipped incomplete sources when no complete pair exists", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-creds-home-");
|
||||
|
||||
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
export type StrictHostKeyChecking = "yes" | "no" | "accept-new";
|
||||
|
||||
export interface WechatAccount {
|
||||
name: string;
|
||||
alias: string;
|
||||
@@ -13,6 +15,14 @@ export interface WechatAccount {
|
||||
app_id?: string;
|
||||
app_secret?: string;
|
||||
chrome_profile_path?: string;
|
||||
remote_publish_host?: string;
|
||||
remote_publish_user?: string;
|
||||
remote_publish_port?: number;
|
||||
remote_publish_identity_file?: string;
|
||||
remote_publish_known_hosts_file?: string;
|
||||
remote_publish_strict_host_key_checking?: StrictHostKeyChecking;
|
||||
remote_publish_connect_timeout?: number;
|
||||
remote_publish_proxy_jump?: string;
|
||||
}
|
||||
|
||||
export interface WechatExtendConfig {
|
||||
@@ -23,6 +33,14 @@ export interface WechatExtendConfig {
|
||||
need_open_comment?: number;
|
||||
only_fans_can_comment?: number;
|
||||
chrome_profile_path?: string;
|
||||
remote_publish_host?: string;
|
||||
remote_publish_user?: string;
|
||||
remote_publish_port?: number;
|
||||
remote_publish_identity_file?: string;
|
||||
remote_publish_known_hosts_file?: string;
|
||||
remote_publish_strict_host_key_checking?: StrictHostKeyChecking;
|
||||
remote_publish_connect_timeout?: number;
|
||||
remote_publish_proxy_jump?: string;
|
||||
accounts?: WechatAccount[];
|
||||
}
|
||||
|
||||
@@ -36,6 +54,14 @@ export interface ResolvedAccount {
|
||||
app_id?: string;
|
||||
app_secret?: string;
|
||||
chrome_profile_path?: string;
|
||||
remote_publish_host?: string;
|
||||
remote_publish_user?: string;
|
||||
remote_publish_port?: number;
|
||||
remote_publish_identity_file?: string;
|
||||
remote_publish_known_hosts_file?: string;
|
||||
remote_publish_strict_host_key_checking?: StrictHostKeyChecking;
|
||||
remote_publish_connect_timeout?: number;
|
||||
remote_publish_proxy_jump?: string;
|
||||
}
|
||||
|
||||
function stripQuotes(s: string): string {
|
||||
@@ -46,6 +72,34 @@ function toBool01(v: string): number {
|
||||
return v === "1" || v === "true" ? 1 : 0;
|
||||
}
|
||||
|
||||
function homeDir(): string {
|
||||
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
||||
}
|
||||
|
||||
function parsePort(key: string, v: string): number {
|
||||
const n = Number.parseInt(v, 10);
|
||||
if (!Number.isFinite(n) || String(n) !== v.trim() || n < 1 || n > 65535) {
|
||||
throw new Error(`Invalid ${key}: ${v} (expected integer 1-65535)`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function parsePositiveInt(key: string, v: string): number {
|
||||
const n = Number.parseInt(v, 10);
|
||||
if (!Number.isFinite(n) || String(n) !== v.trim() || n <= 0) {
|
||||
throw new Error(`Invalid ${key}: ${v} (expected positive integer)`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function parseStrictHostKeyChecking(key: string, v: string): StrictHostKeyChecking {
|
||||
const lower = v.toLowerCase();
|
||||
if (lower === "yes" || lower === "no" || lower === "accept-new") {
|
||||
return lower;
|
||||
}
|
||||
throw new Error(`Invalid ${key}: ${v} (expected yes|no|accept-new)`);
|
||||
}
|
||||
|
||||
function parseWechatExtend(content: string): WechatExtendConfig {
|
||||
const config: WechatExtendConfig = {};
|
||||
const lines = content.split("\n");
|
||||
@@ -106,6 +160,14 @@ function parseWechatExtend(content: string): WechatExtendConfig {
|
||||
case "need_open_comment": config.need_open_comment = toBool01(val); break;
|
||||
case "only_fans_can_comment": config.only_fans_can_comment = toBool01(val); break;
|
||||
case "chrome_profile_path": config.chrome_profile_path = val; break;
|
||||
case "remote_publish_host": config.remote_publish_host = val; break;
|
||||
case "remote_publish_user": config.remote_publish_user = val; break;
|
||||
case "remote_publish_port": config.remote_publish_port = parsePort("remote_publish_port", val); break;
|
||||
case "remote_publish_identity_file": config.remote_publish_identity_file = val; break;
|
||||
case "remote_publish_known_hosts_file": config.remote_publish_known_hosts_file = val; break;
|
||||
case "remote_publish_strict_host_key_checking": config.remote_publish_strict_host_key_checking = parseStrictHostKeyChecking("remote_publish_strict_host_key_checking", val); break;
|
||||
case "remote_publish_connect_timeout": config.remote_publish_connect_timeout = parsePositiveInt("remote_publish_connect_timeout", val); break;
|
||||
case "remote_publish_proxy_jump": config.remote_publish_proxy_jump = val; break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +185,18 @@ function parseWechatExtend(content: string): WechatExtendConfig {
|
||||
app_id: a.app_id || undefined,
|
||||
app_secret: a.app_secret || undefined,
|
||||
chrome_profile_path: a.chrome_profile_path || undefined,
|
||||
remote_publish_host: a.remote_publish_host || undefined,
|
||||
remote_publish_user: a.remote_publish_user || undefined,
|
||||
remote_publish_port: a.remote_publish_port ? parsePort("remote_publish_port", a.remote_publish_port) : undefined,
|
||||
remote_publish_identity_file: a.remote_publish_identity_file || undefined,
|
||||
remote_publish_known_hosts_file: a.remote_publish_known_hosts_file || undefined,
|
||||
remote_publish_strict_host_key_checking: a.remote_publish_strict_host_key_checking
|
||||
? parseStrictHostKeyChecking("remote_publish_strict_host_key_checking", a.remote_publish_strict_host_key_checking)
|
||||
: undefined,
|
||||
remote_publish_connect_timeout: a.remote_publish_connect_timeout
|
||||
? parsePositiveInt("remote_publish_connect_timeout", a.remote_publish_connect_timeout)
|
||||
: undefined,
|
||||
remote_publish_proxy_jump: a.remote_publish_proxy_jump || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -133,18 +207,19 @@ export function loadWechatExtendConfig(): WechatExtendConfig {
|
||||
const paths = [
|
||||
path.join(process.cwd(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
|
||||
path.join(
|
||||
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
|
||||
process.env.XDG_CONFIG_HOME || path.join(homeDir(), ".config"),
|
||||
"baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"
|
||||
),
|
||||
path.join(os.homedir(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
|
||||
path.join(homeDir(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
|
||||
];
|
||||
for (const p of paths) {
|
||||
let content: string;
|
||||
try {
|
||||
const content = fs.readFileSync(p, "utf-8");
|
||||
return parseWechatExtend(content);
|
||||
content = fs.readFileSync(p, "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
return parseWechatExtend(content);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -168,6 +243,15 @@ export function resolveAccount(config: WechatExtendConfig, alias?: string): Reso
|
||||
app_id: acct?.app_id,
|
||||
app_secret: acct?.app_secret,
|
||||
chrome_profile_path: acct?.chrome_profile_path ?? config.chrome_profile_path,
|
||||
remote_publish_host: acct?.remote_publish_host ?? config.remote_publish_host,
|
||||
remote_publish_user: acct?.remote_publish_user ?? config.remote_publish_user,
|
||||
remote_publish_port: acct?.remote_publish_port ?? config.remote_publish_port,
|
||||
remote_publish_identity_file: acct?.remote_publish_identity_file ?? config.remote_publish_identity_file,
|
||||
remote_publish_known_hosts_file: acct?.remote_publish_known_hosts_file ?? config.remote_publish_known_hosts_file,
|
||||
remote_publish_strict_host_key_checking:
|
||||
acct?.remote_publish_strict_host_key_checking ?? config.remote_publish_strict_host_key_checking,
|
||||
remote_publish_connect_timeout: acct?.remote_publish_connect_timeout ?? config.remote_publish_connect_timeout,
|
||||
remote_publish_proxy_jump: acct?.remote_publish_proxy_jump ?? config.remote_publish_proxy_jump,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -273,7 +357,7 @@ function resolveCredentialSource(
|
||||
|
||||
export function loadCredentials(account?: ResolvedAccount): LoadedCredentials {
|
||||
const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env");
|
||||
const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env");
|
||||
const homeEnvPath = path.join(homeDir(), ".baoyu-skills", ".env");
|
||||
const cwdEnv = loadEnvFile(cwdEnvPath);
|
||||
const homeEnv = loadEnvFile(homeEnvPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import { buildMultipart, wechatHttp } from "./wechat-http.ts";
|
||||
|
||||
interface ReceivedRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: Buffer;
|
||||
}
|
||||
|
||||
async function startEchoServer(t: TestContext): Promise<{ baseUrl: string; received: ReceivedRequest[] }> {
|
||||
const received: ReceivedRequest[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
received.push({
|
||||
method: req.method ?? "",
|
||||
url: req.url ?? "",
|
||||
headers: req.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
});
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true, echo: { url: req.url, method: req.method } }));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to start echo server");
|
||||
}
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
t.after(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
return { baseUrl, received };
|
||||
}
|
||||
|
||||
test("wechatHttp performs a GET and passes through query string", async (t) => {
|
||||
const { baseUrl, received } = await startEchoServer(t);
|
||||
const res = await wechatHttp(`${baseUrl}/cgi-bin/token?grant_type=client_credential&appid=AID`);
|
||||
assert.equal(res.status, 200);
|
||||
const data = await res.json<{ ok: boolean; echo: { url: string; method: string } }>();
|
||||
assert.equal(data.ok, true);
|
||||
assert.equal(received.length, 1);
|
||||
assert.equal(received[0]!.method, "GET");
|
||||
assert.equal(received[0]!.url, "/cgi-bin/token?grant_type=client_credential&appid=AID");
|
||||
assert.equal(received[0]!.body.length, 0);
|
||||
});
|
||||
|
||||
test("wechatHttp POST sends JSON body with content-length header", async (t) => {
|
||||
const { baseUrl, received } = await startEchoServer(t);
|
||||
const body = JSON.stringify({ articles: [{ title: "hi" }] });
|
||||
const res = await wechatHttp(`${baseUrl}/cgi-bin/draft/add?access_token=T`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(received.length, 1);
|
||||
assert.equal(received[0]!.method, "POST");
|
||||
assert.equal(received[0]!.headers["content-type"], "application/json");
|
||||
assert.equal(received[0]!.headers["content-length"], String(Buffer.byteLength(body)));
|
||||
assert.equal(received[0]!.body.toString("utf-8"), body);
|
||||
});
|
||||
|
||||
test("wechatHttp .text() returns the raw response body", async (t) => {
|
||||
const { baseUrl } = await startEchoServer(t);
|
||||
const res = await wechatHttp(`${baseUrl}/hello`);
|
||||
const text = await res.text();
|
||||
assert.match(text, /"ok":true/);
|
||||
});
|
||||
|
||||
test("wechatHttp .buffer() returns a Buffer of the response body", async (t) => {
|
||||
const { baseUrl } = await startEchoServer(t);
|
||||
const res = await wechatHttp(`${baseUrl}/`);
|
||||
const buf = await res.buffer();
|
||||
assert.ok(Buffer.isBuffer(buf));
|
||||
assert.ok(buf.length > 0);
|
||||
});
|
||||
|
||||
test("buildMultipart produces a parsable multipart payload", () => {
|
||||
const fileData = Buffer.from("ZZZ");
|
||||
const { contentType, body } = buildMultipart([
|
||||
{
|
||||
name: "media",
|
||||
filename: "image.png",
|
||||
contentType: "image/png",
|
||||
data: fileData,
|
||||
},
|
||||
]);
|
||||
|
||||
const boundaryMatch = contentType.match(/^multipart\/form-data; boundary=(.+)$/);
|
||||
assert.ok(boundaryMatch, `expected boundary in Content-Type, got ${contentType}`);
|
||||
const boundary = boundaryMatch![1]!;
|
||||
const text = body.toString("binary");
|
||||
assert.ok(text.startsWith(`--${boundary}\r\n`), "body must start with opening boundary");
|
||||
assert.ok(
|
||||
text.endsWith(`--${boundary}--\r\n`),
|
||||
"body must end with closing boundary",
|
||||
);
|
||||
assert.match(
|
||||
text,
|
||||
/Content-Disposition: form-data; name="media"; filename="image\.png"\r\n/,
|
||||
);
|
||||
assert.match(text, /Content-Type: image\/png\r\n/);
|
||||
// The raw file bytes must appear verbatim after a blank line.
|
||||
assert.ok(
|
||||
text.includes("\r\n\r\nZZZ\r\n"),
|
||||
"body must contain the raw file bytes after the part headers",
|
||||
);
|
||||
});
|
||||
|
||||
test("wechatHttp accepts a multipart body produced by buildMultipart", async (t) => {
|
||||
const { baseUrl, received } = await startEchoServer(t);
|
||||
const fileData = Buffer.from("HELLO");
|
||||
const multipart = buildMultipart([
|
||||
{ name: "media", filename: "x.png", contentType: "image/png", data: fileData },
|
||||
]);
|
||||
const res = await wechatHttp(`${baseUrl}/cgi-bin/media/uploadimg?access_token=T`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": multipart.contentType },
|
||||
body: multipart.body,
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(received.length, 1);
|
||||
assert.equal(received[0]!.method, "POST");
|
||||
assert.match(received[0]!.headers["content-type"]!, /^multipart\/form-data; boundary=/);
|
||||
assert.ok(received[0]!.body.includes(Buffer.from("HELLO")));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
export interface WechatHttpInit {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string | Buffer;
|
||||
}
|
||||
|
||||
export interface WechatHttpResponse {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
buffer(): Promise<Buffer>;
|
||||
text(): Promise<string>;
|
||||
json<T = unknown>(): Promise<T>;
|
||||
}
|
||||
|
||||
export type WechatClient = (
|
||||
url: string,
|
||||
init?: WechatHttpInit,
|
||||
) => Promise<WechatHttpResponse>;
|
||||
|
||||
export interface MultipartFilePart {
|
||||
name: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
export interface MultipartBody {
|
||||
contentType: string;
|
||||
body: Buffer;
|
||||
}
|
||||
|
||||
export function buildMultipart(parts: MultipartFilePart[]): MultipartBody {
|
||||
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`;
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const header =
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"\r\n` +
|
||||
`Content-Type: ${part.contentType}\r\n\r\n`;
|
||||
chunks.push(Buffer.from(header, "utf-8"));
|
||||
chunks.push(part.data);
|
||||
chunks.push(Buffer.from("\r\n", "utf-8"));
|
||||
}
|
||||
chunks.push(Buffer.from(`--${boundary}--\r\n`, "utf-8"));
|
||||
|
||||
return {
|
||||
contentType: `multipart/form-data; boundary=${boundary}`,
|
||||
body: Buffer.concat(chunks),
|
||||
};
|
||||
}
|
||||
|
||||
function headersToRecord(headers: Headers): Record<string, string | string[] | undefined> {
|
||||
const out: Record<string, string | string[] | undefined> = {};
|
||||
headers.forEach((value, key) => {
|
||||
const existing = out[key];
|
||||
if (existing === undefined) {
|
||||
out[key] = value;
|
||||
} else if (Array.isArray(existing)) {
|
||||
existing.push(value);
|
||||
} else {
|
||||
out[key] = [existing, value];
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export const wechatHttp: WechatClient = async (url, init = {}) => {
|
||||
const method = init.method ?? (init.body !== undefined ? "POST" : "GET");
|
||||
const headers: Record<string, string> = { ...(init.headers ?? {}) };
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (init.body !== undefined) {
|
||||
body = Buffer.isBuffer(init.body)
|
||||
? new Uint8Array(init.body.buffer, init.body.byteOffset, init.body.byteLength)
|
||||
: init.body;
|
||||
}
|
||||
|
||||
const res = await fetch(url, { method, headers, body });
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
return {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: headersToRecord(res.headers),
|
||||
async buffer() {
|
||||
return buf;
|
||||
},
|
||||
async text() {
|
||||
return buf.toString("utf-8");
|
||||
},
|
||||
async json<T = unknown>() {
|
||||
return JSON.parse(buf.toString("utf-8")) as T;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
type WechatUploadAsset,
|
||||
detectImageFormatFromBuffer,
|
||||
} from "./wechat-image-processor.ts";
|
||||
|
||||
export type { WechatUploadAsset };
|
||||
|
||||
const MIME_TYPES_BY_EXT: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".tiff": "image/tiff",
|
||||
".tif": "image/tiff",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
export async function loadUploadAsset(
|
||||
imagePath: string,
|
||||
baseDir?: string,
|
||||
): Promise<WechatUploadAsset> {
|
||||
let fileBuffer: Buffer;
|
||||
let filename: string;
|
||||
let contentType: string;
|
||||
let fileSize = 0;
|
||||
let fileExt = "";
|
||||
|
||||
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
||||
const response = await fetch(imagePath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${imagePath}`);
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength === 0) {
|
||||
throw new Error(`Remote image is empty: ${imagePath}`);
|
||||
}
|
||||
fileBuffer = Buffer.from(buffer);
|
||||
fileSize = buffer.byteLength;
|
||||
const urlPath = imagePath.split("?")[0]!;
|
||||
filename = path.basename(urlPath) || "image.jpg";
|
||||
fileExt = path.extname(filename).toLowerCase();
|
||||
contentType = response.headers.get("content-type") || "image/jpeg";
|
||||
} else {
|
||||
const resolvedPath = path.isAbsolute(imagePath)
|
||||
? imagePath
|
||||
: path.resolve(baseDir || process.cwd(), imagePath);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(`Image not found: ${resolvedPath}`);
|
||||
}
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (stats.size === 0) {
|
||||
throw new Error(`Local image is empty: ${resolvedPath}`);
|
||||
}
|
||||
fileSize = stats.size;
|
||||
fileBuffer = fs.readFileSync(resolvedPath);
|
||||
filename = path.basename(resolvedPath);
|
||||
fileExt = path.extname(filename).toLowerCase();
|
||||
contentType = MIME_TYPES_BY_EXT[fileExt] || "image/jpeg";
|
||||
}
|
||||
|
||||
const detected = detectImageFormatFromBuffer(fileBuffer);
|
||||
if (detected && detected.contentType !== contentType) {
|
||||
console.error(`[wechat-api] Format mismatch: ${filename} declared as ${contentType}, actual ${detected.contentType}`);
|
||||
contentType = detected.contentType;
|
||||
fileExt = detected.fileExt;
|
||||
filename = `${path.basename(filename, path.extname(filename))}${detected.fileExt}`;
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: fileBuffer,
|
||||
filename,
|
||||
contentType,
|
||||
fileExt,
|
||||
fileSize,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import assert from "node:assert/strict";
|
||||
import net from "node:net";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildSshArgs,
|
||||
findFreePort,
|
||||
normalizeRemoteConfig,
|
||||
} from "./wechat-remote-publish.ts";
|
||||
|
||||
test("normalizeRemoteConfig requires a host", () => {
|
||||
assert.throws(
|
||||
() => normalizeRemoteConfig({ host: "" }),
|
||||
/Remote publish host is required/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeRemoteConfig({ host: " " }),
|
||||
/Remote publish host is required/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeRemoteConfig applies user/port defaults and trims host", () => {
|
||||
const result = normalizeRemoteConfig({ host: " example.com " });
|
||||
assert.equal(result.host, "example.com");
|
||||
assert.equal(result.user, "root");
|
||||
assert.equal(result.port, 22);
|
||||
assert.equal(result.identityFile, undefined);
|
||||
assert.equal(result.knownHostsFile, undefined);
|
||||
assert.equal(result.strictHostKeyChecking, undefined);
|
||||
assert.equal(result.connectTimeout, undefined);
|
||||
assert.equal(result.proxyJump, undefined);
|
||||
});
|
||||
|
||||
test("normalizeRemoteConfig preserves explicit user, port, and SSH options", () => {
|
||||
const result = normalizeRemoteConfig({
|
||||
host: "example.com",
|
||||
user: "deploy",
|
||||
port: 2222,
|
||||
identityFile: "/home/me/.ssh/id_ed25519",
|
||||
knownHostsFile: "/home/me/.ssh/known_hosts",
|
||||
strictHostKeyChecking: "accept-new",
|
||||
connectTimeout: 15,
|
||||
proxyJump: "bastion.example.com",
|
||||
});
|
||||
assert.equal(result.user, "deploy");
|
||||
assert.equal(result.port, 2222);
|
||||
assert.equal(result.identityFile, "/home/me/.ssh/id_ed25519");
|
||||
assert.equal(result.knownHostsFile, "/home/me/.ssh/known_hosts");
|
||||
assert.equal(result.strictHostKeyChecking, "accept-new");
|
||||
assert.equal(result.connectTimeout, 15);
|
||||
assert.equal(result.proxyJump, "bastion.example.com");
|
||||
});
|
||||
|
||||
test("normalizeRemoteConfig rejects invalid port", () => {
|
||||
assert.throws(
|
||||
() => normalizeRemoteConfig({ host: "example.com", port: 0 }),
|
||||
/Invalid remote publish port/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeRemoteConfig({ host: "example.com", port: 65536 }),
|
||||
/Invalid remote publish port/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeRemoteConfig({ host: "example.com", port: 1.5 }),
|
||||
/Invalid remote publish port/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeRemoteConfig rejects invalid connect timeout", () => {
|
||||
assert.throws(
|
||||
() => normalizeRemoteConfig({ host: "example.com", connectTimeout: 0 }),
|
||||
/Invalid remote_publish_connect_timeout/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeRemoteConfig({ host: "example.com", connectTimeout: -3 }),
|
||||
/Invalid remote_publish_connect_timeout/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeRemoteConfig rejects invalid strict host key checking", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeRemoteConfig({
|
||||
host: "example.com",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
strictHostKeyChecking: "maybe" as any,
|
||||
}),
|
||||
/Invalid remote_publish_strict_host_key_checking/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeRemoteConfig falls back to default user when blank string provided", () => {
|
||||
const result = normalizeRemoteConfig({ host: "example.com", user: " " });
|
||||
assert.equal(result.user, "root");
|
||||
});
|
||||
|
||||
test("buildSshArgs emits the whitelisted minimum set", () => {
|
||||
const args = buildSshArgs(
|
||||
{ host: "example.com", user: "root", port: 22 },
|
||||
1080,
|
||||
);
|
||||
assert.deepEqual(args, [
|
||||
"-N",
|
||||
"-T",
|
||||
"-D", "127.0.0.1:1080",
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "ServerAliveInterval=30",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-p", "22",
|
||||
"root@example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildSshArgs threads optional ssh options in stable order", () => {
|
||||
const args = buildSshArgs(
|
||||
{
|
||||
host: "example.com",
|
||||
user: "deploy",
|
||||
port: 2222,
|
||||
identityFile: "/p/id_ed25519",
|
||||
knownHostsFile: "/p/known_hosts",
|
||||
strictHostKeyChecking: "accept-new",
|
||||
connectTimeout: 12,
|
||||
proxyJump: "bastion.example.com",
|
||||
},
|
||||
1080,
|
||||
);
|
||||
assert.deepEqual(args, [
|
||||
"-N",
|
||||
"-T",
|
||||
"-D", "127.0.0.1:1080",
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "ServerAliveInterval=30",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-p", "2222",
|
||||
"-i", "/p/id_ed25519",
|
||||
"-o", "UserKnownHostsFile=/p/known_hosts",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "ConnectTimeout=12",
|
||||
"-J", "bastion.example.com",
|
||||
"deploy@example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildSshArgs does not emit raw ssh options for unknown fields", () => {
|
||||
const args = buildSshArgs(
|
||||
{
|
||||
host: "example.com",
|
||||
user: "root",
|
||||
port: 22,
|
||||
// No extra unknown keys are accepted — typed config is the whitelist.
|
||||
},
|
||||
1080,
|
||||
);
|
||||
assert.equal(
|
||||
args.filter((a) => a === "-o" || a.startsWith("--")).length,
|
||||
3, // ExitOnForwardFailure, ServerAliveInterval, ServerAliveCountMax (and only those)
|
||||
"buildSshArgs must only emit the three baseline -o options when no extras given",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSshArgs rejects invalid SOCKS port", () => {
|
||||
assert.throws(
|
||||
() => buildSshArgs({ host: "example.com", user: "root", port: 22 }, 0),
|
||||
/Invalid SOCKS port/,
|
||||
);
|
||||
assert.throws(
|
||||
() => buildSshArgs({ host: "example.com", user: "root", port: 22 }, 70_000),
|
||||
/Invalid SOCKS port/,
|
||||
);
|
||||
});
|
||||
|
||||
test("findFreePort returns a usable loopback port", async () => {
|
||||
const port = await findFreePort();
|
||||
assert.ok(port > 0 && port < 65536, `expected valid port, got ${port}`);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
||||
import net from "node:net";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import type { StrictHostKeyChecking } from "./wechat-extend-config.ts";
|
||||
import type { WechatClient } from "./wechat-http.ts";
|
||||
import { createSocksClient } from "./wechat-socks-http.ts";
|
||||
|
||||
export interface RemotePublishConfig {
|
||||
host: string;
|
||||
user?: string;
|
||||
port?: number;
|
||||
identityFile?: string;
|
||||
knownHostsFile?: string;
|
||||
strictHostKeyChecking?: StrictHostKeyChecking;
|
||||
connectTimeout?: number;
|
||||
proxyJump?: string;
|
||||
}
|
||||
|
||||
export interface NormalizedRemotePublishConfig {
|
||||
host: string;
|
||||
user: string;
|
||||
port: number;
|
||||
identityFile?: string;
|
||||
knownHostsFile?: string;
|
||||
strictHostKeyChecking?: StrictHostKeyChecking;
|
||||
connectTimeout?: number;
|
||||
proxyJump?: string;
|
||||
}
|
||||
|
||||
export interface SshTunnel {
|
||||
port: number;
|
||||
client: WechatClient;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartSshTunnelOptions {
|
||||
readyTimeoutMs?: number;
|
||||
killTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_USER = "root";
|
||||
const DEFAULT_PORT = 22;
|
||||
const DEFAULT_READY_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_KILL_TIMEOUT_MS = 3_000;
|
||||
const SSH_LOOPBACK_HOST = "127.0.0.1";
|
||||
|
||||
export function normalizeRemoteConfig(config: RemotePublishConfig): NormalizedRemotePublishConfig {
|
||||
if (!config.host || !config.host.trim()) {
|
||||
throw new Error("Remote publish host is required (set remote_publish_host or --remote-host).");
|
||||
}
|
||||
|
||||
const port = config.port ?? DEFAULT_PORT;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`Invalid remote publish port: ${config.port}`);
|
||||
}
|
||||
|
||||
if (config.connectTimeout !== undefined) {
|
||||
if (!Number.isInteger(config.connectTimeout) || config.connectTimeout <= 0) {
|
||||
throw new Error(`Invalid remote_publish_connect_timeout: ${config.connectTimeout}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
config.strictHostKeyChecking !== undefined &&
|
||||
config.strictHostKeyChecking !== "yes" &&
|
||||
config.strictHostKeyChecking !== "no" &&
|
||||
config.strictHostKeyChecking !== "accept-new"
|
||||
) {
|
||||
throw new Error(`Invalid remote_publish_strict_host_key_checking: ${config.strictHostKeyChecking}`);
|
||||
}
|
||||
|
||||
return {
|
||||
host: config.host.trim(),
|
||||
user: (config.user ?? DEFAULT_USER).trim() || DEFAULT_USER,
|
||||
port,
|
||||
identityFile: config.identityFile,
|
||||
knownHostsFile: config.knownHostsFile,
|
||||
strictHostKeyChecking: config.strictHostKeyChecking,
|
||||
connectTimeout: config.connectTimeout,
|
||||
proxyJump: config.proxyJump,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSshArgs(config: NormalizedRemotePublishConfig, socksPort: number): string[] {
|
||||
if (!Number.isInteger(socksPort) || socksPort < 1 || socksPort > 65535) {
|
||||
throw new Error(`Invalid SOCKS port: ${socksPort}`);
|
||||
}
|
||||
|
||||
const args: string[] = [
|
||||
"-N",
|
||||
"-T",
|
||||
"-D", `${SSH_LOOPBACK_HOST}:${socksPort}`,
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "ServerAliveInterval=30",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-p", String(config.port),
|
||||
];
|
||||
|
||||
if (config.identityFile) {
|
||||
args.push("-i", config.identityFile);
|
||||
}
|
||||
if (config.knownHostsFile) {
|
||||
args.push("-o", `UserKnownHostsFile=${config.knownHostsFile}`);
|
||||
}
|
||||
if (config.strictHostKeyChecking) {
|
||||
args.push("-o", `StrictHostKeyChecking=${config.strictHostKeyChecking}`);
|
||||
}
|
||||
if (config.connectTimeout !== undefined) {
|
||||
args.push("-o", `ConnectTimeout=${config.connectTimeout}`);
|
||||
}
|
||||
if (config.proxyJump) {
|
||||
args.push("-J", config.proxyJump);
|
||||
}
|
||||
|
||||
args.push(`${config.user}@${config.host}`);
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function findFreePort(): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, SSH_LOOPBACK_HOST, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to acquire free port")));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForSocksReady(port: number, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown = undefined;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await tryConnect(port);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
await sleep(150);
|
||||
}
|
||||
}
|
||||
throw new Error(`SOCKS proxy on ${SSH_LOOPBACK_HOST}:${port} not ready within ${timeoutMs}ms${lastError ? `: ${(lastError as Error).message}` : ""}`);
|
||||
}
|
||||
|
||||
function tryConnect(port: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect({ host: SSH_LOOPBACK_HOST, port });
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", (err) => {
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function startSshTunnel(
|
||||
config: NormalizedRemotePublishConfig,
|
||||
options: StartSshTunnelOptions = {},
|
||||
): Promise<SshTunnel> {
|
||||
const readyTimeout = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
||||
const killTimeout = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
|
||||
|
||||
const port = await findFreePort();
|
||||
const args = buildSshArgs(config, port);
|
||||
|
||||
console.error(`[wechat-remote-publish] Starting SSH SOCKS5 tunnel: ssh ${args.join(" ")}`);
|
||||
const child = spawn("ssh", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}) as ChildProcessByStdio<null, Readable, Readable>;
|
||||
|
||||
const stderrChunks: string[] = [];
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderrChunks.push(chunk.toString("utf-8"));
|
||||
});
|
||||
|
||||
let earlyExit: { code: number | null; signal: NodeJS.Signals | null } | undefined;
|
||||
child.once("exit", (code, signal) => {
|
||||
earlyExit = { code, signal };
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForSocksReady(port, readyTimeout);
|
||||
} catch (err) {
|
||||
await killChild(child, killTimeout);
|
||||
const stderrTail = stderrChunks.join("").trim().split("\n").slice(-5).join("\n");
|
||||
const suffix = stderrTail ? `\nssh stderr (tail):\n${stderrTail}` : "";
|
||||
const exitSuffix = earlyExit
|
||||
? `\nssh exited early with code=${earlyExit.code} signal=${earlyExit.signal}`
|
||||
: "";
|
||||
throw new Error(`${(err as Error).message}${exitSuffix}${suffix}`);
|
||||
}
|
||||
|
||||
const client = createSocksClient({ host: SSH_LOOPBACK_HOST, port });
|
||||
|
||||
const signalHandlers: Array<{ signal: NodeJS.Signals; handler: () => void }> = [];
|
||||
let closed = false;
|
||||
const close = async (): Promise<void> => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const { signal, handler } of signalHandlers) {
|
||||
process.off(signal, handler);
|
||||
}
|
||||
await killChild(child, killTimeout);
|
||||
};
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
||||
const handler = () => {
|
||||
void close();
|
||||
};
|
||||
process.once(signal, handler);
|
||||
signalHandlers.push({ signal, handler });
|
||||
}
|
||||
|
||||
return { port, client, close };
|
||||
}
|
||||
|
||||
async function killChild(child: ChildProcessByStdio<null, Readable, Readable>, killTimeoutMs: number): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
child.once("exit", () => resolve());
|
||||
});
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}, killTimeoutMs);
|
||||
|
||||
try {
|
||||
await exited;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function withSshTunnel<T>(
|
||||
config: NormalizedRemotePublishConfig,
|
||||
fn: (client: WechatClient) => Promise<T>,
|
||||
options?: StartSshTunnelOptions,
|
||||
): Promise<T> {
|
||||
const tunnel = await startSshTunnel(config, options);
|
||||
try {
|
||||
return await fn(tunnel.client);
|
||||
} finally {
|
||||
await tunnel.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import { createSocksClient } from "./wechat-socks-http.ts";
|
||||
|
||||
interface EchoServer {
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
received: Array<{ method: string; url: string; headers: http.IncomingHttpHeaders; body: Buffer }>;
|
||||
}
|
||||
|
||||
async function startEchoServer(t: TestContext): Promise<EchoServer> {
|
||||
const received: EchoServer["received"] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
received.push({
|
||||
method: req.method ?? "",
|
||||
url: req.url ?? "",
|
||||
headers: req.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
});
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true, url: req.url }));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("echo server bind failed");
|
||||
const port = address.port;
|
||||
t.after(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
return { baseUrl: `http://127.0.0.1:${port}`, port, received };
|
||||
}
|
||||
|
||||
interface FakeSocks5 {
|
||||
port: number;
|
||||
connectionCount: () => number;
|
||||
destinations: () => Array<{ host: string; port: number }>;
|
||||
}
|
||||
|
||||
async function startFakeSocks5(t: TestContext): Promise<FakeSocks5> {
|
||||
let connectionCount = 0;
|
||||
const destinations: Array<{ host: string; port: number }> = [];
|
||||
|
||||
const server = net.createServer((client) => {
|
||||
connectionCount++;
|
||||
let phase: "greeting" | "request" | "tunnel" = "greeting";
|
||||
let buf = Buffer.alloc(0);
|
||||
let upstream: net.Socket | undefined;
|
||||
|
||||
const tryParse = () => {
|
||||
if (phase === "greeting") {
|
||||
if (buf.length < 2) return;
|
||||
const nMethods = buf[1]!;
|
||||
if (buf.length < 2 + nMethods) return;
|
||||
buf = buf.subarray(2 + nMethods);
|
||||
client.write(Buffer.from([0x05, 0x00]));
|
||||
phase = "request";
|
||||
}
|
||||
if (phase === "request") {
|
||||
if (buf.length < 5) return;
|
||||
if (buf[0] !== 0x05 || buf[1] !== 0x01) {
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
const atyp = buf[3];
|
||||
let addrEnd: number;
|
||||
let host: string;
|
||||
if (atyp === 0x01) {
|
||||
if (buf.length < 4 + 4 + 2) return;
|
||||
host = `${buf[4]}.${buf[5]}.${buf[6]}.${buf[7]}`;
|
||||
addrEnd = 4 + 4;
|
||||
} else if (atyp === 0x03) {
|
||||
const dlen = buf[4]!;
|
||||
if (buf.length < 4 + 1 + dlen + 2) return;
|
||||
host = buf.subarray(5, 5 + dlen).toString("ascii");
|
||||
addrEnd = 4 + 1 + dlen;
|
||||
} else {
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
const port = (buf[addrEnd]! << 8) | buf[addrEnd + 1]!;
|
||||
destinations.push({ host, port });
|
||||
const totalLen = addrEnd + 2;
|
||||
const remaining = buf.subarray(totalLen);
|
||||
buf = Buffer.alloc(0);
|
||||
|
||||
upstream = net.connect({ host, port }, () => {
|
||||
client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
||||
phase = "tunnel";
|
||||
if (remaining.length > 0) {
|
||||
upstream!.write(remaining);
|
||||
}
|
||||
});
|
||||
upstream.on("data", (data: Buffer) => {
|
||||
client.write(data);
|
||||
});
|
||||
upstream.on("end", () => {
|
||||
try { client.end(); } catch { /* noop */ }
|
||||
});
|
||||
upstream.on("error", () => {
|
||||
try { client.destroy(); } catch { /* noop */ }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
client.on("data", (chunk: Buffer) => {
|
||||
if (phase === "tunnel") {
|
||||
upstream?.write(chunk);
|
||||
return;
|
||||
}
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
tryParse();
|
||||
});
|
||||
client.on("end", () => {
|
||||
try { upstream?.end(); } catch { /* noop */ }
|
||||
});
|
||||
client.on("error", () => {
|
||||
try { upstream?.destroy(); } catch { /* noop */ }
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("socks server bind failed");
|
||||
const port = address.port;
|
||||
t.after(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
return {
|
||||
port,
|
||||
connectionCount: () => connectionCount,
|
||||
destinations: () => destinations,
|
||||
};
|
||||
}
|
||||
|
||||
test("createSocksClient routes plain HTTP through the SOCKS5 proxy", async (t) => {
|
||||
const echo = await startEchoServer(t);
|
||||
const socks = await startFakeSocks5(t);
|
||||
|
||||
const client = createSocksClient({ host: "127.0.0.1", port: socks.port });
|
||||
|
||||
const res = await client(`${echo.baseUrl}/cgi-bin/token?appid=AID`);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const data = await res.json<{ ok: boolean; url: string }>();
|
||||
assert.equal(data.ok, true);
|
||||
assert.equal(data.url, "/cgi-bin/token?appid=AID");
|
||||
|
||||
assert.equal(
|
||||
socks.connectionCount(),
|
||||
1,
|
||||
"SOCKS5 proxy must have received exactly one connection (proves bytes were routed through it)",
|
||||
);
|
||||
|
||||
const dests = socks.destinations();
|
||||
assert.equal(dests.length, 1);
|
||||
assert.equal(dests[0]!.host, "127.0.0.1");
|
||||
assert.equal(dests[0]!.port, echo.port);
|
||||
|
||||
assert.equal(echo.received.length, 1);
|
||||
assert.equal(echo.received[0]!.method, "GET");
|
||||
assert.equal(echo.received[0]!.url, "/cgi-bin/token?appid=AID");
|
||||
});
|
||||
|
||||
test("createSocksClient sends POST body through the SOCKS5 proxy", async (t) => {
|
||||
const echo = await startEchoServer(t);
|
||||
const socks = await startFakeSocks5(t);
|
||||
|
||||
const client = createSocksClient({ host: "127.0.0.1", port: socks.port });
|
||||
|
||||
const body = JSON.stringify({ articles: [{ title: "hi" }] });
|
||||
const res = await client(`${echo.baseUrl}/cgi-bin/draft/add?access_token=T`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(socks.connectionCount(), 1);
|
||||
assert.equal(echo.received.length, 1);
|
||||
assert.equal(echo.received[0]!.method, "POST");
|
||||
assert.equal(echo.received[0]!.headers["content-type"], "application/json");
|
||||
assert.equal(echo.received[0]!.headers["content-length"], String(Buffer.byteLength(body)));
|
||||
assert.equal(echo.received[0]!.body.toString("utf-8"), body);
|
||||
});
|
||||
|
||||
test("createSocksClient rejects invalid proxy ports", () => {
|
||||
assert.throws(
|
||||
() => createSocksClient({ host: "127.0.0.1", port: 0 }),
|
||||
/Invalid SOCKS proxy port/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createSocksClient({ host: "127.0.0.1", port: 70_000 }),
|
||||
/Invalid SOCKS proxy port/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createSocksClient({ host: "", port: 1080 }),
|
||||
/SOCKS proxy host required/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import net from "node:net";
|
||||
import tls from "node:tls";
|
||||
import { URL } from "node:url";
|
||||
import { SocksClient } from "socks";
|
||||
|
||||
import type {
|
||||
WechatClient,
|
||||
WechatHttpInit,
|
||||
WechatHttpResponse,
|
||||
} from "./wechat-http.ts";
|
||||
|
||||
export interface SocksProxyEndpoint {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export function createSocksClient(proxy: SocksProxyEndpoint): WechatClient {
|
||||
if (!proxy.host) throw new Error("SOCKS proxy host required");
|
||||
if (!Number.isInteger(proxy.port) || proxy.port < 1 || proxy.port > 65535) {
|
||||
throw new Error(`Invalid SOCKS proxy port: ${proxy.port}`);
|
||||
}
|
||||
|
||||
return async (url, init = {}) => {
|
||||
return wechatHttpViaSocks(url, init, proxy);
|
||||
};
|
||||
}
|
||||
|
||||
async function wechatHttpViaSocks(
|
||||
rawUrl: string,
|
||||
init: WechatHttpInit,
|
||||
proxy: SocksProxyEndpoint,
|
||||
): Promise<WechatHttpResponse> {
|
||||
const url = new URL(rawUrl);
|
||||
const isHttps = url.protocol === "https:";
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
||||
throw new Error(`Unsupported protocol for SOCKS client: ${url.protocol}`);
|
||||
}
|
||||
const targetPort = url.port ? Number(url.port) : isHttps ? 443 : 80;
|
||||
|
||||
const { socket: tcpSocket } = await SocksClient.createConnection({
|
||||
proxy: { host: proxy.host, port: proxy.port, type: 5 },
|
||||
command: "connect",
|
||||
destination: { host: url.hostname, port: targetPort },
|
||||
});
|
||||
|
||||
let stream: net.Socket | tls.TLSSocket;
|
||||
if (isHttps) {
|
||||
const tlsSocket = tls.connect({ socket: tcpSocket, servername: url.hostname });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onSecure = () => {
|
||||
tlsSocket.removeListener("error", onError);
|
||||
resolve();
|
||||
};
|
||||
const onError = (err: Error) => {
|
||||
tlsSocket.removeListener("secureConnect", onSecure);
|
||||
try {
|
||||
tlsSocket.destroy();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
tcpSocket.destroy();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
reject(err);
|
||||
};
|
||||
tlsSocket.once("secureConnect", onSecure);
|
||||
tlsSocket.once("error", onError);
|
||||
});
|
||||
stream = tlsSocket;
|
||||
} else {
|
||||
stream = tcpSocket;
|
||||
}
|
||||
|
||||
try {
|
||||
return await sendRequestAndReadResponse(stream, url, init);
|
||||
} finally {
|
||||
try {
|
||||
stream.destroy();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRequestAndReadResponse(
|
||||
stream: net.Socket | tls.TLSSocket,
|
||||
url: URL,
|
||||
init: WechatHttpInit,
|
||||
): Promise<WechatHttpResponse> {
|
||||
const method = init.method ?? (init.body !== undefined ? "POST" : "GET");
|
||||
const body =
|
||||
init.body === undefined
|
||||
? undefined
|
||||
: Buffer.isBuffer(init.body)
|
||||
? init.body
|
||||
: Buffer.from(init.body, "utf-8");
|
||||
|
||||
const userHeaders = init.headers ?? {};
|
||||
const headerMap = new Map<string, string>();
|
||||
for (const [k, v] of Object.entries(userHeaders)) {
|
||||
headerMap.set(k.toLowerCase(), `${k}: ${v}`);
|
||||
}
|
||||
if (!headerMap.has("host")) headerMap.set("host", `Host: ${url.host}`);
|
||||
if (!headerMap.has("user-agent")) {
|
||||
headerMap.set("user-agent", "User-Agent: baoyu-skills-wechat-api");
|
||||
}
|
||||
headerMap.set("connection", "Connection: close");
|
||||
if (body && !headerMap.has("content-length")) {
|
||||
headerMap.set("content-length", `Content-Length: ${body.length}`);
|
||||
}
|
||||
|
||||
const path = `${url.pathname || "/"}${url.search}`;
|
||||
const requestHeader = Buffer.from(
|
||||
`${method} ${path} HTTP/1.1\r\n` +
|
||||
Array.from(headerMap.values()).join("\r\n") +
|
||||
"\r\n\r\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await writeAll(stream, requestHeader);
|
||||
if (body) await writeAll(stream, body);
|
||||
|
||||
const raw = await readToEnd(stream);
|
||||
return parseHttpResponse(raw);
|
||||
}
|
||||
|
||||
function writeAll(stream: net.Socket | tls.TLSSocket, data: Buffer): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.write(data, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readToEnd(stream: net.Socket | tls.TLSSocket): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.once("end", () => resolve(Buffer.concat(chunks)));
|
||||
stream.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function parseHttpResponse(raw: Buffer): WechatHttpResponse {
|
||||
const headerEnd = raw.indexOf("\r\n\r\n");
|
||||
if (headerEnd < 0) {
|
||||
throw new Error("Malformed HTTP response: missing header terminator");
|
||||
}
|
||||
const headerText = raw.subarray(0, headerEnd).toString("utf-8");
|
||||
let bodyBytes = raw.subarray(headerEnd + 4);
|
||||
|
||||
const lines = headerText.split("\r\n");
|
||||
const statusLine = lines.shift() ?? "";
|
||||
const statusMatch = statusLine.match(/^HTTP\/[\d.]+\s+(\d+)(?:\s+(.*))?$/);
|
||||
if (!statusMatch) {
|
||||
throw new Error(`Malformed HTTP status line: ${statusLine}`);
|
||||
}
|
||||
const status = Number.parseInt(statusMatch[1]!, 10);
|
||||
const statusText = statusMatch[2] ?? "";
|
||||
|
||||
const headers: Record<string, string | string[] | undefined> = {};
|
||||
const lowercaseHeaders: Record<string, string> = {};
|
||||
for (const line of lines) {
|
||||
const colon = line.indexOf(":");
|
||||
if (colon < 0) continue;
|
||||
const key = line.slice(0, colon).trim();
|
||||
const value = line.slice(colon + 1).trim();
|
||||
const lower = key.toLowerCase();
|
||||
const existing = headers[lower];
|
||||
if (existing === undefined) {
|
||||
headers[lower] = value;
|
||||
} else if (Array.isArray(existing)) {
|
||||
existing.push(value);
|
||||
} else {
|
||||
headers[lower] = [existing, value];
|
||||
}
|
||||
lowercaseHeaders[lower] = value;
|
||||
}
|
||||
|
||||
const transferEncoding = (lowercaseHeaders["transfer-encoding"] ?? "").toLowerCase();
|
||||
if (transferEncoding.split(",").map((s) => s.trim()).includes("chunked")) {
|
||||
bodyBytes = dechunk(bodyBytes);
|
||||
} else if (lowercaseHeaders["content-length"] !== undefined) {
|
||||
const length = Number.parseInt(lowercaseHeaders["content-length"]!, 10);
|
||||
if (Number.isFinite(length) && length >= 0) {
|
||||
bodyBytes = bodyBytes.subarray(0, length);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
statusText,
|
||||
headers,
|
||||
async buffer() {
|
||||
return bodyBytes;
|
||||
},
|
||||
async text() {
|
||||
return bodyBytes.toString("utf-8");
|
||||
},
|
||||
async json<T = unknown>() {
|
||||
return JSON.parse(bodyBytes.toString("utf-8")) as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dechunk(raw: Buffer): Buffer {
|
||||
const parts: Buffer[] = [];
|
||||
let offset = 0;
|
||||
while (offset < raw.length) {
|
||||
const lineEnd = raw.indexOf("\r\n", offset);
|
||||
if (lineEnd < 0) break;
|
||||
const sizeText = raw.subarray(offset, lineEnd).toString("ascii").split(";")[0]!.trim();
|
||||
const size = Number.parseInt(sizeText, 16);
|
||||
if (!Number.isFinite(size) || size < 0) {
|
||||
throw new Error(`Invalid chunked-encoding size: ${sizeText}`);
|
||||
}
|
||||
offset = lineEnd + 2;
|
||||
if (size === 0) break;
|
||||
if (offset + size > raw.length) {
|
||||
throw new Error("Chunked-encoding body truncated");
|
||||
}
|
||||
parts.push(raw.subarray(offset, offset + size));
|
||||
offset += size + 2;
|
||||
}
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-post-to-x
|
||||
description: Posts content and articles to X (Twitter). Supports regular posts with images/videos and X Articles (long-form Markdown). In Codex, honor explicit requests for the Codex Chrome plugin/@chrome by using the Chrome Extension workflow; otherwise use Chrome Computer Use when available and fall back to real Chrome CDP scripts only when allowed. Use when user asks to "post to X", "tweet", "publish to Twitter", or "share on X".
|
||||
version: 1.57.1
|
||||
version: 1.57.2
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-x
|
||||
@@ -49,7 +49,7 @@ Choose exactly one mode before interacting with X:
|
||||
1. If the user explicitly asks for the Codex Chrome plugin, `@chrome`, the Chrome extension, or "Codex 自带的 Chrome 插件", use **Codex Chrome Plugin Mode**. Do not call Computer Use first.
|
||||
2. If the user explicitly asks for Chrome Computer Use, use **Chrome Computer Use Mode**. Do not fall back to CDP, Playwright, the in-app Browser, or the Chrome plugin without telling the user and getting approval.
|
||||
3. If the user explicitly asks for CDP/script mode, use **CDP Script Mode**.
|
||||
4. Otherwise, prefer **Chrome Computer Use Mode** for regular posts, video posts, and quote tweets. For Markdown **X Articles with local content images**, prefer **CDP Script Mode** because placeholder selection and image-block verification are more reliable than manual DraftJS selection through Computer Use.
|
||||
4. Otherwise, prefer **Chrome Computer Use Mode**. For Markdown **X Articles with local content images**, use the tested X editor flow: insert each body image from the toolbar (`Insert` -> `Media` -> dialog icon button `Add photos or video`) at its placeholder, then delete the placeholder text. Use CDP Script Mode only when the selected browser-control mode is unavailable or the UI upload/selection flow is unreliable.
|
||||
|
||||
Never use the in-app Browser for X publishing workflows.
|
||||
|
||||
@@ -65,7 +65,7 @@ Use this mode whenever the user requests the Codex Chrome plugin, `@chrome`, or
|
||||
|
||||
**General rules**
|
||||
- Use the Chrome plugin's `browser.tabs.*`, `tab.playwright.*`, `tab.cua.*`, and file chooser APIs for X UI actions.
|
||||
- Shell commands are allowed for Markdown preprocessing and clipboard preparation.
|
||||
- Shell commands are allowed for Markdown preprocessing and rich-HTML clipboard preparation. For X Article body images, do not rely on image clipboard paste; use the editor's `Insert` -> `Media` upload flow.
|
||||
- If a file upload fails with `Not allowed`, tell the user: `To enable file upload, go to chrome://extensions in Chrome, click Details under the Codex extension, and enable "Allow access to file URLs." See https://developers.openai.com/codex/app/chrome-extension#upload-files for details.`
|
||||
- If the Chrome plugin reports `native pipe is closed`, retry the lightweight browser call once after 2 seconds, then run the Chrome skill health checks. If Chrome is running, the extension is enabled, and the native host manifest is correct, ask permission to open a new Chrome window and retry. Do not keep sending browser actions through the broken pipe.
|
||||
- Never click `Publish`, `Post`, or any externally visible submit action without explicit final confirmation from the user in the current conversation.
|
||||
@@ -84,7 +84,13 @@ Use this mode whenever the user requests the Codex Chrome plugin, `@chrome`, or
|
||||
```
|
||||
6. Paste into the article body with a real paste keystroke through the Chrome plugin. On macOS use `Meta+V`.
|
||||
7. Verify the editor text contains the article body and `XIMGPH_` placeholders. Do not rely on `tab.clipboard.readText()` as proof of the system clipboard after shell clipboard writes; on macOS verify with `pbpaste` if needed.
|
||||
8. For each `contentImages` item in placeholder order, copy the image with `copy-to-clipboard.ts image <localPath>`, select the exact placeholder text, paste with the Chrome plugin, and verify the placeholder is gone and an image block appeared.
|
||||
8. For each `contentImages` item in placeholder order:
|
||||
- Locate the visible placeholder text (`XIMGPH_N`) and click it to place the caret there.
|
||||
- Open the toolbar menu `Insert` -> `Media`.
|
||||
- In the modal, click the icon button with `aria-label="Add photos or video"`; do not click the text/dropzone or hidden file input.
|
||||
- Use the file chooser to upload that image's `localPath`.
|
||||
- After the image appears, if `XIMGPH_N` remains above it, select exactly that placeholder and press `Delete` first. Use `Backspace` only if `Delete` fails and the selected text is confirmed to be exactly the placeholder.
|
||||
- Verify the placeholder count for that `XIMGPH_N` is `0`.
|
||||
9. Open Preview and verify title, cover, body, links, and images.
|
||||
10. Ask for explicit confirmation before clicking `Publish`.
|
||||
|
||||
@@ -183,16 +189,16 @@ Use this mode when the user explicitly asks for Chrome Computer Use, or when no
|
||||
```
|
||||
5. Paste into the article body with Computer Use.
|
||||
6. For each `contentImages` entry in placeholder order:
|
||||
- Copy the image with `copy-to-clipboard.ts image <localPath>`.
|
||||
- Select the exact visible placeholder text such as `XIMGPH_3`.
|
||||
- Paste with Computer Use (`super+v`/`control+v`).
|
||||
- Wait until the X header no longer says `Uploading media...`.
|
||||
- If the placeholder remains, reselect the exact placeholder text and press `BackSpace`. Never press `BackSpace` unless the app state confirms the selected text is exactly that placeholder.
|
||||
- Locate the exact visible placeholder text such as `XIMGPH_3` and click it to set the insertion point.
|
||||
- Open the toolbar `Insert` dropdown, choose `Media`, then click the modal's icon button labeled `Add photos or video`.
|
||||
- Use the native file picker to choose the image's `localPath`.
|
||||
- Wait until the image block appears and any upload activity is finished.
|
||||
- If the placeholder remains above the inserted image, reselect exactly that placeholder text and press `Delete` first. Use `Backspace` only if `Delete` fails and the selected text is confirmed to be exactly the placeholder.
|
||||
7. Verify no `XIMGPH_` placeholders remain and the expected images appear.
|
||||
8. Open Preview and verify title, cover, body, links, and images.
|
||||
9. Ask for explicit confirmation before clicking `Publish`.
|
||||
|
||||
If Computer Use selection or paste becomes unreliable, stop and report the blocker instead of switching to the Chrome plugin or CDP silently.
|
||||
If Computer Use selection, toolbar upload, or file-picker control becomes unreliable, stop and report the blocker instead of switching to the Chrome plugin or CDP silently.
|
||||
|
||||
---
|
||||
|
||||
@@ -291,7 +297,7 @@ ${BUN_X} {baseDir}/scripts/x-article.ts article.md --cover ./cover.jpg
|
||||
|
||||
**Frontmatter**: `title`, `cover_image` supported in YAML front matter.
|
||||
|
||||
**Codex mode note**: If the user explicitly requested the Codex Chrome plugin, follow **Codex Chrome Plugin Mode** above. If the user explicitly requested Chrome Computer Use, follow **Chrome Computer Use Mode**. Otherwise, for Markdown articles with local content images, use `x-article.ts` in **CDP Script Mode** so placeholders can be selected programmatically and the post-composition check can verify image count and remaining `XIMGPH_` text.
|
||||
**Codex mode note**: If the user explicitly requested the Codex Chrome plugin, follow **Codex Chrome Plugin Mode** above. If the user explicitly requested Chrome Computer Use, follow **Chrome Computer Use Mode**. Otherwise, prefer Chrome Computer Use; for Markdown articles with local content images, use the toolbar `Insert` -> `Media` image-upload workflow before falling back to `x-article.ts` in **CDP Script Mode**.
|
||||
|
||||
**CDP fallback note**: The script opens browser with article filled in. User reviews and publishes manually unless `--submit` is used.
|
||||
|
||||
|
||||
@@ -183,12 +183,12 @@ JSON output:
|
||||
- Press `Meta+V` on macOS or `Control+V` on Windows/Linux through the Chrome plugin.
|
||||
- Verify the article body appeared and contains `XIMGPH_` placeholders. On macOS, use `pbpaste` to verify shell-written system clipboard contents if paste is suspicious; `tab.clipboard.readText()` may not reflect the system clipboard after shell writes.
|
||||
10. **Insert Images**: for each `contentImages` item in placeholder order:
|
||||
- Run `copy-to-clipboard.ts image <localPath>`.
|
||||
- Select the exact placeholder text (`XIMGPH_N`) in the editor.
|
||||
- Press `Meta+V`/`Control+V` with the Chrome plugin.
|
||||
- Wait for X to finish uploading media.
|
||||
- If `XIMGPH_N` remains above the inserted image, reselect that exact text and press `BackSpace`.
|
||||
- Do not press `BackSpace` unless the selected text is exactly the placeholder.
|
||||
- Locate the exact visible placeholder text (`XIMGPH_N`) and click it to put the insertion point there.
|
||||
- Open the editor toolbar dropdown `Insert` and choose `Media`.
|
||||
- In the `Insert` modal, click the icon button with `aria-label="Add photos or video"`; do not click the "Choose a file or drag it here" text/dropzone or hidden file input.
|
||||
- Use the Chrome plugin file chooser flow to upload that image's `localPath`.
|
||||
- Wait until the image block appears. If `XIMGPH_N` remains above the image, select exactly that placeholder and press `Delete` first; use `Backspace` only if `Delete` fails and the selected text is confirmed to be exactly the placeholder.
|
||||
- Verify that placeholder's count is `0` before continuing.
|
||||
11. **Verify**:
|
||||
- Inspect the editor for `XIMGPH_` residue.
|
||||
- Confirm the expected number of image blocks is visible.
|
||||
@@ -211,12 +211,12 @@ If the Chrome plugin reports `native pipe is closed`, retry one lightweight brow
|
||||
- Click the article body.
|
||||
- Press `super+v` on macOS or `control+v` on Windows/Linux with Computer Use.
|
||||
9. **Insert Images**: for each `contentImages` item in placeholder order:
|
||||
- Run `copy-to-clipboard.ts image <localPath>`.
|
||||
- Select the exact placeholder text (`XIMGPH_N`) in the editor.
|
||||
- Press `super+v`/`control+v` with Computer Use.
|
||||
- Wait for X to finish uploading media.
|
||||
- If `XIMGPH_N` remains above the inserted image, reselect the exact text and press `BackSpace`.
|
||||
- Do not press `BackSpace` unless the Computer Use state confirms the selected text is exactly the placeholder.
|
||||
- Locate the exact visible placeholder text (`XIMGPH_N`) and click it to put the insertion point there.
|
||||
- Open the editor toolbar dropdown `Insert`, choose `Media`, then click the icon button with `aria-label="Add photos or video"` inside the modal.
|
||||
- Use the native file picker to choose that image's `localPath`.
|
||||
- Wait until the image block appears and upload activity is complete.
|
||||
- If `XIMGPH_N` remains above the inserted image, reselect exactly that placeholder text and press `Delete` first; use `Backspace` only if `Delete` fails and the Computer Use state confirms the selected text is exactly the placeholder.
|
||||
- Confirm that placeholder is gone before continuing.
|
||||
10. **Verify**:
|
||||
- Inspect the Computer Use state for `XIMGPH_` residue.
|
||||
- Confirm the expected number of image blocks is visible.
|
||||
@@ -232,11 +232,12 @@ If the Chrome plugin reports `native pipe is closed`, retry one lightweight brow
|
||||
5. **Upload Cover**: Use file input for cover image
|
||||
6. **Fill Title**: Type title into title field
|
||||
7. **Paste Content**: Copy HTML to clipboard, paste into editor
|
||||
8. **Insert Images**: For each placeholder (reverse order):
|
||||
- Find placeholder text in editor
|
||||
- Select the placeholder
|
||||
- Copy image to clipboard
|
||||
- Paste to replace selection
|
||||
8. **Insert Images**: For each placeholder in placeholder order:
|
||||
- Find and click the placeholder text in the editor
|
||||
- Use `Insert` -> `Media`
|
||||
- Click the modal's icon button labeled `Add photos or video`
|
||||
- Upload the matching image file
|
||||
- Delete the leftover placeholder text with `Delete` after the image appears
|
||||
9. **Post-Composition Check** (automatic):
|
||||
- Scan editor for remaining `XIMGPH_` placeholders
|
||||
- Compare expected vs actual image count
|
||||
@@ -265,12 +266,12 @@ Claude:
|
||||
|
||||
- **No create button**: Ensure X Premium subscription is active
|
||||
- **Cover upload fails**: Check file path and format (PNG, JPEG)
|
||||
- **Images not inserting**: Verify placeholders exist in pasted content
|
||||
- **Images not inserting**: Verify placeholders exist in pasted content; use `Insert` -> `Media` -> modal icon button `Add photos or video`, not image clipboard paste, the dropzone text, or the hidden file input.
|
||||
- **Content not pasting**: Check HTML clipboard: `${BUN_X} {baseDir}/scripts/copy-to-clipboard.ts html --file /tmp/test.html`
|
||||
- **Chrome plugin `native pipe is closed`**: retry once after 2 seconds, then run Chrome skill checks; ask before opening a new Chrome window if checks pass.
|
||||
- **Chrome plugin upload `Not allowed`**: enable file URL access for the Codex Chrome Extension in `chrome://extensions` → Details.
|
||||
- **Computer Use unavailable**: Use the CDP fallback script, unless the user explicitly required Chrome Computer Use.
|
||||
- **Placeholder remains after paste**: Select only the placeholder text and press BackSpace after upload completes.
|
||||
- **Placeholder remains after upload**: Select only the placeholder text and press `Delete` after upload completes. Use `Backspace` only if `Delete` fails and the selection is exactly the placeholder.
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -284,12 +285,14 @@ Claude:
|
||||
2. The Codex Chrome plugin publishes through the user's real Chrome session when explicitly requested:
|
||||
- Uses the user's active Chrome profile and logged-in X session
|
||||
- Uses the Chrome Extension browser client rather than Computer Use or CDP
|
||||
- Uses `copy-to-clipboard.ts` for rich HTML and image clipboard payloads
|
||||
- Uses `copy-to-clipboard.ts` for rich HTML body paste
|
||||
- Inserts body images through X's toolbar `Insert` -> `Media` modal and its `Add photos or video` icon button
|
||||
- Keeps the final publish click under user confirmation
|
||||
|
||||
3. Chrome Computer Use publishes through the user's visible Chrome UI:
|
||||
- Uses the user's active Chrome profile and logged-in X session
|
||||
- Uses `copy-to-clipboard.ts` for rich HTML and image clipboard payloads
|
||||
- Uses `copy-to-clipboard.ts` for rich HTML body paste
|
||||
- Inserts body images through X's toolbar `Insert` -> `Media` modal and its `Add photos or video` icon button
|
||||
- Uses real keystrokes (`super+v`/`control+v`) through Codex Computer Use
|
||||
- Keeps the final publish click under user confirmation
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-slide-deck
|
||||
description: Generates professional slide deck images from content. Creates outlines with style instructions, then generates individual slide images. Use when user asks to "create slides", "make a presentation", "generate deck", "slide deck", or "PPT".
|
||||
version: 1.56.2
|
||||
version: 1.57.0
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-slide-deck
|
||||
@@ -32,17 +32,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.
|
||||
- **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-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **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-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.
|
||||
|
||||
**⛔ 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 slide titles, bullets, or any other text inside an already generated slide image. If text is wrong or unclear, regenerate from a corrected prompt, simplify the slide's 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-slide-[slug].md`) BEFORE invoking any backend. The file is the reproducibility record and lets you switch backends without regenerating prompts.
|
||||
|
||||
Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) 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 slide 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` slide 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 selected slide 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.
|
||||
- Merge PPTX/PDF only after all selected slide images are generated.
|
||||
|
||||
## Confirmation Policy
|
||||
|
||||
Default behavior: **confirm before generation**.
|
||||
@@ -74,6 +96,7 @@ Respond in the user's language across questions, progress reports, error message
|
||||
| `--lang <code>` | Output language (en, zh, ja, ...) |
|
||||
| `--slides <N>` | Target slide count (8-25 recommended, max 30) |
|
||||
| `--ref <files...>` | Reference images applied per slide (style / palette / composition / subject) |
|
||||
| `--batch-size <n>` | Temporary slide image generation batch size for this run. Default: `generation_batch_size` from EXTEND.md, otherwise 4. Clamp to 1-8. |
|
||||
| `--outline-only` | Stop after outline |
|
||||
| `--prompts-only` | Stop after prompts (skip image generation) |
|
||||
| `--images-only` | Skip to Step 7; requires existing `prompts/` |
|
||||
@@ -220,7 +243,7 @@ Copy this checklist and check off items as you complete them:
|
||||
| `${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-slide-deck/EXTEND.md` | XDG |
|
||||
| `$HOME/.baoyu-skills/baoyu-slide-deck/EXTEND.md` | User home |
|
||||
|
||||
If found, read, parse, and print a summary (style / audience / language / review). If not, proceed with defaults — first-time setup is not blocking for this skill. Schema: `references/config/preferences-schema.md`.
|
||||
If found, read, parse, and print a summary (style / audience / language / review / generation batch size). If not, proceed with defaults — first-time setup is not blocking for this skill. Schema: `references/config/preferences-schema.md`.
|
||||
|
||||
**1.2 Analyze content** — follow `references/analysis-framework.md`: classify content, detect language, note signals for style selection, estimate slide count from length (see the **Slide Count Heuristic** in Style System above), generate topic slug. Save source as `source.md` (honor backup rule if one exists).
|
||||
|
||||
@@ -276,7 +299,8 @@ Display the prompts index (`# | Filename | Slide Title`) and ask: proceed / edit
|
||||
1. Resolve the image backend via the Image Generation Tools rule at the top — ask once if multiple are installed.
|
||||
2. Confirm every `prompts/NN-slide-{slug}.md` exists (hard requirement; prompt files are the reproducibility record regardless of backend).
|
||||
3. Session ID: `slides-{topic-slug}-{timestamp}` — pass to the backend only if it supports sessions.
|
||||
4. For each slide: generate sequentially, reusing the session ID. Backup rule applies to PNG files. Report progress as `Generated X/N`. Auto-retry once on failure before reporting an error.
|
||||
4. Build a task list for selected slides with each slide's prompt file, output PNG path, aspect ratio, session ID, and verified direct references.
|
||||
5. Dispatch slide images in batches per the `## Batch Generation Policy`: backend native batch first, runtime parallel tool calls second, sequential only as fallback. Backup rule applies to PNG files before dispatch. Report progress as `Generated X/N`. Retry only failed items once before reporting an error.
|
||||
|
||||
`--regenerate N` jumps to this step for the named slides only. `--images-only` starts here with existing prompts.
|
||||
|
||||
@@ -315,6 +339,12 @@ PDF: {topic-slug}.pdf
|
||||
|
||||
Always update the prompt file before regenerating the image — this keeps the prompts directory as the source of truth and makes changes reproducible. Only `NN` changes on renumber; slugs stay stable so references remain valid.
|
||||
|
||||
Text correction policy:
|
||||
|
||||
- If a slide's title, bullets, 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.
|
||||
|
||||
See `references/modification-guide.md` for full details.
|
||||
|
||||
## References
|
||||
@@ -349,4 +379,5 @@ EXTEND.md lives at the first matching path listed in Step 1.1. Two ways to chang
|
||||
- `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.
|
||||
- `generation_batch_size: 4` — default number of slide images to render concurrently when the backend/runtime supports batch or parallel generation.
|
||||
- `preferred_style: blueprint`, `preferred_audience: experts`, `language: zh`.
|
||||
|
||||
@@ -13,6 +13,7 @@ audience: general # beginners | intermediate | experts | executives
|
||||
language: auto # auto | en | zh | ja | etc.
|
||||
review: true # true = review outline before generation
|
||||
preferred_image_backend: auto # auto | ask | <backend-id>
|
||||
generation_batch_size: 4 # 1-8, used when backend/runtime supports batch or parallel slide generation
|
||||
|
||||
## Custom Dimensions (only when style: custom)
|
||||
dimensions:
|
||||
@@ -42,6 +43,7 @@ custom_styles:
|
||||
| `language` | string | `auto` | Output language (auto = detect from input) |
|
||||
| `review` | boolean | `true` | Show outline review before generation |
|
||||
| `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. |
|
||||
| `generation_batch_size` | int | 4 | Number of slide 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 Dimensions
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# baoyu-wechat-summary preferences
|
||||
#
|
||||
# Copy this file to one of:
|
||||
# .baoyu-skills/baoyu-wechat-summary/EXTEND.md (project-local, takes precedence)
|
||||
# $XDG_CONFIG_HOME/baoyu-skills/baoyu-wechat-summary/EXTEND.md (XDG, falls back to ~/.config)
|
||||
# ~/.baoyu-skills/baoyu-wechat-summary/EXTEND.md (per-user home)
|
||||
#
|
||||
# First match wins. Keys are case-insensitive. Use `key: value` or `key=value`.
|
||||
# Blank lines and lines starting with `#` are ignored.
|
||||
|
||||
# REQUIRED — your own wxid. Used to recognize your own messages so they show up
|
||||
# as `self_display` (below) in digests instead of the raw wxid string.
|
||||
# Find via: wx contacts --query <你的昵称> --json (look for chat_type=private, your own row)
|
||||
self_wxid: wxid_xxxxxxxxxxxx
|
||||
|
||||
# REQUIRED — the display name to substitute for your wxid in digest text.
|
||||
self_display: 宝玉
|
||||
|
||||
# OPTIONAL — which version(s) to generate when the user doesn't explicitly say.
|
||||
# Values: normal | roast | both
|
||||
# Default: normal
|
||||
default_version: normal
|
||||
|
||||
# OPTIONAL — default time range when the user gives none and there's no prior
|
||||
# digest to incrementally extend from. Forms: `Nd` (days), `Nh` (hours), or a
|
||||
# fixed `YYYY-MM-DD` (treated as both --since and --until).
|
||||
# Default: 24h (i.e., today)
|
||||
# default_time_range: 7d
|
||||
|
||||
# OPTIONAL — override the root directory where digest folders live. Default
|
||||
# resolves to `{project_root}/wechat`. Useful if you want a shared archive
|
||||
# outside the current project.
|
||||
# data_root: ~/Documents/wechat-digests
|
||||
@@ -0,0 +1,463 @@
|
||||
---
|
||||
name: baoyu-wechat-summary
|
||||
description: Summarizes WeChat group chat highlights into a structured digest using the local wx-cli binary (https://github.com/jackwener/wx-cli). Generates a normal digest by default; a roast (毒舌) version is opt-in. Maintains per-group history (history.json + history-digests.jsonl) and per-user profiles across runs, with privacy guardrails baked in. Use when the user asks to "总结群聊", "群聊精华", "群聊摘要", "summarize group chat", "group chat digest", mentions a WeChat group name with a time range, says "帮我看看 XX 群最近聊了什么", "XX 群有什么值得看的", or asks to "回溯画像" / "初始化画像" / "backfill profiles". Adds the roast version when the user says "毒舌版", "roast 版", "再来个毒舌的", or similar.
|
||||
version: 1.117.3
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-wechat-summary
|
||||
requires:
|
||||
anyBins:
|
||||
- wx
|
||||
---
|
||||
|
||||
# WeChat Group Summary
|
||||
|
||||
群聊精华提取专家。把零散的微信群聊记录提炼成结构化、可读性强的简报,并维护跨次运行的群聊历史与群友画像。底层依赖外部 [wx-cli](https://github.com/jackwener/wx-cli) 二进制(`wx` 命令),不打包脚本。
|
||||
|
||||
> **⚠️ Sandbox restriction**
|
||||
>
|
||||
> wx-cli reads from `~/.wx-cli/` (config, cache, daemon socket) and from WeChat's data directory (`~/Library/Containers/com.tencent.xinWeChat/` on macOS). Both paths are outside Claude Code's default sandbox. Every `wx` command in this skill needs to run with `dangerouslyDisableSandbox: true` from the start — don't waste a sandbox attempt first. The user can use `/sandbox` to view/edit restrictions.
|
||||
|
||||
## 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.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before invoking the workflow, verify the environment. Run these checks in order; stop at the first failure and surface the exact next command the user needs.
|
||||
|
||||
1. **wx-cli installed** — run `wx --version`. If missing, tell the user to install it themselves (`npm install -g @jackwener/wx-cli` or use one of the alternatives at https://github.com/jackwener/wx-cli). **Do NOT auto-install** — this repo forbids piped/silent installs.
|
||||
2. **`~/.wx-cli` directory owned by the current user** — `sudo wx init` historically chowned this directory to root, which breaks every subsequent non-sudo `wx` call. Check:
|
||||
```bash
|
||||
ls -la ~/.wx-cli/ 2>/dev/null | head -5
|
||||
```
|
||||
If the directory exists but the owner is `root` (or anything other than `$(whoami)`), tell the user to repair it themselves:
|
||||
```bash
|
||||
sudo chown -R $(whoami) ~/.wx-cli
|
||||
sudo rm -f ~/.wx-cli/daemon.pid ~/.wx-cli/daemon.sock
|
||||
wx daemon start
|
||||
```
|
||||
The skill should NOT run `sudo` on the user's behalf.
|
||||
3. **wx-cli initialized** — `wx sessions` should return data. If it fails with "no keys" / "init required", instruct the user to run `wx init` while WeChat is running (on macOS, `codesign --force --deep --sign - /Applications/WeChat.app` first). Prefer non-sudo init; only fall back to `sudo wx init` if the user's wx-cli version requires it — and warn them that they'll need step 2's chown after.
|
||||
4. **WeChat 4.x running and logged in** — required for the daemon to find data files.
|
||||
|
||||
## Preferences (EXTEND.md)
|
||||
|
||||
Check EXTEND.md in priority order — the first one found wins:
|
||||
|
||||
| Priority | Path | Scope |
|
||||
|----------|------|-------|
|
||||
| 1 | `.baoyu-skills/baoyu-wechat-summary/EXTEND.md` (relative to project root) | Project |
|
||||
| 2 | `${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-wechat-summary/EXTEND.md` | XDG |
|
||||
| 3 | `$HOME/.baoyu-skills/baoyu-wechat-summary/EXTEND.md` | User home |
|
||||
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Found | Read, parse, apply. On first use in session, briefly remind: "Using preferences from [path]. Edit it to change defaults." |
|
||||
| Not found | **MUST** run first-time setup (BLOCKING) before generating any digest — do NOT silently use defaults. |
|
||||
|
||||
### Supported keys
|
||||
|
||||
EXTEND.md is plain text with `key: value` or `key=value` lines, `#` for comments, case-insensitive keys.
|
||||
|
||||
| Key | Type | Default | Purpose |
|
||||
|-----|------|---------|---------|
|
||||
| `self_wxid` | string | (required) | The owning account's wxid. Messages whose `from_wxid` matches this are attributed to the user. |
|
||||
| `self_display` | string | (required) | Display name to substitute for the user's own messages in digest text. |
|
||||
| `default_version` | `normal` / `roast` / `both` | `normal` | Which version(s) to generate when the user doesn't say otherwise. |
|
||||
| `default_time_range` | string (e.g. `7d`, `24h`, `1d`) | (none) | Default range when the user omits time and there's no incremental anchor. |
|
||||
| `data_root` | path | `{project_root}/wechat` | Override where digest folders live. |
|
||||
|
||||
A starter template lives at [EXTEND.md.example](EXTEND.md.example).
|
||||
|
||||
### First-Time Setup (BLOCKING)
|
||||
|
||||
If no EXTEND.md is found, do NOT silently proceed.
|
||||
|
||||
**Step A — Try to auto-discover `self_wxid` and `self_display` first.** Run (in order, stop at the first that succeeds):
|
||||
|
||||
```bash
|
||||
# 1. If wx-cli exposes a whoami, use it
|
||||
wx whoami --json 2>/dev/null
|
||||
|
||||
# 2. Otherwise, find self-sent messages in recent sessions
|
||||
wx sessions --json --limit 20 2>/dev/null
|
||||
```
|
||||
|
||||
For option 2, scan the sessions for any private/group thread the user has sent into and read one of their own `from_wxid` / `from_nickname` pairs. If you can confidently pre-fill both values, use them as defaults in the question below; otherwise leave the fields blank for the user to fill in.
|
||||
|
||||
**Step B — Confirm with one `AskUserQuestion` call (batched), pre-filling whatever auto-discovery found:**
|
||||
|
||||
- `self_wxid` (e.g., `wxid_abc123`) — fall-back hint: the user can find it with `wx contacts --query "<own nickname>"`, or by inspecting any of their own sent messages in `wx sessions --json`
|
||||
- `self_display` (e.g., `宝玉`) — how they want their messages attributed
|
||||
- `default_version` — pick one of `normal` / `roast` / `both`
|
||||
- `data_root` — where digest folders live. Default: `{project_root}/wechat`. Enter a custom absolute path (e.g. `~/Documents/wechat-digests`) or leave blank for default.
|
||||
- Save location — pick one of project / XDG / home
|
||||
|
||||
Write EXTEND.md to the chosen path. If the user provided a non-default `data_root`, include it as an uncommented line; otherwise omit it (the default applies automatically). Confirm "Preferences saved to [path]. Edit it any time to change defaults.", then continue with the digest workflow.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Parse the user's request
|
||||
|
||||
Extract:
|
||||
|
||||
- **Group name** (or partial name for fuzzy matching)
|
||||
- **Time range** — interpret flexibly:
|
||||
- "最近 1 天" / "今天" / "last 24 hours" → 1 day
|
||||
- "最近 3 天" → 3 days
|
||||
- "最近 7 天" / "这周" → 7 days
|
||||
- "最近 30 天" / "最近一个月" → 30 days
|
||||
- "某天" (e.g. "3 月 5 号") → that specific date
|
||||
- "某天到某天" (e.g. "3 月 1 号到 3 月 5 号") → date range
|
||||
- "从上次开始" / "继续" / "接着上次" / "since last" → **incremental mode**: read `history.json` for this group, use `last_digest.last_message_time` as the start
|
||||
- No time specified → **incremental mode**. If no `history.json` exists yet, fall back to `default_time_range` from EXTEND.md if set, else last 24 hours.
|
||||
- **Version(s) to generate**:
|
||||
- Start from `default_version` in EXTEND.md.
|
||||
- User request overrides: keywords "毒舌"/"roast"/"挑衅"/"再来个毒的"/"sass" → force `include_roast=true`. Keywords "只要正经的"/"normal only"/"不要毒舌" → force `include_normal=true, include_roast=false`. "都来一份"/"两个版本都要"/"both" → both.
|
||||
- At least one of `include_normal`/`include_roast` must end up true.
|
||||
|
||||
Convert relative ranges into absolute `--since YYYY-MM-DD --until YYYY-MM-DD` pairs using today's local date.
|
||||
|
||||
### Step 2: Find the group + resolve folder path
|
||||
|
||||
```bash
|
||||
wx contacts --query "<group_name>" --json
|
||||
```
|
||||
|
||||
Filter for entries whose `username` ends in `@chatroom`. If multiple groups match, use `AskUserQuestion` to disambiguate. If none match, fall back to `wx sessions --json` and search there before asking the user.
|
||||
|
||||
Once resolved, compute the folder path:
|
||||
|
||||
```
|
||||
{data_root}/{group_id}-{sanitized_group_name}/
|
||||
```
|
||||
|
||||
where `data_root` is from EXTEND.md (default `{project_root}/wechat`).
|
||||
|
||||
**Sanitize the group name** — replace any of `/ \ : * ? " < > | NUL` and control characters with `_`. Trim trailing dots and whitespace. Don't strip emoji or Chinese characters.
|
||||
|
||||
**Group-rename detection**: list existing folders under `{data_root}/` and find any folder whose name starts with `{group_id}-`. If one exists but the suffix differs (group was renamed), rename the existing folder to the new `{group_id}-{sanitized_new_name}` form. If a target with the new name already exists (rare), keep both and prefer the existing one for this run.
|
||||
|
||||
### Step 3: Fetch messages
|
||||
|
||||
For small batches (single-day digest, typically < 200 messages), pipe JSON into the agent directly:
|
||||
|
||||
```bash
|
||||
wx history "<group_name_or_id>" --since YYYY-MM-DD --until YYYY-MM-DD -n 5000 --json
|
||||
```
|
||||
|
||||
For **large batches** (weekly / monthly digests, > 200 messages), redirect to `$TMPDIR` first so the raw payload never sits in conversation context:
|
||||
|
||||
```bash
|
||||
wx history "<group_name_or_id>" --since YYYY-MM-DD --until YYYY-MM-DD -n 5000 --json > "$TMPDIR/wx-messages.json"
|
||||
wc -c "$TMPDIR/wx-messages.json"
|
||||
jq 'length' "$TMPDIR/wx-messages.json"
|
||||
```
|
||||
|
||||
Then read the file in slices via `Read` with `offset` + `limit`, or process with `jq` queries (e.g. `jq '.[0:200]'`, `jq '[.[] | {id, from_nickname, timestamp, content: (.content | .[0:50])}]'` for a lightweight skeleton pass). Reading all 500+ messages at once will burn token budget unnecessarily.
|
||||
|
||||
Notes:
|
||||
|
||||
- `--since` is inclusive; `--until` is interpreted as a date (the whole day). If the user asked for "today only", set both to today.
|
||||
- `-n 5000` is a defensive cap; for very active groups, raise it and re-fetch.
|
||||
- Filter the returned messages by their `timestamp` to be safe (some daemons may return adjacent days).
|
||||
- **Range splitting**: for ranges > 7 days OR > 500 messages, prefer generating per-3-day digests and then a meta-summary over forcing one giant digest — the categorization quality degrades sharply past a week's worth of unrelated topics.
|
||||
|
||||
**Incremental mode**: after the fetch, drop any message whose `timestamp` is `<=` the `last_message_time` from `history.json`. If zero messages remain, tell the user "上次摘要后没有新消息,已跳过生成" and exit.
|
||||
|
||||
### Step 3.5: Parse the message schema
|
||||
|
||||
`wx history --json` returns an array of message objects. Use the fields that are present; tolerate missing fields:
|
||||
|
||||
- **`id` / `msg_id` / `local_id`** — message identifier (use whichever wx-cli emits). Reference IDs in working notes as anchors when building the skeleton.
|
||||
- **`from_wxid`** — stable sender identifier
|
||||
- **`from_nickname`** — display name (may be the group remark or original nickname)
|
||||
- **`content`** — text payload. Examples:
|
||||
- Plain text → use as-is
|
||||
- `[图片]` → opaque placeholder; see image handling below
|
||||
- `[表情]` → emoji/sticker; skip in body unless surrounded by discussion
|
||||
- `[视频]` / `[文件]` → media reference; skip unless discussed
|
||||
- `[链接] <title>` or `[链接/文件] <title>` → shared article; the title IS the information — quote it and credit the sharer
|
||||
- `[系统] ... revokemsg` → revoked; exclude from digest and from leaderboard
|
||||
- **`timestamp`** — convert to `MM-DD HH:MM` for display (and use full ISO for `generated_at`)
|
||||
- **`chat_type`** — sanity-check `group`
|
||||
- **Quote/reply** — try `quote_id`, `reply_to`, `quoted_msg_id`, or any nested `quote` object. If present, use it as strong attribution. If absent, fall back to context but flag the inferred link as uncertain.
|
||||
|
||||
### Step 3.6: Resolve self + ambiguous nicknames
|
||||
|
||||
- Substitute `self_display` for every message whose `from_wxid` matches `self_wxid` (from EXTEND.md). Apply this in the leaderboard, portraits, and body text. The user MUST appear under their real display name and count toward stats — never skip them.
|
||||
- Scan all unique senders for ambiguous handles: ≤2 characters, common programming words (`nil`, `null`, `test`, `admin`, `user`, `undefined`), single emoji, or otherwise low-information. For each, run `wx contacts --query "<nick>" --json --limit 5` and pick a meaningful name in this priority: remark > nickname > wxid. Apply the substitution everywhere in the digest.
|
||||
|
||||
### Step 3.7: Load user profiles
|
||||
|
||||
For each unique sender appearing in this batch:
|
||||
|
||||
- Look in `{folder}/profiles/{wxid}-*.md` by `wxid` prefix match. Read the matched file if found.
|
||||
- If `include_roast`, **also** look in `{folder}/profiles-roast/{wxid}-*.md` for the roast pass.
|
||||
|
||||
Compile a condensed **profile context block** as internal working memory — do NOT write it into the final digest. Example shape:
|
||||
|
||||
```
|
||||
== 群友历史画像(来自 profiles/)==
|
||||
K. H:空中直播员 / 生活百科全书。常见话题:旅行、金融、美食。经典金句:"要不要买moderna"。
|
||||
可可苏玛:...
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Only load profiles for users active in this batch — never preload everyone.
|
||||
- Profile is **background**, not template. Current messages are still the primary source.
|
||||
- Use historical labels for **continuity** ("又双叒叕化身空中直播员") or **contrast** ("一向省钱的 XX 今天居然...").
|
||||
- **Strict separation**: normal pass reads only `profiles/`, roast pass reads only `profiles-roast/`. Never cross-load.
|
||||
|
||||
See [references/profiles.md](references/profiles.md) for the full file format.
|
||||
|
||||
### Step 3.8: Detect existing in-chat digests (optional)
|
||||
|
||||
Some users (e.g., the original 宝玉 workflow) post digests directly into the group as messages. If we don't notice these, the new digest will re-cover the same ground.
|
||||
|
||||
Scan the fetched messages for signals of a prior in-chat digest:
|
||||
|
||||
- `from_wxid == self_wxid` AND
|
||||
- `content` contains `群聊精华` OR `消息统计:` OR `📊 消息统计` OR a leaderboard pattern (e.g. `^\d+\. .+: \d+ 条`), AND
|
||||
- `content` length > 1500 chars.
|
||||
|
||||
If a match is found:
|
||||
|
||||
1. Extract the digest's covered date or range from the title line (e.g., `xxx 群聊精华 · 2026-05-12` or `... · 2026-05-10 ~ 2026-05-12`).
|
||||
2. Surface the finding to the user via `AskUserQuestion`:
|
||||
- "Detected an in-chat digest by you covering {范围}. Use {范围 end + 1} as the start instead of `history.json`?"
|
||||
- Options: `Yes, skip up to {end of detected range}` / `No, use history.json` / `No, cover everything in the requested range`.
|
||||
3. Apply the chosen anchor.
|
||||
|
||||
This is a heuristic — when uncertain (multiple matches, malformed title), default to `history.json` and tell the user what was skipped.
|
||||
|
||||
Generate the digest in three rounds so nothing slips through. The methodology stays here in SKILL.md; the content/style rules live in [references/output-formats.md](references/output-formats.md) — read that file in Round 2 before drafting.
|
||||
|
||||
#### Round 1 — Build the skeleton
|
||||
|
||||
Read every message in order. **Skip image fetching/decoding** in this round. List every distinct discussion topic. Bias toward over-listing — trim in Round 3.
|
||||
|
||||
Internal working format (not written to the final file):
|
||||
|
||||
```
|
||||
== 话题清单(共 N 条消息)==
|
||||
1. [HH:MM-HH:MM] 话题名称(参与者:A, B, C)— 一句话概括(锚点 id:54052, 54055, 54063)
|
||||
2. [HH:MM-HH:MM] 话题名称(参与者:D, E)— 一句话概括(锚点 id:54100-54112)
|
||||
...
|
||||
|
||||
== 可能需要图片上下文的话题 ==
|
||||
- 话题 3:锚点 id=49661(图片是讨论主体)
|
||||
|
||||
== 发言统计 ==
|
||||
1. XXX — N 条 2. YYY — N 条 ...
|
||||
```
|
||||
|
||||
Topic principles:
|
||||
|
||||
- Topic-switch signals: time gap > 30 min, participant change, content jump.
|
||||
- 2+ participants OR substantive content qualifies as a topic; pure emoji-banter does not.
|
||||
- **Strict attribution**: each topic must record "who said what". Don't fuse adjacent messages from different senders just because they're close in time — when minutes apart or interleaved with others, split into separate topics. Prefer two topics over one wrongly-merged topic.
|
||||
- **Carry anchor IDs**: list the key message IDs for each topic. In Round 2, jump back to these IDs in the raw messages and verify content, don't guess from context. If `quote_id` / `reply_to` is present, use the ID chain — that's the most reliable attribution.
|
||||
|
||||
**Flag-for-images criteria** (any one triggers): an explicit comment on an image (`看发型是X?`, `这是谁?`, `笑死`), multiple people piling onto the same image without saying what it is, an image as the core information (晒单/截图/资料), an explanatory line right after an image (`gpt-image-2`, `太可怕了`), or cross-sender ambiguity (B says "这个看着像 X" but the previous image is from A).
|
||||
|
||||
#### Round 2 — Flesh out + write the digest
|
||||
|
||||
For each topic in the skeleton, jump back to its anchor IDs and expand into full content with quotes and clear attribution. Then write the digest file.
|
||||
|
||||
**Image handling** (limited — wx-cli does not decode chat images):
|
||||
|
||||
For each flagged topic, check whether a description file already exists at `{folder}/imgs/{message_id}.txt`. If yes, read it (one-line plain text) and weave its content into the topic. If no, treat the image as opaque (`[图片]`) and write around it — describe what the surrounding messages tell us, but don't invent visual content.
|
||||
|
||||
The `imgs/` directory exists as an **extension point**: a user (or a future wx-cli capability) can drop `{message_id}.txt` files with one-line descriptions, and the skill will pick them up. The skill itself does NOT generate these files in this version.
|
||||
|
||||
**Use the profile context block** (from Step 3.7):
|
||||
|
||||
- Echo continuity for matching behavior ("又双叒叕直播飞行体验")
|
||||
- Highlight contrast for departures ("一向话少的 XX 今天突然爆发")
|
||||
- Callback past quotes ("继上次'要不要买 moderna'之后,这次又...")
|
||||
- Don't sacrifice current material to force a callback.
|
||||
|
||||
**Roast pass — profile usage extras** (only when generating the roast version):
|
||||
|
||||
- 历史槽点可做 callback joke
|
||||
- Running gag 可以升级和迭代
|
||||
- 历史毒舌语录可以引用或翻新
|
||||
- 但当期素材优先,不要为了 callback 硬凑
|
||||
|
||||
**Writing order**: write the body categories first, then the opening overview based on the finished body (so the hook is accurate).
|
||||
|
||||
Detailed structure, voice, formatting rules, and content guidelines are in [references/output-formats.md](references/output-formats.md). Load that file now if not already loaded.
|
||||
|
||||
#### Round 3 — Audit
|
||||
|
||||
Walk the Round 1 skeleton against the finished digest. Check:
|
||||
|
||||
- Any listed topic missing from the digest?
|
||||
- Quotes, names, product/tool names preserved verbatim?
|
||||
- Categorization makes sense — is anything in the wrong bucket?
|
||||
|
||||
Fix in place. When clean, confirm and proceed.
|
||||
|
||||
### Step 7: Save the digest file(s)
|
||||
|
||||
If `include_normal`:
|
||||
|
||||
- Single date → `{folder}/YYYY-MM-DD.md`
|
||||
- Date range → `{folder}/YYYY-MM-DD_YYYY-MM-DD.md`
|
||||
- Overwrite if the same date/range already exists.
|
||||
|
||||
If `include_roast`:
|
||||
|
||||
- Same naming, but with `-roast` suffix: `YYYY-MM-DD-roast.md` or `YYYY-MM-DD_YYYY-MM-DD-roast.md`.
|
||||
|
||||
Both versions share the same statistics (message count, leaderboard) and the same underlying skeleton.
|
||||
|
||||
### Step 8: Save history (two files)
|
||||
|
||||
Maintain two files in the group folder:
|
||||
|
||||
#### `history.json` — single record, fast read
|
||||
|
||||
Always reflects only the most recent normal digest. Overwrite on each run when `include_normal=true`.
|
||||
|
||||
```json
|
||||
{
|
||||
"group_id": "12345678901@chatroom",
|
||||
"group_name": "相亲相爱一家人",
|
||||
"folder": "12345678901@chatroom-相亲相爱一家人",
|
||||
"last_digest": {
|
||||
"file": "2026-03-12.md",
|
||||
"date_range": "2026-03-12",
|
||||
"generated_at": "2026-03-12T10:30:00+08:00",
|
||||
"message_count": 150,
|
||||
"last_message_time": "03-12 18:45"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `group_name` updates on every run (handles renames).
|
||||
- `folder` records the current folder basename for cross-reference.
|
||||
- `last_message_time` is the timestamp of the most recent message included, in `MM-DD HH:MM` — used by incremental mode.
|
||||
- Roast-only runs do NOT touch this file.
|
||||
|
||||
#### `history-digests.jsonl` — append-only archive
|
||||
|
||||
One JSON object per line, same shape as `last_digest`. Every normal-version run appends one line (in chronological order). Used by backfill and historical lookups. Never read for incremental mode (which only needs the latest).
|
||||
|
||||
```jsonl
|
||||
{"file":"2026-03-10.md","date_range":"2026-03-10","generated_at":"2026-03-10T09:00:00+08:00","message_count":420,"last_message_time":"03-10 22:30"}
|
||||
{"file":"2026-03-11.md","date_range":"2026-03-11","generated_at":"2026-03-11T09:05:00+08:00","message_count":312,"last_message_time":"03-11 23:10"}
|
||||
{"file":"2026-03-12.md","date_range":"2026-03-12","generated_at":"2026-03-12T10:30:00+08:00","message_count":150,"last_message_time":"03-12 18:45"}
|
||||
```
|
||||
|
||||
If a normal digest with the same `file` name is regenerated, append a new line anyway (the JSONL is a strict log; readers can dedupe by `file` if they need to).
|
||||
|
||||
### Step 8.5: Update user profiles
|
||||
|
||||
For each user with 3+ messages in this batch who appeared in the 群友画像 section:
|
||||
|
||||
- If `include_normal`, update `{folder}/profiles/{wxid}-{nickname}.md`.
|
||||
- If `include_roast`, update `{folder}/profiles-roast/{wxid}-{nickname}.md`.
|
||||
|
||||
Counts, frontmatter updates, append-only rules for quotes and events, and privacy guardrails are detailed in [references/profiles.md](references/profiles.md). Load that file when running this step.
|
||||
|
||||
### Completion checklist
|
||||
|
||||
Profile updates are easy to forget once the digest is on disk. Before reporting the run as "done", verify every applicable file:
|
||||
|
||||
- [ ] `{folder}/YYYY-MM-DD.md` written (if `include_normal`)
|
||||
- [ ] `{folder}/YYYY-MM-DD-roast.md` written (if `include_roast`)
|
||||
- [ ] `{folder}/history.json` overwritten with the new `last_digest` (if `include_normal`)
|
||||
- [ ] `{folder}/history-digests.jsonl` appended one line (if `include_normal`)
|
||||
- [ ] `{folder}/profiles/{wxid}-*.md` updated for every user with 3+ messages (if `include_normal`)
|
||||
- [ ] `{folder}/profiles-roast/{wxid}-*.md` updated for every user with 3+ messages (if `include_roast`)
|
||||
|
||||
If any item is unchecked, finish it before declaring success. Don't ship a digest with a stale `history.json` — incremental mode depends on it.
|
||||
|
||||
### Step 9: Backfill (user-triggered)
|
||||
|
||||
When the user says "回溯画像" / "初始化画像" / "backfill profiles":
|
||||
|
||||
1. Confirm the target group (if not specified, ask which one).
|
||||
2. List all digest files in `{folder}/` and `history-digests.jsonl`.
|
||||
3. Read existing digests in batches of 10–15 to avoid context blowup.
|
||||
4. For users appearing in 3+ digests, seed profile files using their leaderboard counts, portrait paragraphs, and quoted lines from the historical digests.
|
||||
5. Write to `profiles/` (and `profiles-roast/` if any `-roast.md` files exist).
|
||||
6. Report back: how many profiles were created, how many users covered.
|
||||
|
||||
Full procedure in [references/profiles.md](references/profiles.md).
|
||||
|
||||
## Storage layout
|
||||
|
||||
```
|
||||
{data_root}/ # default: {project_root}/wechat/
|
||||
└── {group_id}-{group_name}/ # e.g. 12345678901@chatroom-相亲相爱一家人/
|
||||
├── history.json # last digest pointer (fast)
|
||||
├── history-digests.jsonl # append-only archive
|
||||
├── 2026-03-12.md # normal digest, single date
|
||||
├── 2026-03-12-roast.md # roast digest (only if generated)
|
||||
├── 2026-03-10_2026-03-12.md # normal digest, date range
|
||||
├── profiles/ # normal user profiles
|
||||
│ ├── onlytiancai-胡浩🐸.md
|
||||
│ └── ...
|
||||
├── profiles-roast/ # roast user profiles (only if any roast generated)
|
||||
│ ├── onlytiancai-胡浩🐸.md
|
||||
│ └── ...
|
||||
└── imgs/ # optional image-description files
|
||||
├── 49661.txt # one-line plain text description
|
||||
└── ...
|
||||
```
|
||||
|
||||
## wx-cli quick reference
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `wx --version` | Sanity-check that wx-cli is installed |
|
||||
| `wx sessions --json` | List recent sessions; useful for verifying init and finding the user's own wxid |
|
||||
| `wx contacts --query "<name>" --json` | Fuzzy-match contacts/groups by display name, remark, or wxid |
|
||||
| `wx history "<group>" --since DATE --until DATE -n N --json` | Pull a group's messages within a date range as JSON |
|
||||
| `wx members "<group>" --json` | List a group's members (rarely needed; mostly for completeness) |
|
||||
| `wx stats "<group>" --since DATE` | wx-cli's built-in stats; we compute our own from `wx history` JSON so the format matches our digest |
|
||||
| `wx daemon status` / `wx daemon stop` / `wx daemon logs --follow` | Daemon lifecycle (troubleshooting) |
|
||||
|
||||
All `wx` commands accept `--json` for machine-readable output. Default output is YAML — only use it for human eyeballing during debugging.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
When a `wx` command fails, diagnose by the symptom, not by retrying blindly. Common patterns:
|
||||
|
||||
| Symptom | Cause | Fix (tell the user to run these — do NOT run `sudo` for them) |
|
||||
|---------|-------|----------------------------------------------------------------|
|
||||
| `Operation not permitted` / `Access denied to ~/.wx-cli` | Sandbox is on | Re-run the command with `dangerouslyDisableSandbox: true`. Persistent fix: `/sandbox` to allow `~/.wx-cli` and the WeChat data dir. |
|
||||
| `无法写入 /Users/<u>/.wx-cli` / `Permission denied` | `~/.wx-cli` is owned by root (legacy `sudo wx init`) | `sudo chown -R $(whoami) ~/.wx-cli && sudo rm -f ~/.wx-cli/daemon.{pid,sock} && wx daemon start` |
|
||||
| `wx history` hangs / times out / returns nothing | Daemon is stuck | `wx daemon stop && rm -f ~/.wx-cli/daemon.{pid,sock} && wx daemon start`, then retry |
|
||||
| `no keys` / `init required` after the daemon was working | Keys went stale (WeChat restart, version upgrade) | Make sure WeChat is running, then `wx init --force` (non-sudo first; only `sudo` if your wx-cli version requires it) |
|
||||
| `wx contacts` returns zero rows for a group you know exists | Group is folded into 折叠群 or the daemon hasn't indexed it yet | `wx sessions --json` and search there; if missing, run `wx daemon stop && wx daemon start` and retry |
|
||||
| Messages returned but `--since` / `--until` window looks wrong | Date string not in `YYYY-MM-DD` format, or off-by-one timezone | Confirm the dates are local-time `YYYY-MM-DD`. Re-filter the JSON by `timestamp` locally as a belt-and-suspenders step. |
|
||||
| Empty result for a chat that should have activity | `-n` cap too low for a noisy group | Raise `-n` (e.g. to 20000) and re-fetch |
|
||||
|
||||
**Recovery order when nothing makes sense:**
|
||||
|
||||
1. Is WeChat running?
|
||||
2. Is `~/.wx-cli` owned by `$(whoami)`?
|
||||
3. Is the daemon healthy? (`wx daemon status`)
|
||||
4. Restart the daemon (`wx daemon stop && wx daemon start`)
|
||||
5. Last resort: `wx init --force` (while WeChat is running)
|
||||
|
||||
Never auto-retry inside the skill — every failure should produce a clear diagnostic plus the exact command the user needs to run.
|
||||
|
||||
## Notes and limitations
|
||||
|
||||
- **Image content is opaque**. wx-cli does not decode chat images. The skill respects an `imgs/{message_id}.txt` extension point but does not auto-populate it. When a topic depends heavily on an image with no description file, the digest should say so honestly rather than invent visual content.
|
||||
- **Reply attribution is best-effort**. If wx-cli's output exposes a quote/reply field, use it. Otherwise fall back to context and flag uncertain inferences in working notes.
|
||||
- **Local time only**. Date parsing uses the agent's local time zone. Cross-time-zone group members may show timestamps that don't match their wall clock. Per the format rules, never use timestamps to infer sleep or location.
|
||||
- **wx-cli reinit**. If `wx history` suddenly returns nothing after a WeChat restart, the keys may be stale. Tell the user to run `sudo wx init --force` (while WeChat is running) and retry.
|
||||
@@ -0,0 +1,273 @@
|
||||
# Output formats — normal & roast digest
|
||||
|
||||
This reference defines the two digest variants the skill produces: the **normal** version (default, sober summary) and the **roast** version (毒舌,sarcastic critique, opt-in). Load this file during Step 4 (skeleton) and keep it open through Step 6 (audit).
|
||||
|
||||
Both versions share the same overall layout and writing rules; the differences are tone, the leaderboard annotations, the portraits, and the footer. Write the normal version first when both are requested — it's the anchor for incremental mode and the source of truth for the profile updates.
|
||||
|
||||
---
|
||||
|
||||
## 1. Normal version
|
||||
|
||||
### 1.1 Five-part structure
|
||||
|
||||
```
|
||||
[Title line]
|
||||
[📊 Stats block + Top 10 leaderboard]
|
||||
[Opening summary — 1-2 paragraphs of prose]
|
||||
[群友画像 — one entry per active user (3+ msgs)]
|
||||
[Categorized body — 3-6 self-named sections per day]
|
||||
[Optional pain-point section]
|
||||
[Fixed footer]
|
||||
```
|
||||
|
||||
### 1.2 Title line
|
||||
|
||||
- Single line, no markdown heading.
|
||||
- Form: `{群名} 群聊精华 · {日期或日期区间}`
|
||||
- Date single day: `2026-03-12`. Date range: `2026-03-12 ~ 2026-03-15`.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
相亲相爱一家人 群聊精华 · 2026-03-12
|
||||
```
|
||||
|
||||
### 1.3 Statistics block
|
||||
|
||||
- Starts with `📊 消息统计: 共 N 条消息`.
|
||||
- Followed by a leaderboard, top 10 senders by message count, one per line.
|
||||
- Form per line: `{排名}. {昵称}: {消息数} 条`
|
||||
- Counting rules:
|
||||
- Include images, emojis, links, voice transcripts — anything that occupies a chat row is one message.
|
||||
- Exclude system messages and revoked messages (`[系统]`, `revokemsg`).
|
||||
- For the `self_wxid` user, substitute `self_display` from EXTEND.md before counting/displaying.
|
||||
- Resolve ambiguous nicknames (per SKILL.md Step 3.6) before tallying so the same person isn't double-counted.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
📊 消息统计: 共 387 条消息
|
||||
1. 蛙总: 92 条
|
||||
2. 老王: 58 条
|
||||
3. 阿喵: 41 条
|
||||
...
|
||||
```
|
||||
|
||||
### 1.4 Opening summary
|
||||
|
||||
- 1-2 paragraphs, plain prose, no headings, no bullets.
|
||||
- Hook the reader: lead with the most distinctive thread of the day (a heated debate, a surprising announcement, a market move someone reacted to).
|
||||
- Reference 2-4 of the day's category titles in the prose so the reader knows what's coming.
|
||||
- Mention 1-2 specific people only if their contribution is central; otherwise stay topic-focused.
|
||||
- No timestamps, no message counts (those live in the stats block).
|
||||
|
||||
### 1.5 群友画像 section
|
||||
|
||||
- Heading line: `群友画像`
|
||||
- One entry per user with 3+ messages this batch.
|
||||
- Order: by message count, descending.
|
||||
- Entry header: `{昵称}({角色标签})` — the role tag is your one-line read on this person *today*. Examples: `做空美股的乐子人`, `深夜技术指导`, `论坛级吐槽担当`.
|
||||
- Body: 2-5 bullets with `•` prefix. Each bullet states one observation. Quote evidence inline where natural.
|
||||
- Continuity: if you loaded a prior profile in Step 3.7, carry forward the established tags/observations that still apply, and call out *change* explicitly (`今天罕见地没提空头`, `从昨天的乐观转向今天的焦虑`).
|
||||
- Don't invent backstory — only what's in the messages or the prior profile.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
群友画像
|
||||
|
||||
蛙总(做空美股的乐子人)
|
||||
• 全天反复提"做空 SPY",被群友提醒已连续三周看错方向
|
||||
• 难得正面回应技术问题:"我那个脚本是用 Bun 跑的,慢得跟蜗牛似的"
|
||||
• 临近收盘转为沉默,与昨日大放厥词的状态对比明显
|
||||
```
|
||||
|
||||
### 1.6 Categorized body
|
||||
|
||||
- 3-6 self-named categories per day.
|
||||
- Each category is a thematic bucket — name it for the *topic*, not generic ("讨论"、"闲聊" are forbidden labels).
|
||||
- Category header: `{emoji} {标题}` — one emoji prefix, then a short noun phrase.
|
||||
- Suggested emoji: 🛠 工具/技术,📦 产品发布,📰 新闻/市场,💬 观点辩论,😄 笑料/段子,📚 学习分享,💸 钱与消费,🍜 生活日常。
|
||||
- Body inside each category: prose with embedded quotes. Use `•` bullets when listing 3+ parallel items; otherwise paragraphs.
|
||||
- Attribution: name the speaker on first mention in a thread (`蛙总说他...`). For follow-on lines in the same thread, attribution can be implicit if the chain is short and clear.
|
||||
- Quotes: use 「」 for direct quotes. Quote when the wording is vivid, surprising, or characteristic; paraphrase otherwise.
|
||||
- Merge: a multi-person discussion is one entry, not a list of one-line replies.
|
||||
- Links: preserve the full URL inline. Article titles stay verbatim.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
🛠 Claude Code 4.7 实测
|
||||
|
||||
蛙总下午把 4.7 装上后第一反应是「比 4.6 慢一倍」,老王跟着复现,怀疑是 Opus 默认配置导致。阿喵贴了官方文档 https://docs.claude.com/.../opus-4-7 ,提到可以切回 Sonnet 4.6 跑速测,三人最终结论:复杂任务 4.7 强,日常用 4.6 更顺手。
|
||||
```
|
||||
|
||||
### 1.7 Pain-point section (optional)
|
||||
|
||||
- Include only when the day's chat contains at least one concrete unresolved or partially-resolved problem.
|
||||
- Heading: `今日待解决问题` or `本周悬而未决`.
|
||||
- One entry per problem. Format:
|
||||
```
|
||||
问题:<一句话描述>
|
||||
提出者:<昵称>
|
||||
背景:<1-2 句来龙去脉>
|
||||
状态:<✅ 已解决 / ⚠️ 部分解决 / ❌ 仍未解决>
|
||||
方案:<若有人提了方案,写在这;否则写"暂无方案">
|
||||
```
|
||||
- Skip the section entirely if there are no genuine pain points — don't pad with trivial questions.
|
||||
|
||||
### 1.8 Footer
|
||||
|
||||
Fixed line, last in file:
|
||||
|
||||
```
|
||||
本简报由 AI 自动生成
|
||||
```
|
||||
|
||||
No date, no signature, no version number.
|
||||
|
||||
---
|
||||
|
||||
## 2. Roast version (毒舌版)
|
||||
|
||||
Roast 版基于普通版的话题骨架和素材,用毒舌、尖锐、挑衅的风格重写。整体结构与普通版相同(统计区块、开头概览、群友画像、正文分类、结尾),但风格完全不同。痛点部分省略。仅当 `include_roast=true` 时生成。标题加 "毒舌版" 后缀。
|
||||
|
||||
风格要求:
|
||||
- 你是一位以尖锐和挑衅风格著称的专业评论员
|
||||
- 对每个群友的行为、言论进行犀利点评,不怕让人尴尬
|
||||
- 发言排行旁给每个人加一句毒舌备注(括号内)
|
||||
- 群友画像改为「不留情面版」,放大每个人的槽点和矛盾之处
|
||||
- 开头概览用更戏谑的口吻,突出荒诞和讽刺
|
||||
- 正文话题标题可以改得更损
|
||||
- 引用原话时配上辛辣点评
|
||||
- 结尾改为:本简报由一个没有感情的 AI 自动生成,如有冒犯,概不负责
|
||||
|
||||
注意:毒舌但不恶毒,调侃但不人身攻击。目标是让群友看了会笑,而不是生气。具体红线:
|
||||
- 只嘲讽群里的公开行为,不碰外貌、体重、健康、家庭、私人关系
|
||||
- 不用时间戳推断作息或时区(服务器时间不等于本地时间)
|
||||
- 不做医学/心理诊断类玩笑(「这位需要看医生」「典型 ADHD」)
|
||||
- 不揣测对方未主动公开的身份属性(性取向、宗教、政治立场)
|
||||
- 嘲讽观点本身,不嘲讽发言的权利(「这个观点错得离谱」可以,「连这都不懂还敢发言」不行)
|
||||
- 如果某人本期没有槽点(3+ 条但都很中性),给一句温和调侃即可,不要硬凑
|
||||
|
||||
**写作顺序:** 先放开写最狠的版本,写完再回头检查红线。不要边写边自我审查,那样只会写出温吞水。
|
||||
|
||||
---
|
||||
|
||||
## 3. Common formatting rules (both versions)
|
||||
|
||||
- **No markdown.** No `**bold**`, no `# headings`, no `*italic*`, no `[link](url)` syntax. Headings are plain text on their own line.
|
||||
- **Bullets use `•`.** Not `-`, not `*`, not `1.` for prose-style bullets.
|
||||
- **Numbered lists** (`1.`, `2.`) are reserved for the leaderboard.
|
||||
- **Subcategory hints** within a body block are plain text with no symbol prefix.
|
||||
- **Links preserved verbatim.** Paste the full URL inline. Don't shorten, don't hide behind text.
|
||||
- **One emoji per category title.** Don't stack 🛠💬 etc.
|
||||
- **Pain-point statuses** use ✅⚠️❌ verbatim.
|
||||
- **Quotes use 「」.** Single quotes for nested.
|
||||
- **Names verbatim.** Don't abbreviate `蛙总` to `蛙`, don't translate Chinese names, don't anonymize.
|
||||
|
||||
---
|
||||
|
||||
## 4. Common content rules (both versions)
|
||||
|
||||
- **Filter only pure noise.** Cut: lone emoji reactions, "好的"/"收到"/"哈哈哈" with no follow-on, duplicate forwards.
|
||||
- **Keep gossip, anecdotes, signature moments.** These are the highlight reel — the whole point of the digest.
|
||||
- **Plain language.** Preserve vivid expressions and idiosyncratic phrasings — that's what makes the speaker recognizable.
|
||||
- **Keep real names.** Both for traceability and so the digest is useful as memory.
|
||||
- **Tool, product, URL names complete.** `Claude Code 4.7`, not `CC`. `https://github.com/...`, not `GitHub 上那个项目`.
|
||||
- **Merge, don't list.** A 30-message debate becomes one paragraph, not 30 bullet points.
|
||||
- **Direct-quote deep observations.** When someone says something striking, quote it verbatim with 「」 rather than paraphrase.
|
||||
- **Shared articles → title + sharer.** `阿喵分享了《一个 Rust 工程师的反思》` — include the title and who shared.
|
||||
- **No timestamp-based sleep/timezone inference.** (Repeated here because it applies to both versions, not just roast — never say `凌晨 3 点还在线` in either.)
|
||||
- **No fabricated facts.** Every claim must be supported by an actual message in the batch (or in a loaded profile). If you're tempted to "add color," stop.
|
||||
|
||||
---
|
||||
|
||||
## 5. Output skeleton — quick reference
|
||||
|
||||
When you forget the structure mid-write, this is the skeleton:
|
||||
|
||||
### Normal
|
||||
|
||||
```
|
||||
{群名} 群聊精华 · {日期}
|
||||
|
||||
📊 消息统计: 共 N 条消息
|
||||
1. {昵称}: N 条
|
||||
2. {昵称}: N 条
|
||||
...
|
||||
10. {昵称}: N 条
|
||||
|
||||
{开篇 1-2 段,无标题,直入主题}
|
||||
|
||||
群友画像
|
||||
|
||||
{昵称}({角色标签})
|
||||
• {观察 1}
|
||||
• {观察 2}
|
||||
• {观察 3}
|
||||
|
||||
{昵称}({角色标签})
|
||||
• {观察 1}
|
||||
• {观察 2}
|
||||
|
||||
🛠 {分类标题 1}
|
||||
|
||||
{该分类下的整理过的讨论 / 段落 / 引用}
|
||||
|
||||
📦 {分类标题 2}
|
||||
|
||||
{...}
|
||||
|
||||
今日待解决问题(可选,没有就不写)
|
||||
|
||||
问题: {一句话}
|
||||
提出者: {昵称}
|
||||
背景: {1-2 句}
|
||||
状态: ⚠️ 部分解决
|
||||
方案: {若有}
|
||||
|
||||
本简报由 AI 自动生成
|
||||
```
|
||||
|
||||
### Roast
|
||||
|
||||
```
|
||||
{群名} 群聊精华 · {日期} · 毒舌版
|
||||
|
||||
📊 消息统计: 共 N 条消息
|
||||
1. {昵称}: N 条 ({毒舌评语})
|
||||
2. {昵称}: N 条 ({毒舌评语})
|
||||
...
|
||||
|
||||
{毒舌开篇 1-2 段}
|
||||
|
||||
群友画像
|
||||
|
||||
{昵称}({放大的角色标签})
|
||||
• {毒舌观察 1}
|
||||
• {毒舌观察 2}
|
||||
|
||||
🛠 {更大声的分类标题}
|
||||
|
||||
{保留真实引用的毒舌叙述}
|
||||
|
||||
本简报由一个没有感情的 AI 自动生成,如有冒犯,概不负责
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Self-check before saving
|
||||
|
||||
Before writing the digest file, mentally walk through:
|
||||
|
||||
1. Stats block accurate? Counts match the filtered message set?
|
||||
2. Top 10 names resolved (self_display substituted, ambiguous nicknames disambiguated)?
|
||||
3. Opening hooks at least one real category title?
|
||||
4. Every active user (3+ msgs) has a 画像 entry?
|
||||
5. Every category has a topic-named title (not "讨论")?
|
||||
6. Every quote uses 「」 and is traceable to a real message?
|
||||
7. Links inline and complete?
|
||||
8. No markdown bold/heading/link syntax leaked through?
|
||||
9. (Roast only) Every roast bullet would pass the §2 红线 audit?
|
||||
10. Footer line exact match?
|
||||
@@ -0,0 +1,291 @@
|
||||
# Profiles — user portrait files
|
||||
|
||||
This reference defines the per-user profile system. Profiles let the digest carry forward observations across many days so the 群友画像 section in each new digest can show continuity (`蛙总今天罕见地没提空头`) instead of starting from scratch.
|
||||
|
||||
Two parallel profile directories live alongside each group's digests:
|
||||
|
||||
- `profiles/` — observations sourced from the **normal** version of the digest.
|
||||
- `profiles-roast/` — observations sourced from the **roast** version.
|
||||
|
||||
They are kept strictly separate. The normal-version generation reads only `profiles/`; the roast-version generation reads only `profiles-roast/`. This prevents roast snark from contaminating the sober summary and vice versa.
|
||||
|
||||
Load this file during Step 3.7 (load profiles for active users), Step 8.5 (update profiles after digest is written), and Step 9 (backfill).
|
||||
|
||||
---
|
||||
|
||||
## 1. File format
|
||||
|
||||
### 1.1 Path & naming
|
||||
|
||||
- Normal: `wechat/{group_id}-{group_name}/profiles/{wxid}-{nickname}.md`
|
||||
- Roast: `wechat/{group_id}-{group_name}/profiles-roast/{wxid}-{nickname}.md`
|
||||
|
||||
The **stable** identifier is the `wxid` prefix. The `-{nickname}` suffix is for human browsability — if it changes, rename the file.
|
||||
|
||||
Filename sanitization: replace `/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`, NUL, and control characters with `_`. Trim trailing dots and whitespace. Cap total filename length at 200 chars (rare nicknames can be very long).
|
||||
|
||||
### 1.2 Frontmatter
|
||||
|
||||
YAML frontmatter at the top of every profile file:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: "<current display name>"
|
||||
wxid: "<wxid>"
|
||||
group_nicknames: ["<历史群昵称 1>", "<历史群昵称 2>"]
|
||||
aliases: ["<群友给的称呼 1>", "<群友给的称呼 2>"]
|
||||
tags: ["<标签 1>", "<标签 2>"]
|
||||
first_seen: "YYYY-MM-DD"
|
||||
last_seen: "YYYY-MM-DD"
|
||||
total_messages: N
|
||||
digest_appearances: N
|
||||
avg_messages_per_digest: N.N
|
||||
---
|
||||
```
|
||||
|
||||
Field rules:
|
||||
|
||||
- `name`: the most recent display name from `from_nickname` (or `self_display` for the owning user).
|
||||
- `wxid`: stable; never changes once written.
|
||||
- `group_nicknames`: append-only history of the user's own prior display names in the group. Push the prior `name` here when `name` changes. Dedupe, preserve chronological order (oldest → newest). Do not include the current `name`.
|
||||
- `aliases`: nicknames **other members** call this user (e.g., `蛙总`, `老王`, `X 哥`). Dedupe-append when observed in this batch. Do not include the current `name`, and do not duplicate `group_nicknames` entries — those record the user's own past handles, not how the group addresses them.
|
||||
- `tags`: free-form labels for the user, **independent** of the body's 角色标签 / 人设标签 section. Use for cross-cutting attributes that don't fit the role/personality framing (region, profession, community, recurring long-form interests, etc.). Agent may append or refine when observing stable patterns. No hard cap.
|
||||
- `first_seen` / `last_seen`: dates of first/most-recent digest appearance, YYYY-MM-DD.
|
||||
- `total_messages`: cumulative count across all digests this profile has been updated from.
|
||||
- `digest_appearances`: how many digest files this user has 3+ messages in.
|
||||
- `avg_messages_per_digest`: `total_messages / digest_appearances`, one decimal.
|
||||
|
||||
**Backwards compatibility**: earlier versions of this skill used `aliases` for what is now `group_nicknames`. When reading an existing profile that lacks `group_nicknames` or `tags`, treat missing fields as `[]` and add them on the next write. **Do not auto-migrate** non-empty legacy `aliases` values — the agent can't reliably tell historical display names apart from community-given nicknames. Leave the values in `aliases`; the user can move historical display names into `group_nicknames` manually if desired.
|
||||
|
||||
### 1.3 Free-form body — normal profile
|
||||
|
||||
Section headers are plain text on their own line. Order is fixed.
|
||||
|
||||
```
|
||||
角色标签
|
||||
|
||||
• {4-6 短语标签}
|
||||
|
||||
关注领域
|
||||
|
||||
• {领域 1}
|
||||
• {领域 2}
|
||||
|
||||
发言风格
|
||||
|
||||
{1-3 句描述,可以多段}
|
||||
|
||||
互动模式
|
||||
|
||||
• {与某某的互动模式}
|
||||
• {另一种互动模式}
|
||||
|
||||
经典金句
|
||||
|
||||
• [YYYY-MM-DD] 「{直接引用}」
|
||||
• [YYYY-MM-DD] 「{直接引用}」
|
||||
|
||||
标志性事件
|
||||
|
||||
• [YYYY-MM-DD] {事件描述}
|
||||
• [YYYY-MM-DD] {事件描述}
|
||||
```
|
||||
|
||||
### 1.4 Free-form body — roast profile
|
||||
|
||||
Same plain-text section header style, different sections.
|
||||
|
||||
```
|
||||
人设标签
|
||||
|
||||
• {4-6 放大版标签}
|
||||
|
||||
核心槽点
|
||||
|
||||
• {可吐槽点 1}
|
||||
• {可吐槽点 2}
|
||||
|
||||
毒舌语录库
|
||||
|
||||
• [YYYY-MM-DD] 「{该用户说过的话} — {简短毒舌点评}」
|
||||
• [YYYY-MM-DD] 「{...}」
|
||||
|
||||
经典翻车现场
|
||||
|
||||
• [YYYY-MM-DD] {翻车描述 + 引用 / 证据}
|
||||
• [YYYY-MM-DD] {...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Update rules
|
||||
|
||||
Rules differ per section. Append-only sections must never lose history; mergeable sections may be rewritten as understanding sharpens.
|
||||
|
||||
### 2.1 Normal profile
|
||||
|
||||
| Section | Update mode | Notes |
|
||||
|---------|-------------|-------|
|
||||
| 角色标签 | **Merge** | Cap 4-6 tags. Can replace less representative tags with stronger ones. Always keep the most consistently-supported tag. |
|
||||
| 关注领域 | **Merge dedupe** | Add new domains; dedupe by meaning, not exact string. |
|
||||
| 发言风格 | **Refine** | Only update when a clearly new pattern emerges. Avoid rewriting on every digest. |
|
||||
| 互动模式 | **Merge** | Add new modes; can refine existing ones with more detail. |
|
||||
| 经典金句 | **Append-only** | Never delete. No cap. Each entry must be dated and quoted verbatim. |
|
||||
| 标志性事件 | **Append-only** | Never delete. No cap. Each entry dated. |
|
||||
|
||||
### 2.2 Roast profile
|
||||
|
||||
| Section | Update mode | Notes |
|
||||
|---------|-------------|-------|
|
||||
| 人设标签 | **Merge** | Cap 4-6. Can sharpen tags as patterns repeat. |
|
||||
| 核心槽点 | **Append-only** | Never delete; recurring 槽点 build up here. |
|
||||
| 毒舌语录库 | **Append-only** | Never delete. No cap. Each entry dated, with both the quote and the roast comment. |
|
||||
| 经典翻车现场 | **Append-only** | Never delete. No cap. Each entry dated. |
|
||||
|
||||
### 2.3 Frontmatter on every update
|
||||
|
||||
- If the current display name differs from the recorded `name`:
|
||||
- Push the old `name` onto `group_nicknames` if not already there (dedupe, preserve chronological order).
|
||||
- Update `name` to the current display name.
|
||||
- Rename the file from `{wxid}-{old_nickname}.md` to `{wxid}-{new_nickname}.md`.
|
||||
- Scan this batch for nicknames **other members** use to address this user, and dedupe-append into `aliases`. Signals:
|
||||
- `@mention` resolving to this `wxid`.
|
||||
- Direct salutations targeting this user with a name different from `name` (e.g., `蛙总你怎么看`, `老王说得对`).
|
||||
- Quoted references in the digest body that name this user as someone other than their current `name`.
|
||||
- Only add when attribution is unambiguous; skip uncertain matches.
|
||||
- If this batch reveals a stable cross-cutting attribute that doesn't fit the role/personality framing of 角色标签 / 人设标签 (region, profession, community, durable interest, etc.), append or refine `tags`. `tags` is independent of the body's tag sections — don't mirror them.
|
||||
- Update `last_seen` to the current digest's end date.
|
||||
- Increment `total_messages` by this batch's message count for this user.
|
||||
- Increment `digest_appearances` by 1.
|
||||
- Recompute `avg_messages_per_digest`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Step 8.5 — Update procedure
|
||||
|
||||
Run after the digest file(s) are written. Iterate over every user with 3+ messages in this batch.
|
||||
|
||||
1. **Look up the profile.**
|
||||
- Scan `profiles/` (or `profiles-roast/` for the roast pass) for a file whose name starts with `{wxid}-`.
|
||||
- If found: open it.
|
||||
- If not found: create a new file using the frontmatter template. `group_nicknames = []`, `aliases = []`, `tags = []`, `first_seen = last_seen = current digest end date`, `total_messages = this batch's count`, `digest_appearances = 1`. Then run §2.3 to seed observed aliases/tags from this batch.
|
||||
|
||||
2. **Resolve wxid for new users.** When a new user appears, you already know their `wxid` from the wx-cli message data — use it directly. If for some reason only the nickname is known, run `wx contacts --query "{nickname}" --json` to resolve; if multiple matches, prefer the one currently in the group (cross-check `wx members <group>` if needed).
|
||||
|
||||
3. **Update frontmatter.** Per §2.3.
|
||||
|
||||
4. **Update body sections.**
|
||||
- For mergeable sections (角色标签,关注领域,发言风格,互动模式 / roast: 人设标签): read the existing content, integrate new observations from this batch, rewrite the section.
|
||||
- For append-only sections (经典金句,标志性事件 / roast: 毒舌语录库,经典翻车现场,核心槽点): append new entries, each dated and verbatim. Never edit or remove prior entries.
|
||||
|
||||
5. **Write back.** Overwrite the file.
|
||||
|
||||
6. **Source separation.** Pass running for the normal digest writes only to `profiles/`. Pass running for the roast digest writes only to `profiles-roast/`. Even if both versions are generated in the same skill invocation, run two separate update passes.
|
||||
|
||||
---
|
||||
|
||||
## 4. Step 9 — Backfill procedure
|
||||
|
||||
Triggered when the user says `回溯画像`, `初始化画像`, `backfill profiles`, or similar. This builds initial profiles from already-written digest files without re-fetching from wx-cli.
|
||||
|
||||
1. **List inputs.**
|
||||
- List every `*.md` digest file under `wechat/{group_id}-{group_name}/` (top level, not inside `profiles/` or `profiles-roast/`).
|
||||
- Partition by filename suffix: `*-roast.md` → roast pass, all others → normal pass.
|
||||
- Optionally also read `history-digests.jsonl` for fast metadata lookup (date, message count) before opening individual files.
|
||||
|
||||
2. **Decide whether to run roast backfill.** Only run the roast pass if at least one `*-roast.md` file exists.
|
||||
|
||||
3. **Process in batches of 10-15 digest files.** Reading all of them at once will blow context. For each batch:
|
||||
- Read the digests.
|
||||
- For each user appearing in the leaderboard or 群友画像 across the batch, accumulate:
|
||||
- Message counts per digest (from the stats block).
|
||||
- Role tags and observations (from the 群友画像 section).
|
||||
- Quotes (from inline 「」 in the body).
|
||||
- Dated events (from category bodies — when the digest mentions specific incidents).
|
||||
- Resolve wxid for each accumulated user via `wx contacts --query "{nickname}" --json` if not already cached. Cache the wxid↔nickname mapping for the rest of the backfill.
|
||||
|
||||
4. **Threshold.** Generate a profile file only for users appearing in **3 or more** digests in the corpus. Below that, skip (probably one-time visitors).
|
||||
|
||||
5. **Write profile files.**
|
||||
- For the normal pass, write to `profiles/{wxid}-{nickname}.md`.
|
||||
- For the roast pass, write to `profiles-roast/{wxid}-{nickname}.md`.
|
||||
- Use the most recent nickname as the filename suffix. Push older display names into `group_nicknames` (see step 6 for the field-by-field rules).
|
||||
- Sort 经典金句,标志性事件,毒舌语录库,经典翻车现场 entries chronologically by date.
|
||||
- No cap on the size of append-only sections during backfill — let history flow in.
|
||||
|
||||
6. **Compute frontmatter.**
|
||||
- `first_seen` = earliest digest date the user appeared in.
|
||||
- `last_seen` = latest digest date the user appeared in.
|
||||
- `total_messages` = sum of per-digest counts.
|
||||
- `digest_appearances` = number of digests the user crossed the 3-message threshold in.
|
||||
- `group_nicknames` = best-effort. If the same `wxid` appears under multiple distinct display names across historical digests (e.g., via the leaderboard line "X — N 条" where X varied), fill the older ones in chronological order (newest stays in `name`). If chronological order is unclear, dedupe and let later runs correct.
|
||||
- `aliases` = best-effort. Scan historical digest bodies for forms where another member calls this user by a name different from their current `name` (@mentions, direct salutations). Skip uncertain matches; leave `[]` if nothing reliable surfaces.
|
||||
- `tags` = `[]`. Backfill does not seed `tags`; let normal runs accumulate them.
|
||||
|
||||
7. **Report.** After both passes complete, print a short summary:
|
||||
- `Backfilled {N} normal profiles from {M} digests.`
|
||||
- `Backfilled {K} roast profiles from {L} roast digests.` (only if roast pass ran)
|
||||
- List any users skipped due to wxid resolution failures so the user can fix manually.
|
||||
|
||||
8. **Re-running backfill is safe.** If the user runs backfill twice, treat existing profile files as the prior state and merge — same rules as Step 8.5 updates. Don't blow away existing append-only entries.
|
||||
|
||||
---
|
||||
|
||||
## 5. Privacy guardrails
|
||||
|
||||
These apply to both normal and roast profiles, with an extra layer for roast.
|
||||
|
||||
### 5.1 Forbidden (write neither in normal nor roast)
|
||||
|
||||
- **Real-world full names** when only a nickname was used in the group. If the person introduced themselves with `我叫王二`, `王二` is on the table; `王晓明` inferred from another channel is not.
|
||||
- **Phone numbers, emails, ID numbers, home addresses, employer addresses, exact birth dates** — even if mentioned in the group, don't lift them into profile files.
|
||||
- **Health, medical, psychological information.** Even self-disclosed (`我最近有点抑郁`) — don't bake it into a permanent profile.
|
||||
- **Private romantic / family details** unless openly group-discussed by the person themselves. A passing mention by another member doesn't count.
|
||||
- **Embarrassing private failures.** Public ones (a take that aged badly in front of the group) are fair game; private ones (a job rejection mentioned briefly) are not.
|
||||
- **Sleep / timezone inference from timestamps.** Server time ≠ recipient's local time, and it implies surveillance.
|
||||
|
||||
### 5.2 Allowed
|
||||
|
||||
- **Public group behavior** — what they said, how they argued, what they shared.
|
||||
- **Direct quotes** of things said in the group (these are already public to the group).
|
||||
- **Interest areas, hobbies, tool preferences** as expressed in group discussion.
|
||||
- **Interaction patterns** with other group members.
|
||||
- **Publicly mentioned consumption** (`蛙总今天又分享了买了什么书`) — fine if they themselves mentioned it.
|
||||
- **Publicly shared travel / life anecdotes** they told the group.
|
||||
|
||||
### 5.3 Roast-only extras
|
||||
|
||||
In addition to §5.1, the roast profile must **not** include:
|
||||
|
||||
- **Anything about appearance, weight, body, looks.**
|
||||
- **Anything about family members** (their kids, parents, partners) — only the person themselves.
|
||||
- **Mental-health speculation**, even as a joke. No `这位需要看医生`, no `典型 ADHD`.
|
||||
- **Identity-based roasts.** No mocking of orientation, religion, ethnicity, nationality, gender.
|
||||
|
||||
The roast may mock:
|
||||
|
||||
- Stupid takes, contradictions, factual errors.
|
||||
- Repetitive behavior (`第 47 次预测见顶`).
|
||||
- Self-undermining moments (`昨天说 X,今天说 not X`).
|
||||
- Performative flexes that didn't land.
|
||||
|
||||
The rule of thumb: **roast the take, not the person.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Reading profiles during digest generation (Step 3.7)
|
||||
|
||||
When loading profile context for a fresh digest:
|
||||
|
||||
1. Iterate over users active in this batch (3+ messages).
|
||||
2. For the normal pass, read `profiles/{wxid}-*.md` for each. Skip if missing.
|
||||
3. If the current run also generates the roast version, **separately** read `profiles-roast/{wxid}-*.md` during the roast generation pass.
|
||||
4. Compile a condensed working-memory block:
|
||||
- The user's current `name`, `group_nicknames`, and `aliases` (so you can recognize them under prior display names or community-given nicknames).
|
||||
- `tags` (cross-cutting attributes — region, profession, community — useful for callouts in 群友画像).
|
||||
- 角色标签 / 人设标签 (so you can carry forward or contrast).
|
||||
- The 3-5 most recent 经典金句 / 毒舌语录 entries (so you can detect callbacks and repeats).
|
||||
- The 3-5 most recent 标志性事件 / 翻车现场 entries (so you can spot recurring themes).
|
||||
5. Don't dump the entire profile into the digest — the profile is *context*, the digest is *today*.
|
||||
|
||||
If a profile contradicts what you see in today's batch (e.g., the profile says `从不主动发起话题`, but today they started three threads), call that out explicitly in the day's 群友画像 — that's the kind of contrast that makes the digest interesting.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-xhs-images
|
||||
description: "[Deprecated: use baoyu-image-cards] Generates Xiaohongshu (Little Red Book) image card series with 12 visual styles, 8 layouts, and 3 color palettes. Breaks content into 1-10 cartoon-style image cards optimized for XHS engagement. Use when user mentions \"小红书图片\", \"XHS images\", \"RedNote infographics\", \"小红书种草\", \"小绿书\", \"微信图文\", \"微信贴图\", or wants social media infographic series for Chinese platforms."
|
||||
version: 1.56.2
|
||||
version: 1.57.0
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-xhs-images
|
||||
@@ -29,17 +29,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.
|
||||
- **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-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **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-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.
|
||||
|
||||
**⛔ 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 titles, body copy, tags, or any other text inside an already generated image card. If text is wrong or unclear, regenerate from a corrected prompt, switch to a layout with less on-card 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 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.
|
||||
|
||||
## 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 the image-1 anchor chain: generate image 1 first, then batch images 2+ using image 1 as the reference.
|
||||
- Never start a batch until every selected prompt file for that batch exists 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
|
||||
|
||||
Default behavior: **confirm before generation**.
|
||||
@@ -62,6 +84,7 @@ Respond in the user's language across questions, progress, errors, and completio
|
||||
| `--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 |
|
||||
| `--batch-size <n>` | Temporary generation batch size for this run. Default: `generation_batch_size` from EXTEND.md, otherwise 4. Clamp to 1-8. |
|
||||
| `--yes` | Non-interactive: skip all confirmations, use EXTEND.md or built-in defaults, auto-confirm recommended plan (Path A) |
|
||||
|
||||
## Dimensions
|
||||
@@ -297,7 +320,7 @@ Check these paths in order; first hit wins:
|
||||
- **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`.
|
||||
**EXTEND.md keys**: watermark, preferred style/layout, custom style definitions, language preference, preferred image backend, generation batch size. Schema: `references/config/preferences-schema.md`.
|
||||
|
||||
### Step 1: Analyze Content → `analysis.md`
|
||||
|
||||
@@ -346,14 +369,13 @@ 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):
|
||||
Generation flow:
|
||||
|
||||
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.
|
||||
1. Write the full prompt for every image to `prompts/NN-{type}-{slug}.md` in the user's preferred language (backup rule applies), then verify all selected prompt files exist.
|
||||
2. Generate **image 1** first without `--ref`; backup rule applies to the PNG file. This establishes the anchor.
|
||||
3. Build a task list for **images 2+** using image 1 as `--ref <path-to-image-01.png>`.
|
||||
4. Dispatch images 2+ in batches per the `## Batch Generation Policy`: backend native batch first, runtime parallel tool calls second, sequential only as fallback.
|
||||
5. Report progress after each completed image. On failure, retry only the failed item once from the same saved prompt file.
|
||||
|
||||
**Watermark** (if enabled in EXTEND.md): append to the generation prompt:
|
||||
|
||||
@@ -412,6 +434,12 @@ For the style × layout compatibility matrix, see the **Style × Layout Matrix**
|
||||
|
||||
Always update the prompt file before regenerating — it's the source of truth and makes changes reproducible.
|
||||
|
||||
Text correction policy:
|
||||
|
||||
- If a card's title, body copy, tags, 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.
|
||||
|
||||
## References
|
||||
|
||||
| File | Content |
|
||||
@@ -448,5 +476,6 @@ EXTEND.md lives at the first matching path listed in Step 0. Three ways to chang
|
||||
- `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.
|
||||
- `generation_batch_size: 4` — default number of images to render concurrently when the backend/runtime supports batch or parallel generation.
|
||||
- `preferred_style: notion`, `preferred_layout: dense`, `preferred_palette: macaron`, `language: zh`.
|
||||
- `watermark.enabled: true` + `watermark.content: "@handle"` — add a watermark.
|
||||
|
||||
@@ -111,12 +111,15 @@ preferred_style:
|
||||
preferred_layout: null
|
||||
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`.
|
||||
|
||||
@@ -26,6 +26,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 generation
|
||||
|
||||
custom_styles:
|
||||
- name: my-style
|
||||
description: "Style description"
|
||||
@@ -52,6 +54,7 @@ custom_styles:
|
||||
| `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. |
|
||||
| `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
|
||||
@@ -109,6 +112,8 @@ language: zh
|
||||
|
||||
preferred_image_backend: codex-imagegen
|
||||
|
||||
generation_batch_size: 4
|
||||
|
||||
custom_styles:
|
||||
- name: corporate
|
||||
description: "Professional B2B style"
|
||||
|
||||
Reference in New Issue
Block a user