mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-14 06:39:48 +08:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cb54420e0 | |||
| 6363bd83e2 | |||
| 7b8247544d | |||
| a9576ebc67 | |||
| 79ca378229 | |||
| e1a1fe23cb | |||
| 3d85a7e663 | |||
| 10aabb39f8 | |||
| 661c74cce4 | |||
| 82445f2a39 | |||
| df9ce95c46 | |||
| 88b433d565 | |||
| 5acef7151b | |||
| e4d4ec8334 | |||
| 4935dc8b1a | |||
| c43ed57ffc | |||
| d96368492e | |||
| cfc647fd38 | |||
| a702513487 | |||
| 6b2b28b421 | |||
| 0df63c3a0b | |||
| 7834d632e0 | |||
| ca9a0a1404 | |||
| 366e7b5403 | |||
| 709e026be1 |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.57.0"
|
"version": "1.59.1"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,45 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 1.59.1 - 2026-03-11
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-translate`: improve short text annotation density rule and add explicit style preset passing to 02-prompt.md
|
||||||
|
- `baoyu-post-to-x`: remove `--disable-blink-features=AutomationControlled` Chrome flag
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
- `baoyu-post-to-weibo`: add entry point guard to md-to-html.ts for module import compatibility
|
||||||
|
- Replace clawhub CLI with local sync-clawhub.mjs script
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Update CLAUDE.md to reflect v1.59.0 codebase state (by @jackL1020)
|
||||||
|
|
||||||
|
## 1.59.0 - 2026-03-09
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-image-gen`: add batch parallel image generation and provider-level throttling (by @SeamoonAO)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-image-gen`: restore Google as default provider when multiple keys available
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Improve skill documentation clarity (by @SeamoonAO)
|
||||||
|
|
||||||
|
## 1.58.0 - 2026-03-08
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- Add XDG config path support for EXTEND.md (by @liby)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-post-to-wechat`: surface agent-browser startup errors
|
||||||
|
- `baoyu-post-to-wechat`: harden agent-browser command and eval handling (by @luojiyin1987)
|
||||||
|
- `baoyu-image-gen`: use execFileSync for google curl requests (by @luojiyin1987)
|
||||||
|
- `baoyu-format-markdown`: use spawnSync for autocorrect command (by @luojiyin1987)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Fix CLAUDE dependency statement (by @luojiyin1987)
|
||||||
|
- Add markdown-to-html to README utility skills (by @luojiyin1987)
|
||||||
|
|
||||||
## 1.57.0 - 2026-03-08
|
## 1.57.0 - 2026-03-08
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -2,6 +2,45 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.59.1 - 2026-03-11
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-translate`:改进短文本注释密度规则,补充风格预设到 02-prompt.md 的显式传递
|
||||||
|
- `baoyu-post-to-x`:移除 `--disable-blink-features=AutomationControlled` Chrome 启动参数
|
||||||
|
|
||||||
|
### 重构
|
||||||
|
- `baoyu-post-to-weibo`:为 md-to-html.ts 添加入口守卫,支持模块导入
|
||||||
|
- 使用本地 sync-clawhub.mjs 脚本替代 clawhub CLI
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- 更新 CLAUDE.md 以反映 v1.59.0 代码库状态 (by @jackL1020)
|
||||||
|
|
||||||
|
## 1.59.0 - 2026-03-09
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-image-gen`:新增批量并行图片生成和提供商级别限流 (by @SeamoonAO)
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-image-gen`:修复多个 API key 可用时恢复 Google 为默认提供商
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- 改进技能文档清晰度 (by @SeamoonAO)
|
||||||
|
|
||||||
|
## 1.58.0 - 2026-03-08
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- 新增 EXTEND.md 的 XDG 配置路径支持 (by @liby)
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-post-to-wechat`:暴露 agent-browser 启动错误信息
|
||||||
|
- `baoyu-post-to-wechat`:加固 agent-browser 命令和 eval 处理 (by @luojiyin1987)
|
||||||
|
- `baoyu-image-gen`:使用 execFileSync 替代 shell 执行 Google curl 请求 (by @luojiyin1987)
|
||||||
|
- `baoyu-format-markdown`:使用 spawnSync 替代 shell 执行 autocorrect 命令 (by @luojiyin1987)
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- 修正 CLAUDE 依赖说明 (by @luojiyin1987)
|
||||||
|
- 将 markdown-to-html 添加到 README 工具技能列表 (by @luojiyin1987)
|
||||||
|
|
||||||
## 1.57.0 - 2026-03-08
|
## 1.57.0 - 2026-03-08
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Claude Code marketplace plugin providing AI-powered content generation skills. Skills use Gemini Web API (reverse-engineered) for text/image generation and Chrome CDP for browser automation.
|
Claude Code marketplace plugin providing AI-powered content generation skills. Current version: **1.59.1**. Skills use official AI APIs (OpenAI, Google, DashScope, Replicate) or the reverse-engineered Gemini Web API for text/image generation, and Chrome CDP for browser automation.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Skills are organized into three plugin categories in `marketplace.json`:
|
Skills are organized into three plugin categories in `.claude-plugin/marketplace.json`:
|
||||||
|
|
||||||
```
|
```
|
||||||
skills/
|
skills/
|
||||||
@@ -19,16 +19,27 @@ skills/
|
|||||||
│ ├── baoyu-article-illustrator/ # Smart illustration placement
|
│ ├── baoyu-article-illustrator/ # Smart illustration placement
|
||||||
│ ├── baoyu-comic/ # Knowledge comics (Logicomix/Ohmsha style)
|
│ ├── baoyu-comic/ # Knowledge comics (Logicomix/Ohmsha style)
|
||||||
│ ├── baoyu-post-to-x/ # X/Twitter posting automation
|
│ ├── baoyu-post-to-x/ # X/Twitter posting automation
|
||||||
│ └── baoyu-post-to-wechat/ # WeChat Official Account posting
|
│ ├── baoyu-post-to-wechat/ # WeChat Official Account posting
|
||||||
|
│ ├── baoyu-post-to-weibo/ # Weibo posting (text, images, videos, articles)
|
||||||
|
│ └── baoyu-infographic/ # Professional infographics (21 layouts × 20 styles)
|
||||||
│
|
│
|
||||||
├── [ai-generation-skills] # AI-powered generation backends
|
├── [ai-generation-skills] # AI-powered generation backends
|
||||||
│ └── baoyu-danger-gemini-web/ # Gemini API wrapper (text + image gen)
|
│ ├── baoyu-danger-gemini-web/ # Gemini Web API wrapper (text + image gen)
|
||||||
|
│ └── baoyu-image-gen/ # Official API image gen (OpenAI, Google, DashScope, Replicate)
|
||||||
│
|
│
|
||||||
└── [utility-skills] # Utility tools for content processing
|
└── [utility-skills] # Utility tools for content processing
|
||||||
├── baoyu-danger-x-to-markdown/ # X/Twitter content to markdown
|
├── baoyu-danger-x-to-markdown/ # X/Twitter content to markdown
|
||||||
└── baoyu-compress-image/ # Image compression
|
├── baoyu-compress-image/ # Image compression (WebP/PNG via sips/cwebp/Sharp)
|
||||||
|
├── baoyu-url-to-markdown/ # Fetch any URL → markdown via Chrome CDP
|
||||||
|
├── baoyu-format-markdown/ # Format/beautify plain text or markdown files
|
||||||
|
├── baoyu-markdown-to-html/ # Markdown → styled HTML (WeChat-compatible themes)
|
||||||
|
└── baoyu-translate/ # Multi-mode translation (quick/normal/refined)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Top-level `scripts/` directory contains repository maintenance utilities:
|
||||||
|
- `scripts/sync-clawhub.sh` - Publish skills to ClawHub/OpenClaw registry
|
||||||
|
- `scripts/sync-md-to-wechat.sh` - Sync markdown content to WeChat
|
||||||
|
|
||||||
**Plugin Categories**:
|
**Plugin Categories**:
|
||||||
| Category | Description |
|
| Category | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
@@ -37,9 +48,10 @@ skills/
|
|||||||
| `utility-skills` | Helper tools for content processing (conversion, compression) |
|
| `utility-skills` | Helper tools for content processing (conversion, compression) |
|
||||||
|
|
||||||
Each skill contains:
|
Each skill contains:
|
||||||
- `SKILL.md` - YAML front matter (name, description) + documentation
|
- `SKILL.md` - YAML front matter (name, description, version, openclaw metadata) + documentation
|
||||||
- `scripts/` - TypeScript implementations
|
- `scripts/` - TypeScript implementations (optional, pure-prompt skills omit this)
|
||||||
- `prompts/system.md` - AI generation guidelines (optional)
|
- `references/` - Extended reference files linked from SKILL.md (optional)
|
||||||
|
- `prompts/` - AI prompt templates (optional, used by some content skills)
|
||||||
|
|
||||||
## Running Skills
|
## Running Skills
|
||||||
|
|
||||||
@@ -88,8 +100,9 @@ ${BUN_X} skills/baoyu-danger-gemini-web/scripts/main.ts --promptfiles system.md
|
|||||||
## Key Dependencies
|
## Key Dependencies
|
||||||
|
|
||||||
- **Bun**: TypeScript runtime (native `bun` preferred, fallback `npx -y bun`)
|
- **Bun**: TypeScript runtime (native `bun` preferred, fallback `npx -y bun`)
|
||||||
- **Chrome**: Required for `baoyu-danger-gemini-web` auth and `baoyu-post-to-x` automation
|
- **Chrome**: Required for `baoyu-danger-gemini-web` auth, `baoyu-post-to-x`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, and `baoyu-url-to-markdown`
|
||||||
- **No npm packages**: Self-contained TypeScript, no external dependencies
|
- **npm packages (per skill)**: Some skill subprojects include `package.json`/lockfiles and require dependency installation in their own `scripts/` directories
|
||||||
|
- **Image generation APIs**: `baoyu-image-gen` requires at least one API key (OpenAI, Google, DashScope, or Replicate)
|
||||||
|
|
||||||
## Chrome Profile (Unified)
|
## Chrome Profile (Unified)
|
||||||
|
|
||||||
@@ -166,10 +179,40 @@ Skills that process external Markdown/HTML should treat content as untrusted:
|
|||||||
- Cookies cached in data directory
|
- Cookies cached in data directory
|
||||||
- Force refresh: `--login` flag
|
- Force refresh: `--login` flag
|
||||||
|
|
||||||
|
`baoyu-image-gen` uses official API keys configured in EXTEND.md (loaded at Step 0 before generation).
|
||||||
|
|
||||||
## Plugin Configuration
|
## Plugin Configuration
|
||||||
|
|
||||||
`.claude-plugin/marketplace.json` defines plugin metadata and skill paths. Version follows semver.
|
`.claude-plugin/marketplace.json` defines plugin metadata and skill paths. Version follows semver.
|
||||||
|
|
||||||
|
The file contains:
|
||||||
|
- `name`: Plugin name (`baoyu-skills`)
|
||||||
|
- `owner`: Author info
|
||||||
|
- `metadata.version`: Semver version (currently `1.59.1`)
|
||||||
|
- `plugins[]`: Array of plugin categories, each with `name`, `description`, `source`, `strict`, and `skills[]` paths
|
||||||
|
|
||||||
|
## ClawHub / OpenClaw Publishing
|
||||||
|
|
||||||
|
Skills include `metadata.openclaw` in their YAML front matter for registry compatibility:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
metadata:
|
||||||
|
openclaw:
|
||||||
|
homepage: https://github.com/JimLiu/baoyu-skills#<skill-name>
|
||||||
|
requires: # optional — only for skills with scripts
|
||||||
|
anyBins:
|
||||||
|
- bun
|
||||||
|
- npx
|
||||||
|
```
|
||||||
|
|
||||||
|
To publish/sync skills to ClawHub registry:
|
||||||
|
```bash
|
||||||
|
bash scripts/sync-clawhub.sh # sync all skills
|
||||||
|
bash scripts/sync-clawhub.sh <skill> # sync one skill
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `clawhub` CLI or `npx` (auto-downloads via npx if not installed).
|
||||||
|
|
||||||
## Skill Loading Rules
|
## Skill Loading Rules
|
||||||
|
|
||||||
**IMPORTANT**: When working in this project, follow these rules:
|
**IMPORTANT**: When working in this project, follow these rules:
|
||||||
@@ -212,29 +255,50 @@ Skills that process external Markdown/HTML should treat content as untrusted:
|
|||||||
| **References** | Keep one level deep from SKILL.md; avoid nested references |
|
| **References** | Keep one level deep from SKILL.md; avoid nested references |
|
||||||
| **No time-sensitive info** | Avoid dates/versions that become outdated |
|
| **No time-sensitive info** | Avoid dates/versions that become outdated |
|
||||||
|
|
||||||
|
### SKILL.md Frontmatter Template
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: baoyu-<name>
|
||||||
|
description: <Third-person description. What it does + when to use it.>
|
||||||
|
version: <semver matching marketplace.json>
|
||||||
|
metadata:
|
||||||
|
openclaw:
|
||||||
|
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-<name>
|
||||||
|
requires: # include only if skill has scripts
|
||||||
|
anyBins:
|
||||||
|
- bun
|
||||||
|
- npx
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
### Steps
|
### Steps
|
||||||
|
|
||||||
1. Create `skills/baoyu-<name>/SKILL.md` with YAML front matter
|
1. Create `skills/baoyu-<name>/SKILL.md` with YAML front matter (see template above)
|
||||||
- Directory name: `baoyu-<name>`
|
- Directory name: `baoyu-<name>`
|
||||||
- SKILL.md `name` field: `baoyu-<name>`
|
- SKILL.md `name` field: `baoyu-<name>`
|
||||||
2. Add TypeScript in `skills/baoyu-<name>/scripts/`
|
- Always include `metadata.openclaw.homepage`
|
||||||
|
- Include `metadata.openclaw.requires.anyBins` only when the skill has executable scripts
|
||||||
|
2. Add TypeScript in `skills/baoyu-<name>/scripts/` (if applicable)
|
||||||
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
|
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
|
||||||
4. **Choose the appropriate category** and register in `marketplace.json`:
|
4. **Choose the appropriate category** and register in `marketplace.json`:
|
||||||
- `content-skills`: For content generation/publishing (images, slides, posts)
|
- `content-skills`: For content generation/publishing (images, slides, posts)
|
||||||
- `ai-generation-skills`: For AI backend capabilities
|
- `ai-generation-skills`: For AI backend capabilities
|
||||||
- `utility-skills`: For helper tools (conversion, compression)
|
- `utility-skills`: For helper tools (conversion, compression)
|
||||||
- If none fit, create a new category with descriptive name
|
- If none fit, create a new category with descriptive name
|
||||||
5. **Add Script Directory section** to SKILL.md (see template below)
|
5. **Add Script Directory section** to SKILL.md (see template below) if skill has scripts
|
||||||
|
6. **Add openclaw metadata** to SKILL.md frontmatter (required for ClawHub registry)
|
||||||
|
|
||||||
### Choosing a Category
|
### Choosing a Category
|
||||||
|
|
||||||
| If your skill... | Use category |
|
| If your skill... | Use category |
|
||||||
|------------------|--------------|
|
|------------------|--------------|
|
||||||
| Generates visual content (images, slides, comics) | `content-skills` |
|
| Generates visual content (images, slides, comics, infographics) | `content-skills` |
|
||||||
| Publishes to platforms (X, WeChat, etc.) | `content-skills` |
|
| Publishes to platforms (X, WeChat, Weibo, etc.) | `content-skills` |
|
||||||
| Provides AI generation backend | `ai-generation-skills` |
|
| Provides AI generation backend (official API or reverse-engineered) | `ai-generation-skills` |
|
||||||
| Converts or processes content | `utility-skills` |
|
| Converts or processes content (URL→md, md→html, format) | `utility-skills` |
|
||||||
| Compresses or optimizes files | `utility-skills` |
|
| Compresses or optimizes files | `utility-skills` |
|
||||||
|
| Translates documents | `utility-skills` |
|
||||||
|
|
||||||
**Creating a new category**: If the skill doesn't fit existing categories, add a new plugin object to `marketplace.json` with:
|
**Creating a new category**: If the skill doesn't fit existing categories, add a new plugin object to `marketplace.json` with:
|
||||||
- `name`: Descriptive kebab-case name (e.g., `analytics-skills`)
|
- `name`: Descriptive kebab-case name (e.g., `analytics-skills`)
|
||||||
@@ -266,10 +330,10 @@ Every SKILL.md with scripts MUST include this section after Usage:
|
|||||||
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
|
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
|
||||||
|
|
||||||
**Agent Execution Instructions**:
|
**Agent Execution Instructions**:
|
||||||
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
|
1. Determine this SKILL.md file's directory path as `{baseDir}`
|
||||||
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
|
2. Script path = `{baseDir}/scripts/<script-name>.ts`
|
||||||
3. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun
|
3. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun
|
||||||
4. Replace all `${SKILL_DIR}` and `${BUN_X}` in this document with actual values
|
4. Replace all `{baseDir}` and `${BUN_X}` in this document with actual values
|
||||||
|
|
||||||
**Script Reference**:
|
**Script Reference**:
|
||||||
| Script | Purpose |
|
| Script | Purpose |
|
||||||
@@ -278,7 +342,9 @@ Every SKILL.md with scripts MUST include this section after Usage:
|
|||||||
| `scripts/other.ts` | Other functionality |
|
| `scripts/other.ts` | Other functionality |
|
||||||
```
|
```
|
||||||
|
|
||||||
When referencing scripts in workflow sections, use `${BUN_X} ${SKILL_DIR}/scripts/<name>.ts` so agents can resolve the correct runtime and path.
|
When referencing scripts in workflow sections, use `${BUN_X} {baseDir}/scripts/<name>.ts` so agents can resolve the correct runtime and path.
|
||||||
|
|
||||||
|
> **Note**: The path variable was renamed from `SKILL_DIR` / `${SKILL_DIR}` to `baseDir` / `{baseDir}` (no `${}` wrapper) in v1.57.0. All existing SKILL.md files use `{baseDir}`.
|
||||||
|
|
||||||
### Progressive Disclosure
|
### Progressive Disclosure
|
||||||
|
|
||||||
@@ -327,7 +393,7 @@ Use this template when implementing image generation in skills:
|
|||||||
### Step N: Generate Images
|
### Step N: Generate Images
|
||||||
|
|
||||||
**Skill Selection**:
|
**Skill Selection**:
|
||||||
1. Check available image generation skills (e.g., `baoyu-danger-gemini-web`)
|
1. Check available image generation skills (`baoyu-image-gen` default, or `baoyu-danger-gemini-web`)
|
||||||
2. Read selected skill's SKILL.md for parameter reference
|
2. Read selected skill's SKILL.md for parameter reference
|
||||||
3. If multiple skills available, ask user to choose
|
3. If multiple skills available, ask user to choose
|
||||||
|
|
||||||
@@ -336,11 +402,14 @@ Use this template when implementing image generation in skills:
|
|||||||
- Prompt file path (or inline prompt)
|
- Prompt file path (or inline prompt)
|
||||||
- Output image path
|
- Output image path
|
||||||
- Any skill-specific parameters (refer to skill's SKILL.md)
|
- Any skill-specific parameters (refer to skill's SKILL.md)
|
||||||
2. Generate images sequentially (one at a time)
|
2. Generate images sequentially by default (one at a time)
|
||||||
|
- Use batch parallel mode only when user already has multiple prompts or explicitly wants parallel throughput
|
||||||
3. After each image, output progress: "Generated X/N"
|
3. After each image, output progress: "Generated X/N"
|
||||||
4. On failure, auto-retry once before reporting error
|
4. On failure, auto-retry once before reporting error
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Batch Parallel Generation** (`baoyu-image-gen` only): supports concurrent workers with per-provider throttling. Configure via `batch.max_workers` and `batch.provider_limits` in EXTEND.md. Default: sequential.
|
||||||
|
|
||||||
### Output Path Convention
|
### Output Path Convention
|
||||||
|
|
||||||
Each session creates an independent directory. Even the same source file generates a new directory per session.
|
Each session creates an independent directory. Even the same source file generates a new directory per session.
|
||||||
@@ -472,22 +541,27 @@ Check EXTEND.md existence (priority order):
|
|||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/<skill-name>/EXTEND.md && echo "project"
|
test -f .baoyu-skills/<skill-name>/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/<skill-name>/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/<skill-name>/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/<skill-name>/EXTEND.md" && echo "user"
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
\`\`\`powershell
|
\`\`\`powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/<skill-name>/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/<skill-name>/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/<skill-name>/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/<skill-name>/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/<skill-name>/EXTEND.md") { "user" }
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
┌────────────────────────────────────────────┬───────────────────┐
|
┌────────────────────────────────────────────────────────┬──────────────────────────┐
|
||||||
│ Path │ Location │
|
│ Path │ Location │
|
||||||
├────────────────────────────────────────────┼───────────────────┤
|
├────────────────────────────────────────────────────────┼──────────────────────────┤
|
||||||
│ .baoyu-skills/<skill-name>/EXTEND.md │ Project directory │
|
│ .baoyu-skills/<skill-name>/EXTEND.md │ Project directory │
|
||||||
├────────────────────────────────────────────┼───────────────────┤
|
├────────────────────────────────────────────────────────┼──────────────────────────┤
|
||||||
│ $HOME/.baoyu-skills/<skill-name>/EXTEND.md │ User home │
|
│ $XDG_CONFIG_HOME/baoyu-skills/<skill-name>/EXTEND.md │ XDG config (~/.config) │
|
||||||
└────────────────────────────────────────────┴───────────────────┘
|
├────────────────────────────────────────────────────────┼──────────────────────────┤
|
||||||
|
│ $HOME/.baoyu-skills/<skill-name>/EXTEND.md │ User home (legacy) │
|
||||||
|
└────────────────────────────────────────────────────────┴──────────────────────────┘
|
||||||
|
|
||||||
┌───────────┬───────────────────────────────────────────────────────────────────────────┐
|
┌───────────┬───────────────────────────────────────────────────────────────────────────┐
|
||||||
│ Result │ Action │
|
│ Result │ Action │
|
||||||
@@ -523,5 +597,6 @@ Custom configurations via EXTEND.md. See **Preferences** section for paths and s
|
|||||||
**Notes**:
|
**Notes**:
|
||||||
- Replace `<skill-name>` with actual skill name (e.g., `baoyu-cover-image`)
|
- Replace `<skill-name>` with actual skill name (e.g., `baoyu-cover-image`)
|
||||||
- Use `$HOME` instead of `~` for cross-platform compatibility (macOS/Linux/WSL/PowerShell)
|
- Use `$HOME` instead of `~` for cross-platform compatibility (macOS/Linux/WSL/PowerShell)
|
||||||
|
- `$XDG_CONFIG_HOME` defaults to `~/.config` when unset
|
||||||
- Use `test -f` (Bash) or `Test-Path` (PowerShell) for explicit file existence check
|
- Use `test -f` (Bash) or `Test-Path` (PowerShell) for explicit file existence check
|
||||||
- ASCII tables for clear visual formatting
|
- ASCII tables for clear visual formatting
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ Simply tell Claude Code:
|
|||||||
|--------|-------------|--------|
|
|--------|-------------|--------|
|
||||||
| **content-skills** | Content generation and publishing | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) |
|
| **content-skills** | Content generation and publishing | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) |
|
||||||
| **ai-generation-skills** | AI-powered generation backends | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
|
| **ai-generation-skills** | AI-powered generation backends | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
|
||||||
| **utility-skills** | Utility tools for content processing | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [translate](#baoyu-translate) |
|
| **utility-skills** | Utility tools for content processing | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [markdown-to-html](#baoyu-markdown-to-html), [translate](#baoyu-translate) |
|
||||||
|
|
||||||
## Update Skills
|
## Update Skills
|
||||||
|
|
||||||
@@ -798,6 +798,21 @@ Format plain text or markdown files with proper frontmatter, titles, summaries,
|
|||||||
| Code/commands | `` `inline` `` or ` ```block``` ` |
|
| Code/commands | `` `inline` `` or ` ```block``` ` |
|
||||||
| Quotes | `>` blockquote |
|
| Quotes | `>` blockquote |
|
||||||
|
|
||||||
|
#### baoyu-markdown-to-html
|
||||||
|
|
||||||
|
Convert markdown files into styled HTML with WeChat-compatible themes, syntax highlighting, and optional bottom citations for external links.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic conversion
|
||||||
|
/baoyu-markdown-to-html article.md
|
||||||
|
|
||||||
|
# Theme + color
|
||||||
|
/baoyu-markdown-to-html article.md --theme grace --color red
|
||||||
|
|
||||||
|
# Convert ordinary external links to bottom citations
|
||||||
|
/baoyu-markdown-to-html article.md --cite
|
||||||
|
```
|
||||||
|
|
||||||
#### baoyu-translate
|
#### baoyu-translate
|
||||||
|
|
||||||
Translate articles and documents between languages with three modes: quick (direct), normal (analysis-informed), and refined (full publication-quality workflow with review and polish).
|
Translate articles and documents between languages with three modes: quick (direct), normal (analysis-informed), and refined (full publication-quality workflow with review and polish).
|
||||||
|
|||||||
+16
-1
@@ -76,7 +76,7 @@ clawhub install baoyu-markdown-to-html
|
|||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
| **content-skills** | 内容生成和发布 | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) |
|
| **content-skills** | 内容生成和发布 | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) |
|
||||||
| **ai-generation-skills** | AI 生成后端 | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
|
| **ai-generation-skills** | AI 生成后端 | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
|
||||||
| **utility-skills** | 内容处理工具 | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [translate](#baoyu-translate) |
|
| **utility-skills** | 内容处理工具 | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [markdown-to-html](#baoyu-markdown-to-html), [translate](#baoyu-translate) |
|
||||||
|
|
||||||
## 更新技能
|
## 更新技能
|
||||||
|
|
||||||
@@ -798,6 +798,21 @@ AI 驱动的生成后端。
|
|||||||
| 代码/命令 | `` `行内` `` 或 ` ```代码块``` ` |
|
| 代码/命令 | `` `行内` `` 或 ` ```代码块``` ` |
|
||||||
| 引用 | `>` 引用块 |
|
| 引用 | `>` 引用块 |
|
||||||
|
|
||||||
|
#### baoyu-markdown-to-html
|
||||||
|
|
||||||
|
将 Markdown 文件转换为样式化 HTML,支持微信公众号兼容主题、代码高亮,以及可选的外链底部引用。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 基础转换
|
||||||
|
/baoyu-markdown-to-html article.md
|
||||||
|
|
||||||
|
# 主题 + 颜色
|
||||||
|
/baoyu-markdown-to-html article.md --theme grace --color red
|
||||||
|
|
||||||
|
# 将普通外链转换为文末引用
|
||||||
|
/baoyu-markdown-to-html article.md --cite
|
||||||
|
```
|
||||||
|
|
||||||
#### baoyu-translate
|
#### baoyu-translate
|
||||||
|
|
||||||
三模式翻译技能:快速(直接翻译)、标准(分析后翻译)、精翻(完整出版级工作流,含审校与润色)。
|
三模式翻译技能:快速(直接翻译)、标准(分析后翻译)、精翻(完整出版级工作流,含审校与润色)。
|
||||||
|
|||||||
@@ -0,0 +1,528 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
|
||||||
|
const DEFAULT_REGISTRY = "https://clawhub.ai";
|
||||||
|
const TEXT_EXTENSIONS = new Set([
|
||||||
|
"md",
|
||||||
|
"mdx",
|
||||||
|
"txt",
|
||||||
|
"json",
|
||||||
|
"json5",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"toml",
|
||||||
|
"js",
|
||||||
|
"cjs",
|
||||||
|
"mjs",
|
||||||
|
"ts",
|
||||||
|
"tsx",
|
||||||
|
"jsx",
|
||||||
|
"py",
|
||||||
|
"sh",
|
||||||
|
"rb",
|
||||||
|
"go",
|
||||||
|
"rs",
|
||||||
|
"swift",
|
||||||
|
"kt",
|
||||||
|
"java",
|
||||||
|
"cs",
|
||||||
|
"cpp",
|
||||||
|
"c",
|
||||||
|
"h",
|
||||||
|
"hpp",
|
||||||
|
"sql",
|
||||||
|
"csv",
|
||||||
|
"ini",
|
||||||
|
"cfg",
|
||||||
|
"env",
|
||||||
|
"xml",
|
||||||
|
"html",
|
||||||
|
"css",
|
||||||
|
"scss",
|
||||||
|
"sass",
|
||||||
|
"svg",
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const config = await readClawhubConfig();
|
||||||
|
const registry = (
|
||||||
|
process.env.CLAWHUB_REGISTRY ||
|
||||||
|
process.env.CLAWDHUB_REGISTRY ||
|
||||||
|
config.registry ||
|
||||||
|
DEFAULT_REGISTRY
|
||||||
|
).replace(/\/+$/, "");
|
||||||
|
|
||||||
|
if (!config.token) {
|
||||||
|
throw new Error("Not logged in. Run: clawhub login");
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiJson(registry, config.token, "/api/v1/whoami");
|
||||||
|
|
||||||
|
const roots = options.roots.length > 0 ? options.roots : [path.resolve("skills")];
|
||||||
|
const skills = await findSkills(roots);
|
||||||
|
|
||||||
|
if (skills.length === 0) {
|
||||||
|
throw new Error("No skills found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("ClawHub sync");
|
||||||
|
console.log(`Roots with skills: ${roots.join(", ")}`);
|
||||||
|
|
||||||
|
const locals = await mapWithConcurrency(skills, options.concurrency, async (skill) => {
|
||||||
|
const files = await listTextFiles(skill.folder);
|
||||||
|
const fingerprint = buildFingerprint(files);
|
||||||
|
return {
|
||||||
|
...skill,
|
||||||
|
fileCount: files.length,
|
||||||
|
fingerprint,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidates = await mapWithConcurrency(locals, options.concurrency, async (skill) => {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
slug: skill.slug,
|
||||||
|
hash: skill.fingerprint,
|
||||||
|
});
|
||||||
|
const { status, body } = await apiJsonWithStatus(
|
||||||
|
registry,
|
||||||
|
config.token,
|
||||||
|
`/api/v1/resolve?${query.toString()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
return {
|
||||||
|
...skill,
|
||||||
|
status: "new",
|
||||||
|
latestVersion: null,
|
||||||
|
matchVersion: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error(body?.message || `Resolve failed for ${skill.slug} (HTTP ${status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestVersion = body?.latestVersion?.version ?? null;
|
||||||
|
const matchVersion = body?.match?.version ?? null;
|
||||||
|
|
||||||
|
if (!latestVersion) {
|
||||||
|
return {
|
||||||
|
...skill,
|
||||||
|
status: "new",
|
||||||
|
latestVersion: null,
|
||||||
|
matchVersion: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...skill,
|
||||||
|
status: matchVersion ? "synced" : "update",
|
||||||
|
latestVersion,
|
||||||
|
matchVersion,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionable = candidates.filter((candidate) => candidate.status !== "synced");
|
||||||
|
if (actionable.length === 0) {
|
||||||
|
console.log("Nothing to sync.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log("To sync");
|
||||||
|
for (const candidate of actionable) {
|
||||||
|
console.log(`- ${formatCandidate(candidate, options.bump)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.dryRun) {
|
||||||
|
console.log("");
|
||||||
|
console.log(`Dry run: would upload ${actionable.length} skill(s).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = options.tags
|
||||||
|
.split(",")
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
for (const candidate of actionable) {
|
||||||
|
const version =
|
||||||
|
candidate.status === "new"
|
||||||
|
? "1.0.0"
|
||||||
|
: bumpSemver(candidate.latestVersion, options.bump);
|
||||||
|
|
||||||
|
console.log(`Publishing ${candidate.slug}@${version}`);
|
||||||
|
const files = await listTextFiles(candidate.folder);
|
||||||
|
await publishSkill({
|
||||||
|
registry,
|
||||||
|
token: config.token,
|
||||||
|
skill: candidate,
|
||||||
|
files,
|
||||||
|
version,
|
||||||
|
changelog: options.changelog,
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log(`Uploaded ${actionable.length} skill(s).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = {
|
||||||
|
roots: [],
|
||||||
|
dryRun: false,
|
||||||
|
bump: "patch",
|
||||||
|
changelog: "",
|
||||||
|
tags: "latest",
|
||||||
|
concurrency: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
if (arg === "--dry-run") {
|
||||||
|
options.dryRun = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--all") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--root") {
|
||||||
|
const value = argv[index + 1];
|
||||||
|
if (!value) throw new Error("--root requires a directory");
|
||||||
|
options.roots.push(path.resolve(value));
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--bump") {
|
||||||
|
const value = argv[index + 1];
|
||||||
|
if (!["patch", "minor", "major"].includes(value)) {
|
||||||
|
throw new Error("--bump must be patch, minor, or major");
|
||||||
|
}
|
||||||
|
options.bump = value;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--changelog") {
|
||||||
|
const value = argv[index + 1];
|
||||||
|
if (value == null) throw new Error("--changelog requires text");
|
||||||
|
options.changelog = value;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--tags") {
|
||||||
|
const value = argv[index + 1];
|
||||||
|
if (value == null) throw new Error("--tags requires a value");
|
||||||
|
options.tags = value;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--concurrency") {
|
||||||
|
const value = Number(argv[index + 1]);
|
||||||
|
if (!Number.isInteger(value) || value < 1 || value > 32) {
|
||||||
|
throw new Error("--concurrency must be an integer between 1 and 32");
|
||||||
|
}
|
||||||
|
options.concurrency = value;
|
||||||
|
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: sync-clawhub.mjs [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--root <dir> Extra skill root (repeatable)
|
||||||
|
--all Accepted for compatibility
|
||||||
|
--dry-run Show what would be uploaded
|
||||||
|
--bump <type> patch | minor | major
|
||||||
|
--changelog <text> Changelog for updates
|
||||||
|
--tags <tags> Comma-separated tags
|
||||||
|
--concurrency <n> Registry check concurrency (1-32)
|
||||||
|
-h, --help Show help`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readClawhubConfig() {
|
||||||
|
const configPath = getConfigPath();
|
||||||
|
try {
|
||||||
|
return JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigPath() {
|
||||||
|
const override =
|
||||||
|
process.env.CLAWHUB_CONFIG_PATH?.trim() || process.env.CLAWDHUB_CONFIG_PATH?.trim();
|
||||||
|
if (override) {
|
||||||
|
return path.resolve(override);
|
||||||
|
}
|
||||||
|
|
||||||
|
const home = os.homedir();
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
const clawhub = path.join(home, "Library", "Application Support", "clawhub", "config.json");
|
||||||
|
const clawdhub = path.join(home, "Library", "Application Support", "clawdhub", "config.json");
|
||||||
|
return pathForExistingConfig(clawhub, clawdhub);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xdg = process.env.XDG_CONFIG_HOME;
|
||||||
|
if (xdg) {
|
||||||
|
const clawhub = path.join(xdg, "clawhub", "config.json");
|
||||||
|
const clawdhub = path.join(xdg, "clawdhub", "config.json");
|
||||||
|
return pathForExistingConfig(clawhub, clawdhub);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === "win32" && process.env.APPDATA) {
|
||||||
|
const clawhub = path.join(process.env.APPDATA, "clawhub", "config.json");
|
||||||
|
const clawdhub = path.join(process.env.APPDATA, "clawdhub", "config.json");
|
||||||
|
return pathForExistingConfig(clawhub, clawdhub);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clawhub = path.join(home, ".config", "clawhub", "config.json");
|
||||||
|
const clawdhub = path.join(home, ".config", "clawdhub", "config.json");
|
||||||
|
return pathForExistingConfig(clawhub, clawdhub);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathForExistingConfig(primary, legacy) {
|
||||||
|
if (existsSync(primary)) return path.resolve(primary);
|
||||||
|
if (existsSync(legacy)) return path.resolve(legacy);
|
||||||
|
return path.resolve(primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findSkills(roots) {
|
||||||
|
const deduped = new Map();
|
||||||
|
for (const root of roots) {
|
||||||
|
const folders = await findSkillFolders(root);
|
||||||
|
for (const folder of folders) {
|
||||||
|
deduped.set(folder.slug, folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...deduped.values()].sort((left, right) => left.slug.localeCompare(right.slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findSkillFolders(root) {
|
||||||
|
const stat = await safeStat(root);
|
||||||
|
if (!stat?.isDirectory()) return [];
|
||||||
|
|
||||||
|
if (await hasSkillMarker(root)) {
|
||||||
|
return [buildSkillEntry(root)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||||
|
const found = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const folder = path.join(root, entry.name);
|
||||||
|
if (await hasSkillMarker(folder)) {
|
||||||
|
found.push(buildSkillEntry(folder));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSkillEntry(folder) {
|
||||||
|
const base = path.basename(folder);
|
||||||
|
return {
|
||||||
|
folder,
|
||||||
|
slug: sanitizeSlug(base),
|
||||||
|
displayName: titleCase(base),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasSkillMarker(folder) {
|
||||||
|
return Boolean(
|
||||||
|
(await safeStat(path.join(folder, "SKILL.md")))?.isFile() ||
|
||||||
|
(await safeStat(path.join(folder, "skill.md")))?.isFile()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listTextFiles(root) {
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
async function walk(folder) {
|
||||||
|
const entries = await fs.readdir(folder, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name.startsWith(".")) continue;
|
||||||
|
if (entry.name === "node_modules") continue;
|
||||||
|
if (entry.name === ".clawhub" || entry.name === ".clawdhub") continue;
|
||||||
|
|
||||||
|
const fullPath = path.join(folder, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await walk(fullPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
|
||||||
|
const relPath = path.relative(root, fullPath).split(path.sep).join("/");
|
||||||
|
const ext = relPath.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
if (!TEXT_EXTENSIONS.has(ext)) continue;
|
||||||
|
|
||||||
|
const bytes = await fs.readFile(fullPath);
|
||||||
|
files.push({ relPath, bytes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await walk(root);
|
||||||
|
files.sort((left, right) => left.relPath.localeCompare(right.relPath));
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFingerprint(files) {
|
||||||
|
const payload = files
|
||||||
|
.map((file) => `${file.relPath}:${sha256(file.bytes)}`)
|
||||||
|
.sort((left, right) => left.localeCompare(right))
|
||||||
|
.join("\n");
|
||||||
|
return crypto.createHash("sha256").update(payload).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(bytes) {
|
||||||
|
return crypto.createHash("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishSkill({ registry, token, skill, files, version, changelog, tags }) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set(
|
||||||
|
"payload",
|
||||||
|
JSON.stringify({
|
||||||
|
slug: skill.slug,
|
||||||
|
displayName: skill.displayName,
|
||||||
|
version,
|
||||||
|
changelog,
|
||||||
|
tags,
|
||||||
|
acceptLicenseTerms: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
form.append("files", new Blob([file.bytes], { type: "text/plain" }), file.relPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${registry}/api/v1/skills`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(text || `Publish failed for ${skill.slug} (HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = text ? JSON.parse(text) : {};
|
||||||
|
console.log(`OK. Published ${skill.slug}@${version}${result.versionId ? ` (${result.versionId})` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiJson(registry, token, requestPath) {
|
||||||
|
const { status, body } = await apiJsonWithStatus(registry, token, requestPath);
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
throw new Error(body?.message || `HTTP ${status}`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiJsonWithStatus(registry, token, requestPath) {
|
||||||
|
const response = await fetch(`${registry}${requestPath}`, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let body = null;
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
body = { message: text };
|
||||||
|
}
|
||||||
|
return { status: response.status, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapWithConcurrency(items, limit, fn) {
|
||||||
|
const results = new Array(items.length);
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (cursor < items.length) {
|
||||||
|
const index = cursor;
|
||||||
|
cursor += 1;
|
||||||
|
results[index] = await fn(items[index], index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Math.min(Math.max(limit, 1), Math.max(items.length, 1));
|
||||||
|
await Promise.all(Array.from({ length: count }, () => worker()));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCandidate(candidate, bump) {
|
||||||
|
if (candidate.status === "new") {
|
||||||
|
return `${candidate.slug} NEW (${candidate.fileCount} files)`;
|
||||||
|
}
|
||||||
|
return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${bumpSemver(
|
||||||
|
candidate.latestVersion,
|
||||||
|
bump
|
||||||
|
)} (${candidate.fileCount} files)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpSemver(version, bump) {
|
||||||
|
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Invalid semver: ${version}`);
|
||||||
|
}
|
||||||
|
const major = Number(match[1]);
|
||||||
|
const minor = Number(match[2]);
|
||||||
|
const patch = Number(match[3]);
|
||||||
|
|
||||||
|
if (bump === "major") return `${major + 1}.0.0`;
|
||||||
|
if (bump === "minor") return `${major}.${minor + 1}.0`;
|
||||||
|
return `${major}.${minor}.${patch + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSlug(value) {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]+/g, "-")
|
||||||
|
.replace(/^-+/, "")
|
||||||
|
.replace(/-+$/, "")
|
||||||
|
.replace(/--+/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCase(value) {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.replace(/[-_]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeStat(filePath) {
|
||||||
|
try {
|
||||||
|
return await fs.stat(filePath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -4,13 +4,8 @@ set -euo pipefail
|
|||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
SKILLS_DIR="${ROOT_DIR}/skills"
|
SKILLS_DIR="${ROOT_DIR}/skills"
|
||||||
|
|
||||||
if command -v clawhub >/dev/null 2>&1; then
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
CLAWHUB_CMD=(clawhub)
|
echo "Error: node is required."
|
||||||
elif command -v npx >/dev/null 2>&1; then
|
|
||||||
CLAWHUB_CMD=(npx -y clawhub)
|
|
||||||
else
|
|
||||||
echo "Error: neither clawhub nor npx is available."
|
|
||||||
echo "See https://docs.openclaw.ai/zh-CN/tools/clawhub"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -18,4 +13,4 @@ if [ "$#" -eq 0 ]; then
|
|||||||
set -- --all
|
set -- --all
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec "${CLAWHUB_CMD[@]}" sync --root "${SKILLS_DIR}" "$@"
|
exec node "${ROOT_DIR}/scripts/sync-clawhub.mjs" --root "${SKILLS_DIR}" "$@"
|
||||||
|
|||||||
@@ -55,12 +55,15 @@ See [references/styles.md](references/styles.md) for Core Styles, full gallery,
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-article-illustrator/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-article-illustrator/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-article-illustrator/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -115,6 +118,8 @@ Full template: [references/workflow.md](references/workflow.md#step-4-generate-o
|
|||||||
|
|
||||||
⛔ **BLOCKING: Prompt files MUST be saved before ANY image generation.**
|
⛔ **BLOCKING: Prompt files MUST be saved before ANY image generation.**
|
||||||
|
|
||||||
|
**Execution strategy**: When multiple illustrations have saved prompt files and the task is now plain generation, prefer `baoyu-image-gen` batch mode (`build-batch.ts` → `--batchfile`) over spawning subagents. Use subagents only when each image still needs separate prompt iteration or creative exploration.
|
||||||
|
|
||||||
1. For each illustration, create a prompt file per [references/prompt-construction.md](references/prompt-construction.md)
|
1. For each illustration, create a prompt file per [references/prompt-construction.md](references/prompt-construction.md)
|
||||||
2. Save to `prompts/NN-{type}-{slug}.md` with YAML frontmatter
|
2. Save to `prompts/NN-{type}-{slug}.md` with YAML frontmatter
|
||||||
3. Prompts **MUST** use type-specific templates with structured sections (ZONES / LABELS / COLORS / STYLE / ASPECT)
|
3. Prompts **MUST** use type-specific templates with structured sections (ZONES / LABELS / COLORS / STYLE / ASPECT)
|
||||||
|
|||||||
@@ -88,12 +88,15 @@ Check preferences and existing state, then ask ALL needed questions in ONE AskUs
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-article-illustrator/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-article-illustrator/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-article-illustrator/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -312,6 +315,10 @@ 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`.
|
**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 `baoyu-image-gen` batch mode (`build-batch.ts` -> `main.ts --batchfile`)
|
||||||
|
- Use subagents only when each illustration still needs separate prompt rewriting, style exploration, or other per-image reasoning before generation
|
||||||
|
|
||||||
**CRITICAL - References in Frontmatter**:
|
**CRITICAL - References in Frontmatter**:
|
||||||
- Only add `references` field if files ACTUALLY EXIST in `references/` directory
|
- Only add `references` field if files ACTUALLY EXIST in `references/` directory
|
||||||
- If style/palette was extracted verbally (no file), append info to prompt BODY instead
|
- If style/palette was extracted verbally (no file), append info to prompt BODY instead
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
type CliArgs = {
|
||||||
|
outlinePath: string | null;
|
||||||
|
promptsDir: string | null;
|
||||||
|
outputPath: string | null;
|
||||||
|
imagesDir: string | null;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
aspectRatio: string;
|
||||||
|
quality: string;
|
||||||
|
jobs: number | null;
|
||||||
|
help: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OutlineEntry = {
|
||||||
|
index: number;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function printUsage(): void {
|
||||||
|
console.log(`Usage:
|
||||||
|
npx -y tsx scripts/build-batch.ts --outline outline.md --prompts prompts --output batch.json --images-dir attachments
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--outline <path> Path to outline.md
|
||||||
|
--prompts <path> Path to prompts directory
|
||||||
|
--output <path> Path to output batch.json
|
||||||
|
--images-dir <path> Directory for generated images
|
||||||
|
--provider <name> Provider for baoyu-image-gen batch tasks (default: replicate)
|
||||||
|
--model <id> Model for baoyu-image-gen batch tasks (default: google/nano-banana-pro)
|
||||||
|
--ar <ratio> Aspect ratio for all tasks (default: 16:9)
|
||||||
|
--quality <level> Quality for all tasks (default: 2k)
|
||||||
|
--jobs <count> Recommended worker count metadata (optional)
|
||||||
|
-h, --help Show help`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): CliArgs {
|
||||||
|
const args: CliArgs = {
|
||||||
|
outlinePath: null,
|
||||||
|
promptsDir: null,
|
||||||
|
outputPath: null,
|
||||||
|
imagesDir: null,
|
||||||
|
provider: "replicate",
|
||||||
|
model: "google/nano-banana-pro",
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
quality: "2k",
|
||||||
|
jobs: null,
|
||||||
|
help: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const current = argv[i]!;
|
||||||
|
if (current === "--outline") args.outlinePath = argv[++i] ?? null;
|
||||||
|
else if (current === "--prompts") args.promptsDir = argv[++i] ?? null;
|
||||||
|
else if (current === "--output") args.outputPath = argv[++i] ?? null;
|
||||||
|
else if (current === "--images-dir") args.imagesDir = argv[++i] ?? null;
|
||||||
|
else if (current === "--provider") args.provider = argv[++i] ?? args.provider;
|
||||||
|
else if (current === "--model") args.model = argv[++i] ?? args.model;
|
||||||
|
else if (current === "--ar") args.aspectRatio = argv[++i] ?? args.aspectRatio;
|
||||||
|
else if (current === "--quality") args.quality = argv[++i] ?? args.quality;
|
||||||
|
else if (current === "--jobs") {
|
||||||
|
const value = argv[++i];
|
||||||
|
args.jobs = value ? parseInt(value, 10) : null;
|
||||||
|
} else if (current === "--help" || current === "-h") {
|
||||||
|
args.help = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOutline(content: string): OutlineEntry[] {
|
||||||
|
const entries: OutlineEntry[] = [];
|
||||||
|
const blocks = content.split(/^## Illustration\s+/m).slice(1);
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const indexMatch = block.match(/^(\d+)/);
|
||||||
|
const filenameMatch = block.match(/\*\*Filename\*\*:\s*(.+)/);
|
||||||
|
if (indexMatch && filenameMatch) {
|
||||||
|
entries.push({
|
||||||
|
index: parseInt(indexMatch[1]!, 10),
|
||||||
|
filename: filenameMatch[1]!.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findPromptFile(promptsDir: string, entry: OutlineEntry): Promise<string | null> {
|
||||||
|
const files = await readdir(promptsDir);
|
||||||
|
const prefix = String(entry.index).padStart(2, "0");
|
||||||
|
const match = files.find((f) => f.startsWith(prefix) && f.endsWith(".md"));
|
||||||
|
return match ? path.join(promptsDir, match) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (args.help) {
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.outlinePath) {
|
||||||
|
console.error("Error: --outline is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!args.promptsDir) {
|
||||||
|
console.error("Error: --prompts is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!args.outputPath) {
|
||||||
|
console.error("Error: --output is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outlineContent = await readFile(args.outlinePath, "utf8");
|
||||||
|
const entries = parseOutline(outlineContent);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
console.error("No illustration entries found in outline.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const promptFile = await findPromptFile(args.promptsDir, entry);
|
||||||
|
if (!promptFile) {
|
||||||
|
console.error(`Warning: No prompt file found for illustration ${entry.index}, skipping.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageDir = args.imagesDir ?? path.dirname(args.outputPath);
|
||||||
|
tasks.push({
|
||||||
|
id: `illustration-${String(entry.index).padStart(2, "0")}`,
|
||||||
|
promptFiles: [promptFile],
|
||||||
|
image: path.join(imageDir, entry.filename),
|
||||||
|
provider: args.provider,
|
||||||
|
model: args.model,
|
||||||
|
ar: args.aspectRatio,
|
||||||
|
quality: args.quality,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const output: Record<string, unknown> = { tasks };
|
||||||
|
if (args.jobs) output.jobs = args.jobs;
|
||||||
|
|
||||||
|
await writeFile(args.outputPath, JSON.stringify(output, null, 2) + "\n");
|
||||||
|
console.log(`Batch file written: ${args.outputPath} (${tasks.length} tasks)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -39,12 +39,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-comic/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-comic/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-comic/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-comic/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-comic/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-comic/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-comic/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-comic/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-comic/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-comic/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -30,12 +30,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-compress-image/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-compress-image/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-compress-image/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-compress-image/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-compress-image/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-compress-image/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-compress-image/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-compress-image/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-compress-image/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-compress-image/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -134,12 +134,15 @@ Check EXTEND.md existence (priority: project → user):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-cover-image/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-cover-image/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-cover-image/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-cover-image/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-cover-image/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-cover-image/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -57,12 +57,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-danger-gemini-web/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-danger-gemini-web/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-danger-gemini-web/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-danger-gemini-web/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-danger-gemini-web/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-danger-gemini-web/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -83,12 +83,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-format-markdown/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-format-markdown/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-format-markdown/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-format-markdown/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-format-markdown/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-format-markdown/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-format-markdown/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-format-markdown/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-format-markdown/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-format-markdown/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { execSync } from "child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
export function applyAutocorrect(filePath: string): boolean {
|
export function applyAutocorrect(filePath: string): boolean {
|
||||||
try {
|
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx";
|
||||||
execSync(`npx autocorrect-node --fix "${filePath}"`, { stdio: "inherit" });
|
const result = spawnSync(npxCmd, ["autocorrect-node", "--fix", filePath], {
|
||||||
return true;
|
stdio: "inherit",
|
||||||
} catch {
|
});
|
||||||
return false;
|
return result.status === 0;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-image-gen
|
name: baoyu-image-gen
|
||||||
description: AI image generation with OpenAI, Google, DashScope and Replicate APIs. Supports text-to-image, reference images, aspect ratios. Sequential by default; parallel generation available on request. Use when user asks to generate, create, or draw images.
|
description: AI image generation with OpenAI, Google, DashScope and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images.
|
||||||
version: 1.56.1
|
version: 1.56.1
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
@@ -31,12 +31,15 @@ Check EXTEND.md existence (priority: project → user):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-image-gen/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-image-gen/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-image-gen/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-image-gen/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-image-gen/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -52,7 +55,7 @@ if (Test-Path "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md") { "user" }
|
|||||||
| `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Project directory |
|
| `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Project directory |
|
||||||
| `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | User home |
|
| `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | User home |
|
||||||
|
|
||||||
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models
|
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models | Batch worker cap | Provider-specific batch limits
|
||||||
|
|
||||||
Schema: `references/config/preferences-schema.md`
|
Schema: `references/config/preferences-schema.md`
|
||||||
|
|
||||||
@@ -88,22 +91,57 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider r
|
|||||||
|
|
||||||
# Replicate with specific model
|
# Replicate with specific model
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
||||||
|
|
||||||
|
# Batch mode with saved prompt files
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json
|
||||||
|
|
||||||
|
# Batch mode with explicit worker count
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4 --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Batch File Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobs": 4,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "hero",
|
||||||
|
"promptFiles": ["prompts/hero.md"],
|
||||||
|
"image": "out/hero.png",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model": "google/nano-banana-pro",
|
||||||
|
"ar": "16:9",
|
||||||
|
"quality": "2k"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "diagram",
|
||||||
|
"promptFiles": ["prompts/diagram.md"],
|
||||||
|
"image": "out/diagram.png",
|
||||||
|
"ref": ["references/original.png"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch file's directory. `jobs` is optional (overridden by CLI `--jobs`). Top-level array format (without `jobs` wrapper) is also accepted.
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `--prompt <text>`, `-p` | Prompt text |
|
| `--prompt <text>`, `-p` | Prompt text |
|
||||||
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
||||||
| `--image <path>` | Output image path (required) |
|
| `--image <path>` | Output image path (required in single-image mode) |
|
||||||
| `--provider google\|openai\|dashscope\|replicate` | Force provider (default: google) |
|
| `--batchfile <path>` | JSON batch file for multi-image generation |
|
||||||
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`; OpenAI: `gpt-image-1.5`) |
|
| `--jobs <count>` | Worker count for batch mode (default: auto, max from config, built-in default 10) |
|
||||||
|
| `--provider google\|openai\|dashscope\|replicate` | Force provider (default: auto-detect) |
|
||||||
|
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`; OpenAI: `gpt-image-1.5`, `gpt-image-1`) |
|
||||||
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||||
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
||||||
| `--quality normal\|2k` | Quality preset (default: 2k) |
|
| `--quality normal\|2k` | Quality preset (default: `2k`) |
|
||||||
| `--imageSize 1K\|2K\|4K` | Image size for Google (default: from quality) |
|
| `--imageSize 1K\|2K\|4K` | Image size for Google (default: from quality) |
|
||||||
| `--ref <files...>` | Reference images. Supported by Google multimodal (`gemini-3-pro-image-preview`, `gemini-3-flash-preview`, `gemini-3.1-flash-image-preview`) and OpenAI edits (GPT Image models). If provider omitted: Google first, then OpenAI |
|
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, and Replicate |
|
||||||
| `--n <count>` | Number of images |
|
| `--n <count>` | Number of images |
|
||||||
| `--json` | JSON output |
|
| `--json` | JSON output |
|
||||||
|
|
||||||
@@ -123,6 +161,9 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider r
|
|||||||
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
||||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
||||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
|
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
|
||||||
|
| `BAOYU_IMAGE_GEN_MAX_WORKERS` | Override batch worker cap |
|
||||||
|
| `BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY` | Override provider concurrency, e.g. `BAOYU_IMAGE_GEN_REPLICATE_CONCURRENCY` |
|
||||||
|
| `BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS` | Override provider start gap, e.g. `BAOYU_IMAGE_GEN_REPLICATE_START_INTERVAL_MS` |
|
||||||
|
|
||||||
**Load Priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
**Load Priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||||
|
|
||||||
@@ -167,10 +208,10 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider r
|
|||||||
|
|
||||||
## Quality Presets
|
## Quality Presets
|
||||||
|
|
||||||
| Preset | Google imageSize | OpenAI Size | Use Case |
|
| Preset | Google imageSize | OpenAI Size | Replicate resolution | Use Case |
|
||||||
|--------|------------------|-------------|----------|
|
|--------|------------------|-------------|----------------------|----------|
|
||||||
| `normal` | 1K | 1024px | Quick previews |
|
| `normal` | 1K | 1024px | 1K | Quick previews |
|
||||||
| `2k` (default) | 2K | 2048px | Covers, illustrations, infographics |
|
| `2k` (default) | 2K | 2048px | 2K | Covers, illustrations, infographics |
|
||||||
|
|
||||||
**Google imageSize**: Can be overridden with `--imageSize 1K|2K|4K`
|
**Google imageSize**: Can be overridden with `--imageSize 1K|2K|4K`
|
||||||
|
|
||||||
@@ -179,41 +220,48 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider r
|
|||||||
Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
||||||
|
|
||||||
- Google multimodal: uses `imageConfig.aspectRatio`
|
- Google multimodal: uses `imageConfig.aspectRatio`
|
||||||
- Google Imagen: uses `aspectRatio` parameter
|
|
||||||
- OpenAI: maps to closest supported size
|
- OpenAI: maps to closest supported size
|
||||||
|
- Replicate: passes `aspect_ratio` to model; when `--ref` is provided without `--ar`, defaults to `match_input_image`
|
||||||
|
|
||||||
## Generation Mode
|
## Generation Mode
|
||||||
|
|
||||||
**Default**: Sequential generation (one image at a time). This ensures stable output and easier debugging.
|
**Default**: Sequential generation.
|
||||||
|
|
||||||
**Parallel Generation**: Only use when user explicitly requests parallel/concurrent generation.
|
**Batch Parallel Generation**: When `--batchfile` contains 2 or more pending tasks, the script automatically enables parallel generation.
|
||||||
|
|
||||||
| Mode | When to Use |
|
| Mode | When to Use |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| Sequential (default) | Normal usage, single images, small batches |
|
| Sequential (default) | Normal usage, single images, small batches |
|
||||||
| Parallel | User explicitly requests, large batches (10+) |
|
| Parallel batch | Batch mode with 2+ tasks |
|
||||||
|
|
||||||
**Parallel Settings** (when requested):
|
Execution choice:
|
||||||
|
|
||||||
| Setting | Value |
|
| Situation | Preferred approach | Why |
|
||||||
|---------|-------|
|
|-----------|--------------------|-----|
|
||||||
| Recommended concurrency | 4 subagents |
|
| One image, or 1-2 simple images | Sequential | Lower coordination overhead and easier debugging |
|
||||||
| Max concurrency | 8 subagents |
|
| Multiple images already have saved prompt files | Batch (`--batchfile`) | Reuses finalized prompts, applies shared throttling/retries, and gives predictable throughput |
|
||||||
| Use case | Large batch generation when user requests parallel |
|
| Each image still needs separate reasoning, prompt writing, or style exploration | Subagents | The work is still exploratory, so each image may need independent analysis before generation |
|
||||||
|
| Output comes from `baoyu-article-illustrator` with `outline.md` + `prompts/` | Batch (`build-batch.ts` -> `--batchfile`) | That workflow already produces prompt files, so direct batch execution is the intended path |
|
||||||
|
|
||||||
**Agent Implementation** (parallel mode only):
|
Rule of thumb:
|
||||||
```
|
|
||||||
# Launch multiple generations in parallel using Task tool
|
- Prefer batch over subagents once prompt files are already saved and the task is "generate all of these"
|
||||||
# Each Task runs as background subagent with run_in_background=true
|
- Use subagents only when generation is coupled with per-image thinking, rewriting, or divergent creative exploration
|
||||||
# Collect results via TaskOutput when all complete
|
|
||||||
```
|
Parallel behavior:
|
||||||
|
|
||||||
|
- Default worker count is automatic, capped by config, built-in default 10
|
||||||
|
- Provider-specific throttling is applied only in batch mode, and the built-in defaults are tuned for faster throughput while still avoiding obvious RPM bursts
|
||||||
|
- You can override worker count with `--jobs <count>`
|
||||||
|
- Each image retries automatically up to 3 attempts
|
||||||
|
- Final output includes success count, failure count, and per-image failure reasons
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
- Missing API key → error with setup instructions
|
- Missing API key → error with setup instructions
|
||||||
- Generation failure → auto-retry once
|
- Generation failure → auto-retry up to 3 attempts per image
|
||||||
- Invalid aspect ratio → warning, proceed with default
|
- Invalid aspect ratio → warning, proceed with default
|
||||||
- Reference images with unsupported provider/model → error with fix hint (switch to Google multimodal: `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`; or OpenAI GPT Image edits)
|
- Reference images with unsupported provider/model → error with fix hint
|
||||||
|
|
||||||
## Extension Support
|
## Extension Support
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,25 @@ default_image_size: null # 1K|2K|4K|null (Google only, overrides quality)
|
|||||||
|
|
||||||
default_model:
|
default_model:
|
||||||
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
|
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
|
||||||
openai: null # e.g., "gpt-image-1.5"
|
openai: null # e.g., "gpt-image-1.5", "gpt-image-1"
|
||||||
dashscope: null # e.g., "z-image-turbo"
|
dashscope: null # e.g., "z-image-turbo"
|
||||||
replicate: null # e.g., "google/nano-banana-pro"
|
replicate: null # e.g., "google/nano-banana-pro"
|
||||||
|
|
||||||
|
batch:
|
||||||
|
max_workers: 10
|
||||||
|
provider_limits:
|
||||||
|
replicate:
|
||||||
|
concurrency: 5
|
||||||
|
start_interval_ms: 700
|
||||||
|
google:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
|
openai:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
|
dashscope:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -40,6 +56,9 @@ default_model:
|
|||||||
| `default_model.openai` | string\|null | null | OpenAI default model |
|
| `default_model.openai` | string\|null | null | OpenAI default model |
|
||||||
| `default_model.dashscope` | string\|null | null | DashScope default model |
|
| `default_model.dashscope` | string\|null | null | DashScope default model |
|
||||||
| `default_model.replicate` | string\|null | null | Replicate default model |
|
| `default_model.replicate` | string\|null | null | Replicate default model |
|
||||||
|
| `batch.max_workers` | int\|null | 10 | Batch worker cap |
|
||||||
|
| `batch.provider_limits.<provider>.concurrency` | int\|null | provider default | Max simultaneous requests per provider |
|
||||||
|
| `batch.provider_limits.<provider>.start_interval_ms` | int\|null | provider default | Minimum gap between request starts per provider |
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
@@ -65,5 +84,11 @@ default_model:
|
|||||||
openai: "gpt-image-1.5"
|
openai: "gpt-image-1.5"
|
||||||
dashscope: "z-image-turbo"
|
dashscope: "z-image-turbo"
|
||||||
replicate: "google/nano-banana-pro"
|
replicate: "google/nano-banana-pro"
|
||||||
|
batch:
|
||||||
|
max_workers: 10
|
||||||
|
provider_limits:
|
||||||
|
replicate:
|
||||||
|
concurrency: 5
|
||||||
|
start_interval_ms: 700
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,34 +2,108 @@ import path from "node:path";
|
|||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import type { CliArgs, Provider, ExtendConfig } from "./types";
|
import type {
|
||||||
|
BatchFile,
|
||||||
|
BatchTaskInput,
|
||||||
|
CliArgs,
|
||||||
|
ExtendConfig,
|
||||||
|
Provider,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type ProviderModule = {
|
||||||
|
getDefaultModel: () => string;
|
||||||
|
generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreparedTask = {
|
||||||
|
id: string;
|
||||||
|
prompt: string;
|
||||||
|
args: CliArgs;
|
||||||
|
provider: Provider;
|
||||||
|
model: string;
|
||||||
|
outputPath: string;
|
||||||
|
providerModule: ProviderModule;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskResult = {
|
||||||
|
id: string;
|
||||||
|
provider: Provider;
|
||||||
|
model: string;
|
||||||
|
outputPath: string;
|
||||||
|
success: boolean;
|
||||||
|
attempts: number;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderRateLimit = {
|
||||||
|
concurrency: number;
|
||||||
|
startIntervalMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoadedBatchTasks = {
|
||||||
|
tasks: BatchTaskInput[];
|
||||||
|
jobs: number | null;
|
||||||
|
batchDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 3;
|
||||||
|
const DEFAULT_MAX_WORKERS = 10;
|
||||||
|
const POLL_WAIT_MS = 250;
|
||||||
|
const DEFAULT_PROVIDER_RATE_LIMITS: Record<Provider, ProviderRateLimit> = {
|
||||||
|
replicate: { concurrency: 5, startIntervalMs: 700 },
|
||||||
|
google: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
|
openai: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
|
dashscope: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
|
};
|
||||||
|
|
||||||
function printUsage(): void {
|
function printUsage(): void {
|
||||||
console.log(`Usage:
|
console.log(`Usage:
|
||||||
npx -y bun scripts/main.ts --prompt "A cat" --image cat.png
|
npx -y bun scripts/main.ts --prompt "A cat" --image cat.png
|
||||||
npx -y bun scripts/main.ts --prompt "A landscape" --image landscape.png --ar 16:9
|
|
||||||
npx -y bun scripts/main.ts --promptfiles system.md content.md --image out.png
|
npx -y bun scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||||
|
npx -y bun scripts/main.ts --batchfile batch.json
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-p, --prompt <text> Prompt text
|
-p, --prompt <text> Prompt text
|
||||||
--promptfiles <files...> Read prompt from files (concatenated)
|
--promptfiles <files...> Read prompt from files (concatenated)
|
||||||
--image <path> Output image path (required)
|
--image <path> Output image path (required in single-image mode)
|
||||||
|
--batchfile <path> JSON batch file for multi-image generation
|
||||||
|
--jobs <count> Worker count for batch mode (default: auto, max from config, built-in default 10)
|
||||||
--provider google|openai|dashscope|replicate Force provider (auto-detect by default)
|
--provider google|openai|dashscope|replicate Force provider (auto-detect by default)
|
||||||
-m, --model <id> Model ID
|
-m, --model <id> Model ID
|
||||||
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
||||||
--size <WxH> Size (e.g., 1024x1024)
|
--size <WxH> Size (e.g., 1024x1024)
|
||||||
--quality normal|2k Quality preset (default: 2k)
|
--quality normal|2k Quality preset (default: 2k)
|
||||||
--imageSize 1K|2K|4K Image size for Google (default: from quality)
|
--imageSize 1K|2K|4K Image size for Google (default: from quality)
|
||||||
--ref <files...> Reference images (Google multimodal or OpenAI edits)
|
--ref <files...> Reference images (Google multimodal, OpenAI GPT Image edits, or Replicate)
|
||||||
--n <count> Number of images (default: 1)
|
--n <count> Number of images for the current task (default: 1)
|
||||||
--json JSON output
|
--json JSON output
|
||||||
-h, --help Show help
|
-h, --help Show help
|
||||||
|
|
||||||
|
Batch file format:
|
||||||
|
{
|
||||||
|
"jobs": 4,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "hero",
|
||||||
|
"promptFiles": ["prompts/hero.md"],
|
||||||
|
"image": "out/hero.png",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model": "google/nano-banana-pro",
|
||||||
|
"ar": "16:9"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Batch mode automatically runs in parallel when pending tasks >= 2
|
||||||
|
- Each image retries automatically up to 3 attempts
|
||||||
|
- Batch summary reports success count, failure count, and per-image errors
|
||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
OPENAI_API_KEY OpenAI API key
|
OPENAI_API_KEY OpenAI API key
|
||||||
GOOGLE_API_KEY Google API key
|
GOOGLE_API_KEY Google API key
|
||||||
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
|
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
|
||||||
DASHSCOPE_API_KEY DashScope API key (阿里云通义万象)
|
DASHSCOPE_API_KEY DashScope API key
|
||||||
REPLICATE_API_TOKEN Replicate API token
|
REPLICATE_API_TOKEN Replicate API token
|
||||||
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
|
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
|
||||||
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
||||||
@@ -40,6 +114,9 @@ Environment variables:
|
|||||||
GOOGLE_BASE_URL Custom Google endpoint
|
GOOGLE_BASE_URL Custom Google endpoint
|
||||||
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
||||||
REPLICATE_BASE_URL Custom Replicate endpoint
|
REPLICATE_BASE_URL Custom Replicate endpoint
|
||||||
|
BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap
|
||||||
|
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency
|
||||||
|
BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS Override provider start gap in ms
|
||||||
|
|
||||||
Env file load order: CLI args > EXTEND.md > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
|
Env file load order: CLI args > EXTEND.md > process.env > <cwd>/.baoyu-skills/.env > ~/.baoyu-skills/.env`);
|
||||||
}
|
}
|
||||||
@@ -57,6 +134,8 @@ function parseArgs(argv: string[]): CliArgs {
|
|||||||
imageSize: null,
|
imageSize: null,
|
||||||
referenceImages: [],
|
referenceImages: [],
|
||||||
n: 1,
|
n: 1,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: null,
|
||||||
json: false,
|
json: false,
|
||||||
help: false,
|
help: false,
|
||||||
};
|
};
|
||||||
@@ -110,9 +189,26 @@ function parseArgs(argv: string[]): CliArgs {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (a === "--batchfile") {
|
||||||
|
const v = argv[++i];
|
||||||
|
if (!v) throw new Error("Missing value for --batchfile");
|
||||||
|
out.batchFile = v;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a === "--jobs") {
|
||||||
|
const v = argv[++i];
|
||||||
|
if (!v) throw new Error("Missing value for --jobs");
|
||||||
|
out.jobs = parseInt(v, 10);
|
||||||
|
if (isNaN(out.jobs) || out.jobs < 1) throw new Error(`Invalid worker count: ${v}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (a === "--provider") {
|
if (a === "--provider") {
|
||||||
const v = argv[++i];
|
const v = argv[++i];
|
||||||
if (v !== "google" && v !== "openai" && v !== "dashscope" && v !== "replicate") throw new Error(`Invalid provider: ${v}`);
|
if (v !== "google" && v !== "openai" && v !== "dashscope" && v !== "replicate") {
|
||||||
|
throw new Error(`Invalid provider: ${v}`);
|
||||||
|
}
|
||||||
out.provider = v;
|
out.provider = v;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -228,9 +324,11 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
|||||||
const config: Partial<ExtendConfig> = {};
|
const config: Partial<ExtendConfig> = {};
|
||||||
const lines = yaml.split("\n");
|
const lines = yaml.split("\n");
|
||||||
let currentKey: string | null = null;
|
let currentKey: string | null = null;
|
||||||
|
let currentProvider: Provider | null = null;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
|
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
||||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
|
||||||
if (trimmed.includes(":") && !trimmed.startsWith("-")) {
|
if (trimmed.includes(":") && !trimmed.startsWith("-")) {
|
||||||
@@ -247,18 +345,57 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
|||||||
} else if (key === "default_provider") {
|
} else if (key === "default_provider") {
|
||||||
config.default_provider = value === "null" ? null : (value as Provider);
|
config.default_provider = value === "null" ? null : (value as Provider);
|
||||||
} else if (key === "default_quality") {
|
} else if (key === "default_quality") {
|
||||||
config.default_quality = value === "null" ? null : (value as "normal" | "2k");
|
config.default_quality = value === "null" ? null : value as "normal" | "2k";
|
||||||
} else if (key === "default_aspect_ratio") {
|
} else if (key === "default_aspect_ratio") {
|
||||||
const cleaned = value.replace(/['"]/g, "");
|
const cleaned = value.replace(/['"]/g, "");
|
||||||
config.default_aspect_ratio = cleaned === "null" ? null : cleaned;
|
config.default_aspect_ratio = cleaned === "null" ? null : cleaned;
|
||||||
} else if (key === "default_image_size") {
|
} else if (key === "default_image_size") {
|
||||||
config.default_image_size = value === "null" ? null : (value as "1K" | "2K" | "4K");
|
config.default_image_size = value === "null" ? null : value as "1K" | "2K" | "4K";
|
||||||
} else if (key === "default_model") {
|
} else if (key === "default_model") {
|
||||||
config.default_model = { google: null, openai: null, dashscope: null, replicate: null };
|
config.default_model = { google: null, openai: null, dashscope: null, replicate: null };
|
||||||
currentKey = "default_model";
|
currentKey = "default_model";
|
||||||
} else if (currentKey === "default_model" && (key === "google" || key === "openai" || key === "dashscope" || key === "replicate")) {
|
currentProvider = null;
|
||||||
|
} else if (key === "batch") {
|
||||||
|
config.batch = {};
|
||||||
|
currentKey = "batch";
|
||||||
|
currentProvider = null;
|
||||||
|
} else if (currentKey === "batch" && indent >= 2 && key === "max_workers") {
|
||||||
|
config.batch ??= {};
|
||||||
|
config.batch.max_workers = value === "null" ? null : parseInt(value, 10);
|
||||||
|
} else if (currentKey === "batch" && indent >= 2 && key === "provider_limits") {
|
||||||
|
config.batch ??= {};
|
||||||
|
config.batch.provider_limits ??= {};
|
||||||
|
currentKey = "provider_limits";
|
||||||
|
currentProvider = null;
|
||||||
|
} else if (
|
||||||
|
currentKey === "provider_limits" &&
|
||||||
|
indent >= 4 &&
|
||||||
|
(key === "google" || key === "openai" || key === "dashscope" || key === "replicate")
|
||||||
|
) {
|
||||||
|
config.batch ??= {};
|
||||||
|
config.batch.provider_limits ??= {};
|
||||||
|
config.batch.provider_limits[key] ??= {};
|
||||||
|
currentProvider = key;
|
||||||
|
} else if (
|
||||||
|
currentKey === "default_model" &&
|
||||||
|
(key === "google" || key === "openai" || key === "dashscope" || key === "replicate")
|
||||||
|
) {
|
||||||
const cleaned = value.replace(/['"]/g, "");
|
const cleaned = value.replace(/['"]/g, "");
|
||||||
config.default_model![key] = cleaned === "null" ? null : cleaned;
|
config.default_model![key] = cleaned === "null" ? null : cleaned;
|
||||||
|
} else if (
|
||||||
|
currentKey === "provider_limits" &&
|
||||||
|
currentProvider &&
|
||||||
|
indent >= 6 &&
|
||||||
|
(key === "concurrency" || key === "start_interval_ms")
|
||||||
|
) {
|
||||||
|
config.batch ??= {};
|
||||||
|
config.batch.provider_limits ??= {};
|
||||||
|
const providerLimit = (config.batch.provider_limits[currentProvider] ??= {});
|
||||||
|
if (key === "concurrency") {
|
||||||
|
providerLimit.concurrency = value === "null" ? null : parseInt(value, 10);
|
||||||
|
} else {
|
||||||
|
providerLimit.start_interval_ms = value === "null" ? null : parseInt(value, 10);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,7 +417,6 @@ async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
|
|||||||
const content = await readFile(p, "utf8");
|
const content = await readFile(p, "utf8");
|
||||||
const yaml = extractYamlFrontMatter(content);
|
const yaml = extractYamlFrontMatter(content);
|
||||||
if (!yaml) continue;
|
if (!yaml) continue;
|
||||||
|
|
||||||
return parseSimpleYaml(yaml);
|
return parseSimpleYaml(yaml);
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
@@ -300,6 +436,57 @@ function mergeConfig(args: CliArgs, extend: Partial<ExtendConfig>): CliArgs {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(value: string | undefined): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveBatchInt(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isInteger(value) && value > 0 ? value : null;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return parsePositiveInt(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredMaxWorkers(extendConfig: Partial<ExtendConfig>): number {
|
||||||
|
const envValue = parsePositiveInt(process.env.BAOYU_IMAGE_GEN_MAX_WORKERS);
|
||||||
|
const configValue = extendConfig.batch?.max_workers ?? null;
|
||||||
|
return Math.max(1, envValue ?? configValue ?? DEFAULT_MAX_WORKERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredProviderRateLimits(
|
||||||
|
extendConfig: Partial<ExtendConfig>
|
||||||
|
): Record<Provider, ProviderRateLimit> {
|
||||||
|
const configured: Record<Provider, ProviderRateLimit> = {
|
||||||
|
replicate: { ...DEFAULT_PROVIDER_RATE_LIMITS.replicate },
|
||||||
|
google: { ...DEFAULT_PROVIDER_RATE_LIMITS.google },
|
||||||
|
openai: { ...DEFAULT_PROVIDER_RATE_LIMITS.openai },
|
||||||
|
dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const provider of ["replicate", "google", "openai", "dashscope"] as Provider[]) {
|
||||||
|
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`;
|
||||||
|
const extendLimit = extendConfig.batch?.provider_limits?.[provider];
|
||||||
|
configured[provider] = {
|
||||||
|
concurrency:
|
||||||
|
parsePositiveInt(process.env[`${envPrefix}_CONCURRENCY`]) ??
|
||||||
|
extendLimit?.concurrency ??
|
||||||
|
configured[provider].concurrency,
|
||||||
|
startIntervalMs:
|
||||||
|
parsePositiveInt(process.env[`${envPrefix}_START_INTERVAL_MS`]) ??
|
||||||
|
extendLimit?.start_interval_ms ??
|
||||||
|
configured[provider].startIntervalMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
async function readPromptFromFiles(files: string[]): Promise<string> {
|
async function readPromptFromFiles(files: string[]): Promise<string> {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
@@ -311,9 +498,12 @@ async function readPromptFromFiles(files: string[]): Promise<string> {
|
|||||||
async function readPromptFromStdin(): Promise<string | null> {
|
async function readPromptFromStdin(): Promise<string | null> {
|
||||||
if (process.stdin.isTTY) return null;
|
if (process.stdin.isTTY) return null;
|
||||||
try {
|
try {
|
||||||
const t = await Bun.stdin.text();
|
const chunks: Buffer[] = [];
|
||||||
const v = t.trim();
|
for await (const chunk of process.stdin) {
|
||||||
return v.length > 0 ? v : null;
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const value = Buffer.concat(chunks).toString("utf8").trim();
|
||||||
|
return value.length > 0 ? value : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -327,7 +517,13 @@ function normalizeOutputImagePath(p: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detectProvider(args: CliArgs): Provider {
|
function detectProvider(args: CliArgs): Provider {
|
||||||
if (args.referenceImages.length > 0 && args.provider && args.provider !== "google" && args.provider !== "openai" && args.provider !== "replicate") {
|
if (
|
||||||
|
args.referenceImages.length > 0 &&
|
||||||
|
args.provider &&
|
||||||
|
args.provider !== "google" &&
|
||||||
|
args.provider !== "openai" &&
|
||||||
|
args.provider !== "replicate"
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), or --provider replicate."
|
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), or --provider replicate."
|
||||||
);
|
);
|
||||||
@@ -349,7 +545,12 @@ function detectProvider(args: CliArgs): Provider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = [hasGoogle && "google", hasOpenai && "openai", hasDashscope && "dashscope", hasReplicate && "replicate"].filter(Boolean) as Provider[];
|
const available = [
|
||||||
|
hasGoogle && "google",
|
||||||
|
hasOpenai && "openai",
|
||||||
|
hasDashscope && "dashscope",
|
||||||
|
hasReplicate && "replicate",
|
||||||
|
].filter(Boolean) as Provider[];
|
||||||
|
|
||||||
if (available.length === 1) return available[0]!;
|
if (available.length === 1) return available[0]!;
|
||||||
if (available.length > 1) return available[0]!;
|
if (available.length > 1) return available[0]!;
|
||||||
@@ -371,11 +572,6 @@ async function validateReferenceImages(referenceImages: string[]): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderModule = {
|
|
||||||
getDefaultModel: () => string;
|
|
||||||
generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isRetryableGenerationError(error: unknown): boolean {
|
function isRetryableGenerationError(error: unknown): boolean {
|
||||||
const msg = error instanceof Error ? error.message : String(error);
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
const nonRetryableMarkers = [
|
const nonRetryableMarkers = [
|
||||||
@@ -384,26 +580,353 @@ function isRetryableGenerationError(error: unknown): boolean {
|
|||||||
"only supported",
|
"only supported",
|
||||||
"No API key found",
|
"No API key found",
|
||||||
"is required",
|
"is required",
|
||||||
|
"Invalid ",
|
||||||
|
"Unexpected ",
|
||||||
|
"API error (400)",
|
||||||
|
"API error (401)",
|
||||||
|
"API error (402)",
|
||||||
|
"API error (403)",
|
||||||
|
"API error (404)",
|
||||||
|
"temporarily disabled",
|
||||||
];
|
];
|
||||||
return !nonRetryableMarkers.some((marker) => msg.includes(marker));
|
return !nonRetryableMarkers.some((marker) => msg.includes(marker));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||||
if (provider === "google") {
|
if (provider === "google") return (await import("./providers/google")) as ProviderModule;
|
||||||
return (await import("./providers/google")) as ProviderModule;
|
if (provider === "dashscope") return (await import("./providers/dashscope")) as ProviderModule;
|
||||||
}
|
if (provider === "replicate") return (await import("./providers/replicate")) as ProviderModule;
|
||||||
if (provider === "dashscope") {
|
|
||||||
return (await import("./providers/dashscope")) as ProviderModule;
|
|
||||||
}
|
|
||||||
if (provider === "replicate") {
|
|
||||||
return (await import("./providers/replicate")) as ProviderModule;
|
|
||||||
}
|
|
||||||
return (await import("./providers/openai")) as ProviderModule;
|
return (await import("./providers/openai")) as ProviderModule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPromptForArgs(args: CliArgs): Promise<string | null> {
|
||||||
|
let prompt: string | null = args.prompt;
|
||||||
|
if (!prompt && args.promptFiles.length > 0) {
|
||||||
|
prompt = await readPromptFromFiles(args.promptFiles);
|
||||||
|
}
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelForProvider(
|
||||||
|
provider: Provider,
|
||||||
|
requestedModel: string | null,
|
||||||
|
extendConfig: Partial<ExtendConfig>,
|
||||||
|
providerModule: ProviderModule
|
||||||
|
): string {
|
||||||
|
if (requestedModel) return requestedModel;
|
||||||
|
if (extendConfig.default_model) {
|
||||||
|
if (provider === "google" && extendConfig.default_model.google) return extendConfig.default_model.google;
|
||||||
|
if (provider === "openai" && extendConfig.default_model.openai) return extendConfig.default_model.openai;
|
||||||
|
if (provider === "dashscope" && extendConfig.default_model.dashscope) return extendConfig.default_model.dashscope;
|
||||||
|
if (provider === "replicate" && extendConfig.default_model.replicate) return extendConfig.default_model.replicate;
|
||||||
|
}
|
||||||
|
return providerModule.getDefaultModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareSingleTask(args: CliArgs, extendConfig: Partial<ExtendConfig>): Promise<PreparedTask> {
|
||||||
|
if (!args.quality) args.quality = "2k";
|
||||||
|
|
||||||
|
const prompt = (await loadPromptForArgs(args)) ?? (await readPromptFromStdin());
|
||||||
|
if (!prompt) throw new Error("Prompt is required");
|
||||||
|
if (!args.imagePath) throw new Error("--image is required");
|
||||||
|
if (args.referenceImages.length > 0) await validateReferenceImages(args.referenceImages);
|
||||||
|
|
||||||
|
const provider = detectProvider(args);
|
||||||
|
const providerModule = await loadProviderModule(provider);
|
||||||
|
const model = getModelForProvider(provider, args.model, extendConfig, providerModule);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: "single",
|
||||||
|
prompt,
|
||||||
|
args,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
outputPath: normalizeOutputImagePath(args.imagePath),
|
||||||
|
providerModule,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBatchTasks(batchFilePath: string): Promise<LoadedBatchTasks> {
|
||||||
|
const resolvedBatchFilePath = path.resolve(batchFilePath);
|
||||||
|
const content = await readFile(resolvedBatchFilePath, "utf8");
|
||||||
|
const parsed = JSON.parse(content.replace(/^\uFEFF/, "")) as BatchFile;
|
||||||
|
const batchDir = path.dirname(resolvedBatchFilePath);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return {
|
||||||
|
tasks: parsed,
|
||||||
|
jobs: null,
|
||||||
|
batchDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (parsed && typeof parsed === "object" && Array.isArray(parsed.tasks)) {
|
||||||
|
const jobs = parsePositiveBatchInt(parsed.jobs);
|
||||||
|
if (parsed.jobs !== undefined && parsed.jobs !== null && jobs === null) {
|
||||||
|
throw new Error("Invalid batch file. jobs must be a positive integer when provided.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tasks: parsed.tasks,
|
||||||
|
jobs,
|
||||||
|
batchDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error("Invalid batch file. Expected an array of tasks or an object with a tasks array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBatchPath(batchDir: string, filePath: string): string {
|
||||||
|
return path.isAbsolute(filePath) ? filePath : path.resolve(batchDir, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir: string): CliArgs {
|
||||||
|
return {
|
||||||
|
...baseArgs,
|
||||||
|
prompt: task.prompt ?? null,
|
||||||
|
promptFiles: task.promptFiles ? task.promptFiles.map((filePath) => resolveBatchPath(batchDir, filePath)) : [],
|
||||||
|
imagePath: task.image ? resolveBatchPath(batchDir, task.image) : null,
|
||||||
|
provider: task.provider ?? baseArgs.provider ?? null,
|
||||||
|
model: task.model ?? baseArgs.model ?? null,
|
||||||
|
aspectRatio: task.ar ?? baseArgs.aspectRatio ?? null,
|
||||||
|
size: task.size ?? baseArgs.size ?? null,
|
||||||
|
quality: task.quality ?? baseArgs.quality ?? null,
|
||||||
|
imageSize: task.imageSize ?? baseArgs.imageSize ?? null,
|
||||||
|
referenceImages: task.ref ? task.ref.map((filePath) => resolveBatchPath(batchDir, filePath)) : [],
|
||||||
|
n: task.n ?? baseArgs.n,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: baseArgs.jobs,
|
||||||
|
json: baseArgs.json,
|
||||||
|
help: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareBatchTasks(
|
||||||
|
args: CliArgs,
|
||||||
|
extendConfig: Partial<ExtendConfig>
|
||||||
|
): Promise<{ tasks: PreparedTask[]; jobs: number | null }> {
|
||||||
|
if (!args.batchFile) throw new Error("--batchfile is required in batch mode");
|
||||||
|
const { tasks: taskInputs, jobs: batchJobs, batchDir } = await loadBatchTasks(args.batchFile);
|
||||||
|
if (taskInputs.length === 0) throw new Error("Batch file does not contain any tasks.");
|
||||||
|
|
||||||
|
const prepared: PreparedTask[] = [];
|
||||||
|
for (let i = 0; i < taskInputs.length; i++) {
|
||||||
|
const task = taskInputs[i]!;
|
||||||
|
const taskArgs = createTaskArgs(args, task, batchDir);
|
||||||
|
const prompt = await loadPromptForArgs(taskArgs);
|
||||||
|
if (!prompt) throw new Error(`Task ${i + 1} is missing prompt or promptFiles.`);
|
||||||
|
if (!taskArgs.imagePath) throw new Error(`Task ${i + 1} is missing image output path.`);
|
||||||
|
if (taskArgs.referenceImages.length > 0) await validateReferenceImages(taskArgs.referenceImages);
|
||||||
|
|
||||||
|
const provider = detectProvider(taskArgs);
|
||||||
|
const providerModule = await loadProviderModule(provider);
|
||||||
|
const model = getModelForProvider(provider, taskArgs.model, extendConfig, providerModule);
|
||||||
|
prepared.push({
|
||||||
|
id: task.id || `task-${String(i + 1).padStart(2, "0")}`,
|
||||||
|
prompt,
|
||||||
|
args: taskArgs,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
outputPath: normalizeOutputImagePath(taskArgs.imagePath),
|
||||||
|
providerModule,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tasks: prepared,
|
||||||
|
jobs: args.jobs ?? batchJobs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeImage(outputPath: string, imageData: Uint8Array): Promise<void> {
|
||||||
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
|
await writeFile(outputPath, imageData);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePreparedTask(task: PreparedTask): Promise<TaskResult> {
|
||||||
|
console.error(`Using ${task.provider} / ${task.model} for ${task.id}`);
|
||||||
|
console.error(
|
||||||
|
`Switch model: --model <id> | EXTEND.md default_model.${task.provider} | env ${task.provider.toUpperCase()}_IMAGE_MODEL`
|
||||||
|
);
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
while (attempts < MAX_ATTEMPTS) {
|
||||||
|
attempts += 1;
|
||||||
|
try {
|
||||||
|
const imageData = await task.providerModule.generateImage(task.prompt, task.model, task.args);
|
||||||
|
await writeImage(task.outputPath, imageData);
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
provider: task.provider,
|
||||||
|
model: task.model,
|
||||||
|
outputPath: task.outputPath,
|
||||||
|
success: true,
|
||||||
|
attempts,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const canRetry = attempts < MAX_ATTEMPTS && isRetryableGenerationError(error);
|
||||||
|
if (canRetry) {
|
||||||
|
console.error(`[${task.id}] Attempt ${attempts}/${MAX_ATTEMPTS} failed, retrying...`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
provider: task.provider,
|
||||||
|
model: task.model,
|
||||||
|
outputPath: task.outputPath,
|
||||||
|
success: false,
|
||||||
|
attempts,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
provider: task.provider,
|
||||||
|
model: task.model,
|
||||||
|
outputPath: task.outputPath,
|
||||||
|
success: false,
|
||||||
|
attempts: MAX_ATTEMPTS,
|
||||||
|
error: "Unknown failure",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProviderGate(providerRateLimits: Record<Provider, ProviderRateLimit>) {
|
||||||
|
const state = new Map<Provider, { active: number; lastStartedAt: number }>();
|
||||||
|
|
||||||
|
return async function acquire(provider: Provider): Promise<() => void> {
|
||||||
|
const limit = providerRateLimits[provider];
|
||||||
|
while (true) {
|
||||||
|
const current = state.get(provider) ?? { active: 0, lastStartedAt: 0 };
|
||||||
|
const now = Date.now();
|
||||||
|
const enoughCapacity = current.active < limit.concurrency;
|
||||||
|
const enoughGap = now - current.lastStartedAt >= limit.startIntervalMs;
|
||||||
|
if (enoughCapacity && enoughGap) {
|
||||||
|
state.set(provider, { active: current.active + 1, lastStartedAt: now });
|
||||||
|
return () => {
|
||||||
|
const latest = state.get(provider) ?? { active: 1, lastStartedAt: now };
|
||||||
|
state.set(provider, {
|
||||||
|
active: Math.max(0, latest.active - 1),
|
||||||
|
lastStartedAt: latest.lastStartedAt,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, POLL_WAIT_MS));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkerCount(taskCount: number, jobs: number | null, maxWorkers: number): number {
|
||||||
|
const requested = jobs ?? Math.min(taskCount, maxWorkers);
|
||||||
|
return Math.max(1, Math.min(requested, taskCount, maxWorkers));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBatchTasks(
|
||||||
|
tasks: PreparedTask[],
|
||||||
|
jobs: number | null,
|
||||||
|
extendConfig: Partial<ExtendConfig>
|
||||||
|
): Promise<TaskResult[]> {
|
||||||
|
if (tasks.length === 1) {
|
||||||
|
return [await generatePreparedTask(tasks[0]!)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWorkers = getConfiguredMaxWorkers(extendConfig);
|
||||||
|
const providerRateLimits = getConfiguredProviderRateLimits(extendConfig);
|
||||||
|
const acquireProvider = createProviderGate(providerRateLimits);
|
||||||
|
const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers);
|
||||||
|
console.error(`Batch mode: ${tasks.length} tasks, ${workerCount} workers, parallel mode enabled.`);
|
||||||
|
for (const provider of ["replicate", "google", "openai", "dashscope"] as Provider[]) {
|
||||||
|
const limit = providerRateLimits[provider];
|
||||||
|
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextIndex = 0;
|
||||||
|
const results: TaskResult[] = new Array(tasks.length);
|
||||||
|
|
||||||
|
const worker = async (): Promise<void> => {
|
||||||
|
while (true) {
|
||||||
|
const currentIndex = nextIndex;
|
||||||
|
nextIndex += 1;
|
||||||
|
if (currentIndex >= tasks.length) return;
|
||||||
|
|
||||||
|
const task = tasks[currentIndex]!;
|
||||||
|
const release = await acquireProvider(task.provider);
|
||||||
|
try {
|
||||||
|
results[currentIndex] = await generatePreparedTask(task);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printBatchSummary(results: TaskResult[]): void {
|
||||||
|
const successCount = results.filter((result) => result.success).length;
|
||||||
|
const failureCount = results.length - successCount;
|
||||||
|
|
||||||
|
console.error("");
|
||||||
|
console.error("Batch generation summary:");
|
||||||
|
console.error(`- Total: ${results.length}`);
|
||||||
|
console.error(`- Succeeded: ${successCount}`);
|
||||||
|
console.error(`- Failed: ${failureCount}`);
|
||||||
|
|
||||||
|
if (failureCount > 0) {
|
||||||
|
console.error("Failure reasons:");
|
||||||
|
for (const result of results.filter((item) => !item.success)) {
|
||||||
|
console.error(`- ${result.id}: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitJson(payload: unknown): void {
|
||||||
|
console.log(JSON.stringify(payload, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSingleMode(args: CliArgs, extendConfig: Partial<ExtendConfig>): Promise<void> {
|
||||||
|
const task = await prepareSingleTask(args, extendConfig);
|
||||||
|
const result = await generatePreparedTask(task);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || "Generation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.json) {
|
||||||
|
emitJson({
|
||||||
|
savedImage: result.outputPath,
|
||||||
|
provider: result.provider,
|
||||||
|
model: result.model,
|
||||||
|
attempts: result.attempts,
|
||||||
|
prompt: task.prompt.slice(0, 200),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(result.outputPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBatchMode(args: CliArgs, extendConfig: Partial<ExtendConfig>): Promise<void> {
|
||||||
|
const { tasks, jobs } = await prepareBatchTasks(args, extendConfig);
|
||||||
|
const results = await runBatchTasks(tasks, jobs, extendConfig);
|
||||||
|
printBatchSummary(results);
|
||||||
|
|
||||||
|
if (args.json) {
|
||||||
|
emitJson({
|
||||||
|
mode: "batch",
|
||||||
|
total: results.length,
|
||||||
|
succeeded: results.filter((item) => item.success).length,
|
||||||
|
failed: results.filter((item) => !item.success).length,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.some((item) => !item.success)) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const args = parseArgs(process.argv.slice(2));
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
if (args.help) {
|
if (args.help) {
|
||||||
printUsage();
|
printUsage();
|
||||||
return;
|
return;
|
||||||
@@ -412,86 +935,18 @@ async function main(): Promise<void> {
|
|||||||
await loadEnv();
|
await loadEnv();
|
||||||
const extendConfig = await loadExtendConfig();
|
const extendConfig = await loadExtendConfig();
|
||||||
const mergedArgs = mergeConfig(args, extendConfig);
|
const mergedArgs = mergeConfig(args, extendConfig);
|
||||||
|
|
||||||
if (!mergedArgs.quality) mergedArgs.quality = "2k";
|
if (!mergedArgs.quality) mergedArgs.quality = "2k";
|
||||||
|
|
||||||
let prompt: string | null = mergedArgs.prompt;
|
if (mergedArgs.batchFile) {
|
||||||
if (!prompt && mergedArgs.promptFiles.length > 0) prompt = await readPromptFromFiles(mergedArgs.promptFiles);
|
await runBatchMode(mergedArgs, extendConfig);
|
||||||
if (!prompt) prompt = await readPromptFromStdin();
|
|
||||||
|
|
||||||
if (!prompt) {
|
|
||||||
console.error("Error: Prompt is required");
|
|
||||||
printUsage();
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mergedArgs.imagePath) {
|
await runSingleMode(mergedArgs, extendConfig);
|
||||||
console.error("Error: --image is required");
|
|
||||||
printUsage();
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mergedArgs.referenceImages.length > 0) {
|
|
||||||
await validateReferenceImages(mergedArgs.referenceImages);
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = detectProvider(mergedArgs);
|
|
||||||
const providerModule = await loadProviderModule(provider);
|
|
||||||
|
|
||||||
let model = mergedArgs.model;
|
|
||||||
if (!model && extendConfig.default_model) {
|
|
||||||
if (provider === "google") model = extendConfig.default_model.google ?? null;
|
|
||||||
if (provider === "openai") model = extendConfig.default_model.openai ?? null;
|
|
||||||
if (provider === "dashscope") model = extendConfig.default_model.dashscope ?? null;
|
|
||||||
if (provider === "replicate") model = extendConfig.default_model.replicate ?? null;
|
|
||||||
}
|
|
||||||
model = model || providerModule.getDefaultModel();
|
|
||||||
|
|
||||||
const outputPath = normalizeOutputImagePath(mergedArgs.imagePath);
|
|
||||||
|
|
||||||
let imageData: Uint8Array;
|
|
||||||
let retried = false;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
imageData = await providerModule.generateImage(prompt, model, mergedArgs);
|
|
||||||
break;
|
|
||||||
} catch (e) {
|
|
||||||
if (!retried && isRetryableGenerationError(e)) {
|
|
||||||
retried = true;
|
|
||||||
console.error("Generation failed, retrying...");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dir = path.dirname(outputPath);
|
|
||||||
await mkdir(dir, { recursive: true });
|
|
||||||
await writeFile(outputPath, imageData);
|
|
||||||
|
|
||||||
if (mergedArgs.json) {
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
savedImage: outputPath,
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
prompt: prompt.slice(0, 200),
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(outputPath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => {
|
main().catch((error) => {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.error(msg);
|
console.error(message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { execSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import type { CliArgs } from "../types";
|
import type { CliArgs } from "../types";
|
||||||
|
|
||||||
const GOOGLE_MULTIMODAL_MODELS = [
|
const GOOGLE_MULTIMODAL_MODELS = [
|
||||||
@@ -76,14 +76,43 @@ async function postGoogleJsonViaCurl<T>(
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const proxy = getHttpProxy();
|
const proxy = getHttpProxy();
|
||||||
const bodyStr = JSON.stringify(body);
|
const bodyStr = JSON.stringify(body);
|
||||||
const proxyArgs = proxy ? `-x "${proxy}"` : "";
|
const args = [
|
||||||
|
"-s",
|
||||||
|
"--connect-timeout",
|
||||||
|
"30",
|
||||||
|
"--max-time",
|
||||||
|
"300",
|
||||||
|
...(proxy ? ["-x", proxy] : []),
|
||||||
|
url,
|
||||||
|
"-H",
|
||||||
|
"Content-Type: application/json",
|
||||||
|
"-H",
|
||||||
|
`x-goog-api-key: ${apiKey}`,
|
||||||
|
"-d",
|
||||||
|
"@-",
|
||||||
|
];
|
||||||
|
|
||||||
const result = execSync(
|
let result = "";
|
||||||
`curl -s --connect-timeout 30 --max-time 300 ${proxyArgs} "${url}" -H "Content-Type: application/json" -H "x-goog-api-key: ${apiKey}" -d @-`,
|
try {
|
||||||
{ input: bodyStr, maxBuffer: 100 * 1024 * 1024, timeout: 310000 },
|
result = execFileSync("curl", args, {
|
||||||
);
|
input: bodyStr,
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 100 * 1024 * 1024,
|
||||||
|
timeout: 310000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const e = error as { message?: string; stderr?: string | Buffer };
|
||||||
|
const stderrText =
|
||||||
|
typeof e.stderr === "string"
|
||||||
|
? e.stderr
|
||||||
|
: e.stderr
|
||||||
|
? e.stderr.toString("utf8")
|
||||||
|
: "";
|
||||||
|
const details = stderrText.trim() || e.message || "curl request failed";
|
||||||
|
throw new Error(`Google API request failed via curl: ${details}`);
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = JSON.parse(result.toString()) as any;
|
const parsed = JSON.parse(result) as any;
|
||||||
if (parsed.error) {
|
if (parsed.error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Google API error (${parsed.error.code}): ${parsed.error.message}`,
|
`Google API error (${parsed.error.code}): ${parsed.error.message}`,
|
||||||
|
|||||||
@@ -68,7 +68,11 @@ export async function generateImage(
|
|||||||
const baseURL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
|
const baseURL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
|
||||||
const apiKey = process.env.OPENAI_API_KEY;
|
const apiKey = process.env.OPENAI_API_KEY;
|
||||||
|
|
||||||
if (!apiKey) throw new Error("OPENAI_API_KEY is required");
|
if (!apiKey) {
|
||||||
|
throw new Error(
|
||||||
|
"OPENAI_API_KEY is required. Codex/ChatGPT desktop login does not automatically grant OpenAI Images API access to this script."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.OPENAI_IMAGE_USE_CHAT === "true") {
|
if (process.env.OPENAI_IMAGE_USE_CHAT === "true") {
|
||||||
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
|
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
|
||||||
|
|||||||
@@ -36,22 +36,24 @@ function buildInput(prompt: string, args: CliArgs, referenceImages: string[]): R
|
|||||||
|
|
||||||
if (args.aspectRatio) {
|
if (args.aspectRatio) {
|
||||||
input.aspect_ratio = args.aspectRatio;
|
input.aspect_ratio = args.aspectRatio;
|
||||||
|
} else if (referenceImages.length > 0) {
|
||||||
|
input.aspect_ratio = "match_input_image";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.n > 1) {
|
if (args.n > 1) {
|
||||||
input.number_of_images = args.n;
|
input.number_of_images = args.n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.quality === "normal") {
|
||||||
|
input.resolution = "1K";
|
||||||
|
} else if (args.quality === "2k") {
|
||||||
|
input.resolution = "2K";
|
||||||
|
}
|
||||||
|
|
||||||
input.output_format = "png";
|
input.output_format = "png";
|
||||||
|
|
||||||
if (referenceImages.length > 0) {
|
if (referenceImages.length > 0) {
|
||||||
if (referenceImages.length === 1) {
|
input.image_input = referenceImages;
|
||||||
input.image = referenceImages[0];
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < referenceImages.length; i++) {
|
|
||||||
input[`image${i > 0 ? i + 1 : ""}`] = referenceImages[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return input;
|
return input;
|
||||||
|
|||||||
@@ -13,10 +13,34 @@ export type CliArgs = {
|
|||||||
imageSize: string | null;
|
imageSize: string | null;
|
||||||
referenceImages: string[];
|
referenceImages: string[];
|
||||||
n: number;
|
n: number;
|
||||||
|
batchFile: string | null;
|
||||||
|
jobs: number | null;
|
||||||
json: boolean;
|
json: boolean;
|
||||||
help: boolean;
|
help: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BatchTaskInput = {
|
||||||
|
id?: string;
|
||||||
|
prompt?: string | null;
|
||||||
|
promptFiles?: string[];
|
||||||
|
image?: string;
|
||||||
|
provider?: Provider | null;
|
||||||
|
model?: string | null;
|
||||||
|
ar?: string | null;
|
||||||
|
size?: string | null;
|
||||||
|
quality?: Quality | null;
|
||||||
|
imageSize?: "1K" | "2K" | "4K" | null;
|
||||||
|
ref?: string[];
|
||||||
|
n?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BatchFile =
|
||||||
|
| BatchTaskInput[]
|
||||||
|
| {
|
||||||
|
tasks: BatchTaskInput[];
|
||||||
|
jobs?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type ExtendConfig = {
|
export type ExtendConfig = {
|
||||||
version: number;
|
version: number;
|
||||||
default_provider: Provider | null;
|
default_provider: Provider | null;
|
||||||
@@ -29,4 +53,16 @@ export type ExtendConfig = {
|
|||||||
dashscope: string | null;
|
dashscope: string | null;
|
||||||
replicate: string | null;
|
replicate: string | null;
|
||||||
};
|
};
|
||||||
|
batch?: {
|
||||||
|
max_workers?: number | null;
|
||||||
|
provider_limits?: Partial<
|
||||||
|
Record<
|
||||||
|
Provider,
|
||||||
|
{
|
||||||
|
concurrency?: number | null;
|
||||||
|
start_interval_ms?: number | null;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -147,12 +147,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-infographic/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-infographic/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-infographic/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-infographic/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-infographic/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-infographic/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-infographic/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-infographic/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-infographic/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-infographic/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -30,12 +30,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-markdown-to-html/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-markdown-to-html/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-markdown-to-html/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-markdown-to-html/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-markdown-to-html/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-markdown-to-html/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-markdown-to-html/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-markdown-to-html/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-markdown-to-html/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-markdown-to-html/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ function parseExtendYaml(yaml: string): Partial<ExtendConfig> {
|
|||||||
export function loadExtendConfig(): Partial<ExtendConfig> {
|
export function loadExtendConfig(): Partial<ExtendConfig> {
|
||||||
const paths = [
|
const paths = [
|
||||||
path.join(process.cwd(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
path.join(process.cwd(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
||||||
|
path.join(
|
||||||
|
process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config"),
|
||||||
|
"baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"
|
||||||
|
),
|
||||||
path.join(homedir(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
path.join(homedir(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
||||||
];
|
];
|
||||||
for (const p of paths) {
|
for (const p of paths) {
|
||||||
|
|||||||
@@ -36,12 +36,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-post-to-wechat/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-post-to-wechat/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-post-to-wechat/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-post-to-wechat/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-post-to-wechat/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ function parseExtendYaml(yaml: string): Partial<ExtendConfig> {
|
|||||||
export function loadExtendConfig(): Partial<ExtendConfig> {
|
export function loadExtendConfig(): Partial<ExtendConfig> {
|
||||||
const paths = [
|
const paths = [
|
||||||
path.join(process.cwd(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
path.join(process.cwd(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
||||||
|
path.join(
|
||||||
|
process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config"),
|
||||||
|
"baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"
|
||||||
|
),
|
||||||
path.join(homedir(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
path.join(homedir(), ".baoyu-skills", "baoyu-markdown-to-html", "EXTEND.md"),
|
||||||
];
|
];
|
||||||
for (const p of paths) {
|
for (const p of paths) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { execSync, spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
@@ -10,30 +10,51 @@ function sleep(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
function ab(cmd: string, json = false): string {
|
function quoteForLog(arg: string): string {
|
||||||
const fullCmd = `agent-browser --session ${SESSION} ${cmd}${json ? ' --json' : ''}`;
|
return /[\s"'\\]/.test(arg) ? JSON.stringify(arg) : arg;
|
||||||
console.log(`[ab] ${fullCmd}`);
|
|
||||||
try {
|
|
||||||
const result = execSync(fullCmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
||||||
return result.trim();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const err = e as { stdout?: string; stderr?: string; message?: string };
|
|
||||||
console.error(`[ab] Error: ${err.stderr || err.message}`);
|
|
||||||
return err.stdout || '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function abRaw(args: string[]): { success: boolean; output: string } {
|
function toSafeJsStringLiteral(value: string): string {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
.replace(/\u2028/g, '\\u2028')
|
||||||
|
.replace(/\u2029/g, '\\u2029');
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAgentBrowser(args: string[]): {
|
||||||
|
success: boolean;
|
||||||
|
output: string;
|
||||||
|
spawnError?: string;
|
||||||
|
} {
|
||||||
const result = spawnSync('agent-browser', ['--session', SESSION, ...args], {
|
const result = spawnSync('agent-browser', ['--session', SESSION, ...args], {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['pipe', 'pipe', 'pipe']
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
});
|
});
|
||||||
|
const spawnError = result.error?.message?.trim();
|
||||||
|
const output = result.stdout || result.stderr || '';
|
||||||
return {
|
return {
|
||||||
success: result.status === 0,
|
success: result.status === 0,
|
||||||
output: result.stdout || result.stderr || ''
|
output: output || spawnError || '',
|
||||||
|
spawnError
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ab(args: string[], json = false): string {
|
||||||
|
const fullArgs = json ? [...args, '--json'] : args;
|
||||||
|
console.log(`[ab] agent-browser --session ${SESSION} ${fullArgs.map(quoteForLog).join(' ')}`);
|
||||||
|
const result = runAgentBrowser(fullArgs);
|
||||||
|
if (result.spawnError) {
|
||||||
|
throw new Error(`agent-browser failed to start: ${result.spawnError}`);
|
||||||
|
}
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(`[ab] Error: ${result.output.trim()}`);
|
||||||
|
}
|
||||||
|
return result.output.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function abRaw(args: string[]): { success: boolean; output: string } {
|
||||||
|
return runAgentBrowser(args);
|
||||||
|
}
|
||||||
|
|
||||||
interface SnapshotElement {
|
interface SnapshotElement {
|
||||||
ref: string;
|
ref: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -99,17 +120,17 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('[wechat] Opening WeChat Official Account...');
|
console.log('[wechat] Opening WeChat Official Account...');
|
||||||
ab(`open ${WECHAT_URL} --headed`);
|
ab(['open', WECHAT_URL, '--headed']);
|
||||||
await sleep(5000);
|
await sleep(5000);
|
||||||
|
|
||||||
console.log('[wechat] Checking login status...');
|
console.log('[wechat] Checking login status...');
|
||||||
let url = ab('get url');
|
let url = ab(['get', 'url']);
|
||||||
console.log(`[wechat] Current URL: ${url}`);
|
console.log(`[wechat] Current URL: ${url}`);
|
||||||
|
|
||||||
const waitForLogin = async (timeoutMs = 120_000): Promise<boolean> => {
|
const waitForLogin = async (timeoutMs = 120_000): Promise<boolean> => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
url = ab('get url');
|
url = ab(['get', 'url']);
|
||||||
if (url.includes('/cgi-bin/home')) return true;
|
if (url.includes('/cgi-bin/home')) return true;
|
||||||
console.log('[wechat] Waiting for login...');
|
console.log('[wechat] Waiting for login...');
|
||||||
await sleep(3000);
|
await sleep(3000);
|
||||||
@@ -126,7 +147,7 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
|
|
||||||
console.log('[wechat] Getting page snapshot...');
|
console.log('[wechat] Getting page snapshot...');
|
||||||
let snapshot = ab('snapshot');
|
let snapshot = ab(['snapshot']);
|
||||||
console.log(snapshot);
|
console.log(snapshot);
|
||||||
|
|
||||||
console.log('[wechat] Looking for "图文" menu...');
|
console.log('[wechat] Looking for "图文" menu...');
|
||||||
@@ -134,16 +155,16 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
|
|
||||||
if (!tuWenRef) {
|
if (!tuWenRef) {
|
||||||
console.log('[wechat] Using eval to find and click menu...');
|
console.log('[wechat] Using eval to find and click menu...');
|
||||||
ab(`eval "document.querySelectorAll('.new-creation__menu .new-creation__menu-item')[2].click()"`);
|
ab(['eval', "document.querySelectorAll('.new-creation__menu .new-creation__menu-item')[2].click()"]);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[wechat] Clicking menu ref: ${tuWenRef}`);
|
console.log(`[wechat] Clicking menu ref: ${tuWenRef}`);
|
||||||
ab(`click ${tuWenRef}`);
|
ab(['click', tuWenRef]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sleep(4000);
|
await sleep(4000);
|
||||||
|
|
||||||
console.log('[wechat] Checking for new tab...');
|
console.log('[wechat] Checking for new tab...');
|
||||||
const tabsOutput = ab('tab');
|
const tabsOutput = ab(['tab']);
|
||||||
console.log(`[wechat] Tabs: ${tabsOutput}`);
|
console.log(`[wechat] Tabs: ${tabsOutput}`);
|
||||||
|
|
||||||
const tabLines = tabsOutput.split('\n');
|
const tabLines = tabsOutput.split('\n');
|
||||||
@@ -153,14 +174,14 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
const tabMatch = tabsOutput.match(/\[(\d+)\].*(?:appmsg|edit)/i);
|
const tabMatch = tabsOutput.match(/\[(\d+)\].*(?:appmsg|edit)/i);
|
||||||
if (tabMatch) {
|
if (tabMatch) {
|
||||||
console.log(`[wechat] Switching to editor tab ${tabMatch[1]}...`);
|
console.log(`[wechat] Switching to editor tab ${tabMatch[1]}...`);
|
||||||
ab(`tab ${tabMatch[1]}`);
|
ab(['tab', tabMatch[1]]);
|
||||||
} else {
|
} else {
|
||||||
const lastTabMatch = tabsOutput.match(/\[(\d+)\]/g);
|
const lastTabMatch = tabsOutput.match(/\[(\d+)\]/g);
|
||||||
if (lastTabMatch && lastTabMatch.length > 1) {
|
if (lastTabMatch && lastTabMatch.length > 1) {
|
||||||
const lastTab = lastTabMatch[lastTabMatch.length - 1].match(/\d+/)?.[0];
|
const lastTab = lastTabMatch[lastTabMatch.length - 1].match(/\d+/)?.[0];
|
||||||
if (lastTab) {
|
if (lastTab) {
|
||||||
console.log(`[wechat] Switching to last tab ${lastTab}...`);
|
console.log(`[wechat] Switching to last tab ${lastTab}...`);
|
||||||
ab(`tab ${lastTab}`);
|
ab(['tab', lastTab]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,38 +189,44 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
|
|
||||||
await sleep(3000);
|
await sleep(3000);
|
||||||
|
|
||||||
url = ab('get url');
|
url = ab(['get', 'url']);
|
||||||
console.log(`[wechat] Editor URL: ${url}`);
|
console.log(`[wechat] Editor URL: ${url}`);
|
||||||
|
|
||||||
console.log('[wechat] Getting editor snapshot...');
|
console.log('[wechat] Getting editor snapshot...');
|
||||||
snapshot = ab('snapshot');
|
snapshot = ab(['snapshot']);
|
||||||
console.log(snapshot.substring(0, 2000));
|
console.log(snapshot.substring(0, 2000));
|
||||||
|
|
||||||
console.log('[wechat] Uploading images...');
|
console.log('[wechat] Uploading images...');
|
||||||
const fileInputSelector = '.js_upload_btn_container input[type=file]';
|
const fileInputSelector = '.js_upload_btn_container input[type=file]';
|
||||||
|
const fileInputSelectorJs = toSafeJsStringLiteral(fileInputSelector);
|
||||||
|
|
||||||
ab(`eval "document.querySelector('${fileInputSelector}').style.display = 'block'"`);
|
ab(['eval', `{
|
||||||
|
const input = document.querySelector(${fileInputSelectorJs});
|
||||||
|
if (input) input.style.display = 'block';
|
||||||
|
}`]);
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
const uploadResult = abRaw(['upload', `"${fileInputSelector}"`, ...absoluteImages]);
|
const uploadResult = abRaw(['upload', fileInputSelector, ...absoluteImages]);
|
||||||
console.log(`[wechat] Upload result: ${uploadResult.output}`);
|
console.log(`[wechat] Upload result: ${uploadResult.output}`);
|
||||||
|
|
||||||
if (!uploadResult.success) {
|
if (!uploadResult.success) {
|
||||||
console.log('[wechat] Using alternative upload method...');
|
console.log('[wechat] Using alternative upload method...');
|
||||||
for (const img of absoluteImages) {
|
for (const img of absoluteImages) {
|
||||||
console.log(`[wechat] Uploading: ${img}`);
|
console.log(`[wechat] Uploading: ${img}`);
|
||||||
ab(`eval "
|
const imgUrlJs = toSafeJsStringLiteral(`file://${img}`);
|
||||||
const input = document.querySelector('${fileInputSelector}');
|
const imgFileNameJs = toSafeJsStringLiteral(path.basename(img));
|
||||||
|
ab(['eval', `
|
||||||
|
const input = document.querySelector(${fileInputSelectorJs});
|
||||||
if (input) {
|
if (input) {
|
||||||
const dt = new DataTransfer();
|
const dt = new DataTransfer();
|
||||||
fetch('file://${img}').then(r => r.blob()).then(b => {
|
fetch(${imgUrlJs}).then(r => r.blob()).then(b => {
|
||||||
const file = new File([b], '${path.basename(img)}', { type: 'image/png' });
|
const file = new File([b], ${imgFileNameJs}, { type: 'image/png' });
|
||||||
dt.items.add(file);
|
dt.items.add(file);
|
||||||
input.files = dt.files;
|
input.files = dt.files;
|
||||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"`);
|
`]);
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,13 +235,14 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
await sleep(10000);
|
await sleep(10000);
|
||||||
|
|
||||||
console.log('[wechat] Filling title...');
|
console.log('[wechat] Filling title...');
|
||||||
snapshot = ab('snapshot -i');
|
snapshot = ab(['snapshot', '-i']);
|
||||||
const titleRef = findElementByText(snapshot, 'title') || findElementByText(snapshot, '标题');
|
const titleRef = findElementByText(snapshot, 'title') || findElementByText(snapshot, '标题');
|
||||||
|
|
||||||
if (titleRef) {
|
if (titleRef) {
|
||||||
ab(`fill ${titleRef} "${title.replace(/"/g, '\\"')}"`);
|
ab(['fill', titleRef, title]);
|
||||||
} else {
|
} else {
|
||||||
ab(`eval "const t = document.querySelector('#title'); if(t) { t.value = '${title.replace(/'/g, "\\'")}'; t.dispatchEvent(new Event('input', {bubbles: true})); }"`);
|
const titleJs = toSafeJsStringLiteral(title);
|
||||||
|
ab(['eval', `const t = document.querySelector('#title'); if(t) { t.value = ${titleJs}; t.dispatchEvent(new Event('input', {bubbles: true})); }`]);
|
||||||
}
|
}
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
@@ -222,9 +250,9 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
const editorRef = findElementByText(snapshot, 'js_pmEditorArea') || findElementByText(snapshot, 'textbox');
|
const editorRef = findElementByText(snapshot, 'js_pmEditorArea') || findElementByText(snapshot, 'textbox');
|
||||||
|
|
||||||
if (editorRef) {
|
if (editorRef) {
|
||||||
ab(`click ${editorRef}`);
|
ab(['click', editorRef]);
|
||||||
} else {
|
} else {
|
||||||
ab(`eval "document.querySelector('.js_pmEditorArea')?.click()"`);
|
ab(['eval', "document.querySelector('.js_pmEditorArea')?.click()"]);
|
||||||
}
|
}
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
@@ -233,11 +261,11 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
if (line.length > 0) {
|
if (line.length > 0) {
|
||||||
const escapedLine = line.replace(/"/g, '\\"').replace(/'/g, "\\'");
|
const lineJs = toSafeJsStringLiteral(line);
|
||||||
ab(`eval "document.execCommand('insertText', false, '${escapedLine}')"`);
|
ab(['eval', `document.execCommand('insertText', false, ${lineJs})`]);
|
||||||
}
|
}
|
||||||
if (i < lines.length - 1) {
|
if (i < lines.length - 1) {
|
||||||
ab('press Enter');
|
ab(['press', 'Enter']);
|
||||||
}
|
}
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
}
|
}
|
||||||
@@ -249,9 +277,9 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
console.log('[wechat] Saving as draft...');
|
console.log('[wechat] Saving as draft...');
|
||||||
const submitRef = findElementByText(snapshot, 'js_submit') || findElementByText(snapshot, '保存');
|
const submitRef = findElementByText(snapshot, 'js_submit') || findElementByText(snapshot, '保存');
|
||||||
if (submitRef) {
|
if (submitRef) {
|
||||||
ab(`click ${submitRef}`);
|
ab(['click', submitRef]);
|
||||||
} else {
|
} else {
|
||||||
ab(`eval "document.querySelector('#js_submit')?.click()"`);
|
ab(['eval', "document.querySelector('#js_submit')?.click()"]);
|
||||||
}
|
}
|
||||||
await sleep(3000);
|
await sleep(3000);
|
||||||
console.log('[wechat] Draft saved!');
|
console.log('[wechat] Draft saved!');
|
||||||
@@ -261,7 +289,7 @@ async function postToWeChat(options: WeChatOptions): Promise<void> {
|
|||||||
|
|
||||||
if (!keepOpen) {
|
if (!keepOpen) {
|
||||||
console.log('[wechat] Closing browser...');
|
console.log('[wechat] Closing browser...');
|
||||||
ab('close');
|
ab(['close']);
|
||||||
} else {
|
} else {
|
||||||
console.log('[wechat] Done. Browser window left open.');
|
console.log('[wechat] Done. Browser window left open.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,12 +40,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-post-to-weibo/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-post-to-weibo/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-post-to-weibo/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-post-to-weibo/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-post-to-weibo/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-post-to-weibo/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-post-to-weibo/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-post-to-weibo/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-post-to-weibo/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-post-to-weibo/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -446,7 +446,9 @@ Options:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await main().catch((err) => {
|
if (import.meta.main ?? (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename ?? ''))) {
|
||||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
await main().catch((err) => {
|
||||||
process.exit(1);
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
});
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,12 +44,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-post-to-x/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-post-to-x/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-post-to-x/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-post-to-x/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-post-to-x/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
|||||||
`--user-data-dir=${profileDir}`,
|
`--user-data-dir=${profileDir}`,
|
||||||
'--no-first-run',
|
'--no-first-run',
|
||||||
'--no-default-browser-check',
|
'--no-default-browser-check',
|
||||||
'--disable-blink-features=AutomationControlled',
|
|
||||||
'--start-maximized',
|
'--start-maximized',
|
||||||
X_ARTICLES_URL,
|
X_ARTICLES_URL,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
|||||||
`--user-data-dir=${profileDir}`,
|
`--user-data-dir=${profileDir}`,
|
||||||
'--no-first-run',
|
'--no-first-run',
|
||||||
'--no-default-browser-check',
|
'--no-default-browser-check',
|
||||||
'--disable-blink-features=AutomationControlled',
|
|
||||||
'--start-maximized',
|
'--start-maximized',
|
||||||
X_COMPOSE_URL,
|
X_COMPOSE_URL,
|
||||||
], { stdio: 'ignore' });
|
], { stdio: 'ignore' });
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
|
|||||||
`--user-data-dir=${profileDir}`,
|
`--user-data-dir=${profileDir}`,
|
||||||
'--no-first-run',
|
'--no-first-run',
|
||||||
'--no-default-browser-check',
|
'--no-default-browser-check',
|
||||||
'--disable-blink-features=AutomationControlled',
|
|
||||||
'--start-maximized',
|
'--start-maximized',
|
||||||
tweetUrl,
|
tweetUrl,
|
||||||
], { stdio: 'ignore' });
|
], { stdio: 'ignore' });
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
|||||||
`--user-data-dir=${profileDir}`,
|
`--user-data-dir=${profileDir}`,
|
||||||
'--no-first-run',
|
'--no-first-run',
|
||||||
'--no-default-browser-check',
|
'--no-default-browser-check',
|
||||||
'--disable-blink-features=AutomationControlled',
|
|
||||||
'--start-maximized',
|
'--start-maximized',
|
||||||
X_COMPOSE_URL,
|
X_COMPOSE_URL,
|
||||||
], { stdio: 'ignore' });
|
], { stdio: 'ignore' });
|
||||||
|
|||||||
@@ -203,12 +203,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-slide-deck/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-slide-deck/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-slide-deck/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-slide-deck/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-slide-deck/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-slide-deck/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-slide-deck/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-slide-deck/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-slide-deck/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-slide-deck/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -30,12 +30,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-translate/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-translate/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-translate/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-translate/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-translate/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-translate/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-translate/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-translate/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-translate/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-translate/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -185,7 +188,7 @@ Before translating chunks:
|
|||||||
- Splits at markdown block boundaries to preserve structure
|
- Splits at markdown block boundaries to preserve structure
|
||||||
- If a single block exceeds the threshold, falls back to line splitting, then word splitting
|
- If a single block exceeds the threshold, falls back to line splitting, then word splitting
|
||||||
4. **Assemble translation prompt**:
|
4. **Assemble translation prompt**:
|
||||||
- Main agent reads `01-analysis.md` (if exists) and assembles shared context using Part 1 of [references/subagent-prompt-template.md](references/subagent-prompt-template.md) — inlining content background, merged glossary, and comprehension challenges
|
- Main agent reads `01-analysis.md` (if exists) and assembles shared context using Part 1 of [references/subagent-prompt-template.md](references/subagent-prompt-template.md) — inlining the resolved style preset (from `--style` flag, EXTEND.md `style` setting, or default `storytelling`), content background, merged glossary, and comprehension challenges
|
||||||
- Save as `02-prompt.md` in the output directory (shared context only, no task instructions)
|
- Save as `02-prompt.md` in the output directory (shared context only, no task instructions)
|
||||||
5. **Draft translation via subagents** (if Agent tool available):
|
5. **Draft translation via subagents** (if Agent tool available):
|
||||||
- Spawn one subagent **per chunk**, all in parallel (Part 2 of the template)
|
- Spawn one subagent **per chunk**, all in parallel (Part 2 of the template)
|
||||||
@@ -209,9 +212,10 @@ Before translating chunks:
|
|||||||
- **Natural flow**: Use idiomatic target language word order and sentence patterns; break or restructure sentences freely when the source structure doesn't work naturally in the target language
|
- **Natural flow**: Use idiomatic target language word order and sentence patterns; break or restructure sentences freely when the source structure doesn't work naturally in the target language
|
||||||
- **Terminology**: Use standard translations; annotate with original term in parentheses on first occurrence
|
- **Terminology**: Use standard translations; annotate with original term in parentheses on first occurrence
|
||||||
- **Preserve format**: Keep all markdown formatting (headings, bold, italic, images, links, code blocks)
|
- **Preserve format**: Keep all markdown formatting (headings, bold, italic, images, links, code blocks)
|
||||||
|
- **Image-language awareness**: Preserve image references exactly during translation, but after the translation is complete, review referenced images and check whether their likely main text language still matches the translated article language
|
||||||
- **Frontmatter transformation**: If the source has YAML frontmatter, preserve it in the translation with these changes: (1) Rename metadata fields that describe the *source* article — `url`→`sourceUrl`, `title`→`sourceTitle`, `description`→`sourceDescription`, `author`→`sourceAuthor`, `date`→`sourceDate`, and any similar origin-metadata fields — by adding a `source` prefix (camelCase). (2) Translate the values of text fields (title, description, etc.) and add them as new top-level fields. (3) Keep other fields (tags, categories, custom fields) as-is, translating their values where appropriate
|
- **Frontmatter transformation**: If the source has YAML frontmatter, preserve it in the translation with these changes: (1) Rename metadata fields that describe the *source* article — `url`→`sourceUrl`, `title`→`sourceTitle`, `description`→`sourceDescription`, `author`→`sourceAuthor`, `date`→`sourceDate`, and any similar origin-metadata fields — by adding a `source` prefix (camelCase). (2) Translate the values of text fields (title, description, etc.) and add them as new top-level fields. (3) Keep other fields (tags, categories, custom fields) as-is, translating their values where appropriate
|
||||||
- **Respect original**: Maintain original meaning and intent; do not add, remove, or editorialize — but sentence structure and imagery may be adapted freely to serve the meaning
|
- **Respect original**: Maintain original meaning and intent; do not add, remove, or editorialize — but sentence structure and imagery may be adapted freely to serve the meaning
|
||||||
- **Translator's notes**: For terms, concepts, or cultural references that target readers may not understand — due to jargon, cultural gaps, or domain-specific knowledge — add a concise explanatory note in parentheses immediately after the term. The note should explain *what it means* in plain language, not just provide the English original. Format: `译文(English original,通俗解释)`. Calibrate annotation depth to the target audience: general readers need more notes than technical readers. Only add notes where genuinely needed; do not over-annotate obvious terms.
|
- **Translator's notes**: For terms, concepts, or cultural references that target readers may not understand — due to jargon, cultural gaps, or domain-specific knowledge — add a concise explanatory note in parentheses immediately after the term. The note should explain *what it means* in plain language, not just provide the English original. Format: `译文(English original,通俗解释)`. Calibrate annotation depth to the target audience: general readers need more notes than technical readers. For short texts (< 5 sentences), further reduce annotations — only annotate non-common terms that the target audience is unlikely to know; skip terms that are widely recognized or self-explanatory in context. Only add notes where genuinely needed; do not over-annotate obvious terms.
|
||||||
|
|
||||||
#### Quick Mode
|
#### Quick Mode
|
||||||
|
|
||||||
@@ -220,7 +224,7 @@ Translate directly → save to `translation.md`. No analysis file, but still app
|
|||||||
#### Normal Mode
|
#### Normal Mode
|
||||||
|
|
||||||
1. **Analyze** → `01-analysis.md` (domain, tone, audience, terminology, reader comprehension challenges, figurative language & metaphor mapping)
|
1. **Analyze** → `01-analysis.md` (domain, tone, audience, terminology, reader comprehension challenges, figurative language & metaphor mapping)
|
||||||
2. **Assemble prompt** → `02-prompt.md` (translation instructions with inlined context)
|
2. **Assemble prompt** → `02-prompt.md` (translation instructions with inlined style preset, content background, glossary, and comprehension challenges)
|
||||||
3. **Translate** (following `02-prompt.md`) → `translation.md`
|
3. **Translate** (following `02-prompt.md`) → `translation.md`
|
||||||
|
|
||||||
After completion, prompt user: "Translation saved. To further review and polish, reply **继续润色** or **refine**."
|
After completion, prompt user: "Translation saved. To further review and polish, reply **继续润色** or **refine**."
|
||||||
@@ -247,6 +251,20 @@ Each step reads the previous step's file and builds on it.
|
|||||||
|
|
||||||
Final translation is always at `translation.md` in the output directory.
|
Final translation is always at `translation.md` in the output directory.
|
||||||
|
|
||||||
|
After the final translation is written, do a lightweight image-language pass:
|
||||||
|
|
||||||
|
1. Collect image references from the translated article
|
||||||
|
2. Identify likely text-heavy images such as covers, screenshots, diagrams, charts, frameworks, and infographics
|
||||||
|
3. If any image likely contains a main text language that does not match the translated article language, proactively remind the user
|
||||||
|
4. The reminder must be a list only. Do not automatically localize those images unless the user asks
|
||||||
|
|
||||||
|
Reminder format (use whatever image syntax the article already uses — standard markdown or wikilink):
|
||||||
|
```text
|
||||||
|
Possible image localization needed:
|
||||||
|
- : likely still contains source-language text while the article is now in target language
|
||||||
|
- : likely text-heavy framework graphic, check whether labels need translation
|
||||||
|
```
|
||||||
|
|
||||||
Display summary:
|
Display summary:
|
||||||
```
|
```
|
||||||
**Translation complete** ({mode} mode)
|
**Translation complete** ({mode} mode)
|
||||||
@@ -258,6 +276,8 @@ Final: {output-dir}/translation.md
|
|||||||
Glossary terms applied: {count}
|
Glossary terms applied: {count}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If mismatched image-language candidates were found, append a short note after the summary telling the user that some embedded images may still need image-text localization, followed by the candidate list.
|
||||||
|
|
||||||
## Extension Support
|
## Extension Support
|
||||||
|
|
||||||
Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options.
|
Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options.
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ Implicit assumptions: [unstated premises]
|
|||||||
|
|
||||||
## Step 2: Assemble Translation Prompt
|
## Step 2: Assemble Translation Prompt
|
||||||
|
|
||||||
Main agent reads `01-analysis.md` and assembles a complete translation prompt using [references/subagent-prompt-template.md](subagent-prompt-template.md). Inline content background, merged glossary, and comprehension challenges into the prompt. Save to `02-prompt.md`.
|
Main agent reads `01-analysis.md` and assembles a complete translation prompt using [references/subagent-prompt-template.md](subagent-prompt-template.md). Inline the resolved style preset (from `--style` flag, EXTEND.md `style` setting, or default `storytelling`), content background, merged glossary, and comprehension challenges into the prompt. Save to `02-prompt.md`.
|
||||||
|
|
||||||
This prompt is used by the subagent (chunked) or by the main agent itself (non-chunked).
|
This prompt is used by the subagent (chunked) or by the main agent itself (non-chunked).
|
||||||
|
|
||||||
|
|||||||
@@ -38,12 +38,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-url-to-markdown/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-url-to-markdown/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-url-to-markdown/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-url-to-markdown/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-url-to-markdown/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-url-to-markdown/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-url-to-markdown/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-url-to-markdown/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-url-to-markdown/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-url-to-markdown/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -278,12 +278,15 @@ Check EXTEND.md existence (priority order):
|
|||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-xhs-images/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-xhs-images/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-xhs-images/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-xhs-images/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-xhs-images/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-xhs-images/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-xhs-images/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-xhs-images/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-xhs-images/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-xhs-images/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user